feat: add optional Windows PowerShell tool and settings toggle
- commit
- cfaaf8e6360207ad4099176ad457ee962c3ab0f5
- author
- patriceckhart · · 2026-09-06 18:10 UTC
- parent
- f8649b4
- signature
- unsigned
15 files · +656 -15
M README.md +20 -1
| @@ -247,7 +247,25 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 247 | 247 | - `edit`: one or more exact-match replacements in an existing file. | |
| 248 | 248 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | |
| 249 | 249 | ||
| 250 | - | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | |
| 250 | + | When the sandbox is on (see `/jail`), filesystem tools refuse paths outside the session cwd and `bash` applies best-effort command checks. Jail mode is an accident-prevention guardrail, not a security boundary. | |
| 251 | + | ||
| 252 | + | ### PowerShell tool (Windows) | |
| 253 | + | ||
| 254 | + | The optional `powershell` tool executes native PowerShell commands in the session cwd. On Windows, open `/settings` and toggle **PowerShell tool** to enable or disable it alongside the default tools. It is off by default, persists as `powershell_enabled` in `$ZOT_HOME/config.json`, and updates the live tool list without a restart. The saved preference also applies to other modes on Windows. | |
| 255 | + | ||
| 256 | + | For a one-run tool selection, enable it instead of `bash`: | |
| 257 | + | ||
| 258 | + | ```powershell | |
| 259 | + | zot --tools read,write,edit,glob,powershell | |
| 260 | + | ``` | |
| 261 | + | ||
| 262 | + | Include `bash` in that list to enable both. Explicit `--tools` and `--no-tools` override the saved preference and disable the settings toggle for that run. The toggle is hidden on non-Windows platforms. `!` shell escapes remain unchanged (`cmd /C` on Windows). | |
| 263 | + | ||
| 264 | + | PowerShell execution prefers `pwsh.exe` on `PATH`, falling back to `powershell.exe`. It uses `-NoProfile -NonInteractive -ExecutionPolicy Bypass`, with UTF-8 output and encoded command transport to preserve Unicode and quoting. Administrator-enforced execution policies can still take precedence. Missing executables and non-Windows execution return explicit errors. | |
| 265 | + | ||
| 266 | + | The tool accepts `command` and an optional `timeout` in seconds, with merged streaming stdout/stderr, exit status, and the same output limits as `bash`. On Windows, cancellation terminates the shell and bounds output draining, but does not guarantee termination of descendant processes. | |
| 267 | + | ||
| 268 | + | PowerShell is blocked while `/jail` is active because the existing shell checks do not parse PowerShell syntax. Packaged agents require `permissions.bash.mode: "ask"` (launch-time consent); `none`, `allowlist`, and unknown modes deny PowerShell execution. Bash allowlists are not applied to PowerShell. | |
| 251 | 269 | ||
| 252 | 270 | ## Modes | |
| 253 | 271 | ||
| @@ -403,6 +421,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 403 | 421 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 404 | 422 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 405 | 423 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 424 | + | - **PowerShell tool** (Windows only): enable or disable native PowerShell alongside the default tools. Off by default; saved as `powershell_enabled` and applied without restarting. Explicit `--tools` or `--no-tools` takes precedence and disables this toggle for the run. Execution is still blocked while jailed and remains subject to packaged-agent permissions. | |
| 406 | 425 | - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges. | |
| 407 | 426 | ||
| 408 | 427 | **config.json example:** | |
| @@ -247,7 +247,25 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 247 | - `edit`: one or more exact-match replacements in an existing file. | 247 | - `edit`: one or more exact-match replacements in an existing file. | ||
| 248 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | 248 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | ||
| 249 | 249 | ||||
| 250 | - | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | 250 | + | When the sandbox is on (see `/jail`), filesystem tools refuse paths outside the session cwd and `bash` applies best-effort command checks. Jail mode is an accident-prevention guardrail, not a security boundary. |
| 251 | + | ||||
| 252 | + | ### PowerShell tool (Windows) | |||
| 253 | + | ||||
| 254 | + | The optional `powershell` tool executes native PowerShell commands in the session cwd. On Windows, open `/settings` and toggle **PowerShell tool** to enable or disable it alongside the default tools. It is off by default, persists as `powershell_enabled` in `$ZOT_HOME/config.json`, and updates the live tool list without a restart. The saved preference also applies to other modes on Windows. | |||
| 255 | + | ||||
| 256 | + | For a one-run tool selection, enable it instead of `bash`: | |||
| 257 | + | ||||
| 258 | + | ```powershell | |||
| 259 | + | zot --tools read,write,edit,glob,powershell | |||
| 260 | + | ``` | |||
| 261 | + | ||||
| 262 | + | Include `bash` in that list to enable both. Explicit `--tools` and `--no-tools` override the saved preference and disable the settings toggle for that run. The toggle is hidden on non-Windows platforms. `!` shell escapes remain unchanged (`cmd /C` on Windows). | |||
| 263 | + | ||||
| 264 | + | PowerShell execution prefers `pwsh.exe` on `PATH`, falling back to `powershell.exe`. It uses `-NoProfile -NonInteractive -ExecutionPolicy Bypass`, with UTF-8 output and encoded command transport to preserve Unicode and quoting. Administrator-enforced execution policies can still take precedence. Missing executables and non-Windows execution return explicit errors. | |||
| 265 | + | ||||
| 266 | + | The tool accepts `command` and an optional `timeout` in seconds, with merged streaming stdout/stderr, exit status, and the same output limits as `bash`. On Windows, cancellation terminates the shell and bounds output draining, but does not guarantee termination of descendant processes. | |||
| 267 | + | ||||
| 268 | + | PowerShell is blocked while `/jail` is active because the existing shell checks do not parse PowerShell syntax. Packaged agents require `permissions.bash.mode: "ask"` (launch-time consent); `none`, `allowlist`, and unknown modes deny PowerShell execution. Bash allowlists are not applied to PowerShell. | |||
| 251 | 269 | ||||
| 252 | ## Modes | 270 | ## Modes | ||
| 253 | 271 | ||||
| @@ -403,6 +421,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 403 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 421 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | ||
| 404 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 422 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | ||
| 405 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 423 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 424 | + | - **PowerShell tool** (Windows only): enable or disable native PowerShell alongside the default tools. Off by default; saved as `powershell_enabled` and applied without restarting. Explicit `--tools` or `--no-tools` takes precedence and disables this toggle for the run. Execution is still blocked while jailed and remains subject to packaged-agent permissions. | |||
| 406 | - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges. | 425 | - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges. | ||
| 407 | 426 | ||||
| 408 | **config.json example:** | 427 | **config.json example:** | ||
M docs/zotfiles.md +2 -0
| @@ -401,6 +401,8 @@ Bash is available after the user approves the agent at launch. Consent for an ag | |||
| 401 | 401 | ||
| 402 | 402 | In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation. | |
| 403 | 403 | ||
| 404 | + | The optional Windows `powershell` tool also requires `bash.mode: "ask"` for packaged agents. It is denied in all other permission modes and while jailed. Bash allowlists cannot safely validate PowerShell syntax. Enable the tool via the Windows-only **PowerShell tool** toggle in `/settings` or select it explicitly with `--tools read,write,edit,glob,powershell`; see [PowerShell tool](../README.md#powershell-tool-windows). | |
| 405 | + | ||
| 404 | 406 | ### `allowlist` | |
| 405 | 407 | ||
| 406 | 408 | ```json | |
| @@ -401,6 +401,8 @@ Bash is available after the user approves the agent at launch. Consent for an ag | |||||
| 401 | 401 | ||||
| 402 | In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation. | 402 | In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation. | ||
| 403 | 403 | ||||
| 404 | + | The optional Windows `powershell` tool also requires `bash.mode: "ask"` for packaged agents. It is denied in all other permission modes and while jailed. Bash allowlists cannot safely validate PowerShell syntax. Enable the tool via the Windows-only **PowerShell tool** toggle in `/settings` or select it explicitly with `--tools read,write,edit,glob,powershell`; see [PowerShell tool](../README.md#powershell-tool-windows). | |||
| 405 | + | ||||
| 404 | ### `allowlist` | 406 | ### `allowlist` | ||
| 405 | 407 | ||||
| 406 | ```json | 408 | ```json | ||
M packages/agent/build.go +14 -2
| @@ -578,7 +578,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 578 | 578 | if args.PermissionSet != nil { | |
| 579 | 579 | sandbox.SetPermissions(args.PermissionSet) | |
| 580 | 580 | } | |
| 581 | - | reg := buildToolRegistry(args, args.CWD, sandbox) | |
| 581 | + | reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS) | |
| 582 | 582 | if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() { | |
| 583 | 583 | reg = withOpenRouterServerTools(reg) | |
| 584 | 584 | } | |
| @@ -997,6 +997,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 997 | 997 | v.Sandbox = s | |
| 998 | 998 | case *tools.BashTool: | |
| 999 | 999 | v.Sandbox = s | |
| 1000 | + | case *tools.PowerShellTool: | |
| 1001 | + | v.Sandbox = s | |
| 1000 | 1002 | case *tools.GlobTool: | |
| 1001 | 1003 | v.Sandbox = s | |
| 1002 | 1004 | } | |
| @@ -1064,6 +1066,14 @@ func withOpenRouterServerTools(reg core.Registry) core.Registry { | |||
| 1064 | 1066 | return out | |
| 1065 | 1067 | } | |
| 1066 | 1068 | ||
| 1069 | + | func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry { | |
| 1070 | + | reg := buildToolRegistry(args, cwd, sandbox) | |
| 1071 | + | if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled { | |
| 1072 | + | reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox} | |
| 1073 | + | } | |
| 1074 | + | return reg | |
| 1075 | + | } | |
| 1076 | + | ||
| 1067 | 1077 | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | |
| 1068 | 1078 | if args.NoTools { | |
| 1069 | 1079 | return core.Registry{} | |
| @@ -1082,6 +1092,8 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||
| 1082 | 1092 | } | |
| 1083 | 1093 | return reg | |
| 1084 | 1094 | } | |
| 1095 | + | // PowerShell is explicitly selected, never added to the default tool set. | |
| 1096 | + | all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox} | |
| 1085 | 1097 | for _, name := range args.Tools { | |
| 1086 | 1098 | if t, ok := all[name]; ok { | |
| 1087 | 1099 | reg[name] = t | |
| @@ -1091,7 +1103,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||
| 1091 | 1103 | } | |
| 1092 | 1104 | ||
| 1093 | 1105 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1094 | - | order := []string{"read", "write", "edit", "bash", "glob"} | |
| 1106 | + | order := []string{"read", "write", "edit", "bash", "powershell", "glob"} | |
| 1095 | 1107 | var out []ToolSummary | |
| 1096 | 1108 | for _, name := range order { | |
| 1097 | 1109 | if t, ok := reg[name]; ok { | |
| @@ -578,7 +578,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 578 | if args.PermissionSet != nil { | 578 | if args.PermissionSet != nil { | ||
| 579 | sandbox.SetPermissions(args.PermissionSet) | 579 | sandbox.SetPermissions(args.PermissionSet) | ||
| 580 | } | 580 | } | ||
| 581 | - | reg := buildToolRegistry(args, args.CWD, sandbox) | 581 | + | reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS) |
| 582 | if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() { | 582 | if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() { | ||
| 583 | reg = withOpenRouterServerTools(reg) | 583 | reg = withOpenRouterServerTools(reg) | ||
| 584 | } | 584 | } | ||
| @@ -997,6 +997,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 997 | v.Sandbox = s | 997 | v.Sandbox = s | ||
| 998 | case *tools.BashTool: | 998 | case *tools.BashTool: | ||
| 999 | v.Sandbox = s | 999 | v.Sandbox = s | ||
| 1000 | + | case *tools.PowerShellTool: | |||
| 1001 | + | v.Sandbox = s | |||
| 1000 | case *tools.GlobTool: | 1002 | case *tools.GlobTool: | ||
| 1001 | v.Sandbox = s | 1003 | v.Sandbox = s | ||
| 1002 | } | 1004 | } | ||
| @@ -1064,6 +1066,14 @@ func withOpenRouterServerTools(reg core.Registry) core.Registry { | |||||
| 1064 | return out | 1066 | return out | ||
| 1065 | } | 1067 | } | ||
| 1066 | 1068 | ||||
| 1069 | + | func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry { | |||
| 1070 | + | reg := buildToolRegistry(args, cwd, sandbox) | |||
| 1071 | + | if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled { | |||
| 1072 | + | reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox} | |||
| 1073 | + | } | |||
| 1074 | + | return reg | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1067 | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | 1077 | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | ||
| 1068 | if args.NoTools { | 1078 | if args.NoTools { | ||
| 1069 | return core.Registry{} | 1079 | return core.Registry{} | ||
| @@ -1082,6 +1092,8 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||||
| 1082 | } | 1092 | } | ||
| 1083 | return reg | 1093 | return reg | ||
| 1084 | } | 1094 | } | ||
| 1095 | + | // PowerShell is explicitly selected, never added to the default tool set. | |||
| 1096 | + | all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox} | |||
| 1085 | for _, name := range args.Tools { | 1097 | for _, name := range args.Tools { | ||
| 1086 | if t, ok := all[name]; ok { | 1098 | if t, ok := all[name]; ok { | ||
| 1087 | reg[name] = t | 1099 | reg[name] = t | ||
| @@ -1091,7 +1103,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||||
| 1091 | } | 1103 | } | ||
| 1092 | 1104 | ||||
| 1093 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1105 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1094 | - | order := []string{"read", "write", "edit", "bash", "glob"} | 1106 | + | order := []string{"read", "write", "edit", "bash", "powershell", "glob"} |
| 1095 | var out []ToolSummary | 1107 | var out []ToolSummary | ||
| 1096 | for _, name := range order { | 1108 | for _, name := range order { | ||
| 1097 | if t, ok := reg[name]; ok { | 1109 | if t, ok := reg[name]; ok { | ||
M packages/agent/cli.go +2 -0
| @@ -1280,6 +1280,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1280 | 1280 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1281 | 1281 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1282 | 1282 | JailByDefault: initialCfg.JailByDefault, | |
| 1283 | + | PowerShellEnabled: initialCfg.PowerShellEnabled, | |
| 1284 | + | ToolSelectionExplicit: len(args.Tools) > 0, | |
| 1283 | 1285 | OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled, | |
| 1284 | 1286 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | |
| 1285 | 1287 | NoTools: args.NoTools, | |
| @@ -1280,6 +1280,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1280 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 1280 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 1281 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 1281 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | ||
| 1282 | JailByDefault: initialCfg.JailByDefault, | 1282 | JailByDefault: initialCfg.JailByDefault, | ||
| 1283 | + | PowerShellEnabled: initialCfg.PowerShellEnabled, | |||
| 1284 | + | ToolSelectionExplicit: len(args.Tools) > 0, | |||
| 1283 | OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled, | 1285 | OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled, | ||
| 1284 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | 1286 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | ||
| 1285 | NoTools: args.NoTools, | 1287 | NoTools: args.NoTools, | ||
M packages/agent/config.go +4 -0
| @@ -68,6 +68,10 @@ type Config struct { | |||
| 68 | 68 | // The live session can still override this with /jail or /unjail. | |
| 69 | 69 | JailByDefault *bool `json:"jail_by_default,omitempty"` | |
| 70 | 70 | ||
| 71 | + | // PowerShellEnabled adds the optional PowerShell tool to the default tool | |
| 72 | + | // set on Windows. Explicit --tools and --no-tools override this preference. | |
| 73 | + | PowerShellEnabled *bool `json:"powershell_enabled,omitempty"` | |
| 74 | + | ||
| 71 | 75 | // OpenRouterServerToolsEnabled advertises OpenRouter server tools | |
| 72 | 76 | // (web search, datetime, advisor, and others) when the provider is OpenRouter. | |
| 73 | 77 | // Default off: users explicitly opt in from /settings. | |
| @@ -68,6 +68,10 @@ type Config struct { | |||||
| 68 | // The live session can still override this with /jail or /unjail. | 68 | // The live session can still override this with /jail or /unjail. | ||
| 69 | JailByDefault *bool `json:"jail_by_default,omitempty"` | 69 | JailByDefault *bool `json:"jail_by_default,omitempty"` | ||
| 70 | 70 | ||||
| 71 | + | // PowerShellEnabled adds the optional PowerShell tool to the default tool | |||
| 72 | + | // set on Windows. Explicit --tools and --no-tools override this preference. | |||
| 73 | + | PowerShellEnabled *bool `json:"powershell_enabled,omitempty"` | |||
| 74 | + | ||||
| 71 | // OpenRouterServerToolsEnabled advertises OpenRouter server tools | 75 | // OpenRouterServerToolsEnabled advertises OpenRouter server tools | ||
| 72 | // (web search, datetime, advisor, and others) when the provider is OpenRouter. | 76 | // (web search, datetime, advisor, and others) when the provider is OpenRouter. | ||
| 73 | // Default off: users explicitly opt in from /settings. | 77 | // Default off: users explicitly opt in from /settings. | ||
M packages/agent/modes/interactive.go +9 -0
| @@ -73,6 +73,12 @@ type InteractiveConfig struct { | |||
| 73 | 73 | // The current sandbox may differ after a session-scoped /jail or /unjail. | |
| 74 | 74 | JailByDefault *bool | |
| 75 | 75 | ||
| 76 | + | // PowerShellEnabled mirrors the Windows-only default tool preference. | |
| 77 | + | PowerShellEnabled *bool | |
| 78 | + | ||
| 79 | + | // ToolSelectionExplicit preserves an explicit --tools selection. | |
| 80 | + | ToolSelectionExplicit bool | |
| 81 | + | ||
| 76 | 82 | // OpenRouterServerToolsEnabled mirrors the persisted preference. | |
| 77 | 83 | // nil means the default, off. | |
| 78 | 84 | OpenRouterServerToolsEnabled *bool | |
| @@ -3621,6 +3627,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3621 | 3627 | children: quickItems, | |
| 3622 | 3628 | }) | |
| 3623 | 3629 | } | |
| 3630 | + | items = i.appendPowerShellSetting(items, runtime.GOOS) | |
| 3624 | 3631 | i.settingsDialog.Open(items) | |
| 3625 | 3632 | } | |
| 3626 | 3633 | ||
| @@ -3912,6 +3919,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3912 | 3919 | i.statusOK = "jail by default " + onOff(value) | |
| 3913 | 3920 | i.statusErr = "" | |
| 3914 | 3921 | i.mu.Unlock() | |
| 3922 | + | case "powershell_enabled": | |
| 3923 | + | i.applyPowerShellSetting(value, runtime.GOOS) | |
| 3915 | 3924 | case "openrouter_server_tools_enabled": | |
| 3916 | 3925 | val := value | |
| 3917 | 3926 | i.cfg.OpenRouterServerToolsEnabled = &val | |
| @@ -73,6 +73,12 @@ type InteractiveConfig struct { | |||||
| 73 | // The current sandbox may differ after a session-scoped /jail or /unjail. | 73 | // The current sandbox may differ after a session-scoped /jail or /unjail. | ||
| 74 | JailByDefault *bool | 74 | JailByDefault *bool | ||
| 75 | 75 | ||||
| 76 | + | // PowerShellEnabled mirrors the Windows-only default tool preference. | |||
| 77 | + | PowerShellEnabled *bool | |||
| 78 | + | ||||
| 79 | + | // ToolSelectionExplicit preserves an explicit --tools selection. | |||
| 80 | + | ToolSelectionExplicit bool | |||
| 81 | + | ||||
| 76 | // OpenRouterServerToolsEnabled mirrors the persisted preference. | 82 | // OpenRouterServerToolsEnabled mirrors the persisted preference. | ||
| 77 | // nil means the default, off. | 83 | // nil means the default, off. | ||
| 78 | OpenRouterServerToolsEnabled *bool | 84 | OpenRouterServerToolsEnabled *bool | ||
| @@ -3621,6 +3627,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3621 | children: quickItems, | 3627 | children: quickItems, | ||
| 3622 | }) | 3628 | }) | ||
| 3623 | } | 3629 | } | ||
| 3630 | + | items = i.appendPowerShellSetting(items, runtime.GOOS) | |||
| 3624 | i.settingsDialog.Open(items) | 3631 | i.settingsDialog.Open(items) | ||
| 3625 | } | 3632 | } | ||
| 3626 | 3633 | ||||
| @@ -3912,6 +3919,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 3912 | i.statusOK = "jail by default " + onOff(value) | 3919 | i.statusOK = "jail by default " + onOff(value) | ||
| 3913 | i.statusErr = "" | 3920 | i.statusErr = "" | ||
| 3914 | i.mu.Unlock() | 3921 | i.mu.Unlock() | ||
| 3922 | + | case "powershell_enabled": | |||
| 3923 | + | i.applyPowerShellSetting(value, runtime.GOOS) | |||
| 3915 | case "openrouter_server_tools_enabled": | 3924 | case "openrouter_server_tools_enabled": | ||
| 3916 | val := value | 3925 | val := value | ||
| 3917 | i.cfg.OpenRouterServerToolsEnabled = &val | 3926 | i.cfg.OpenRouterServerToolsEnabled = &val | ||
A packages/agent/modes/powershell_setting.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Optional so existing SettingsStore implementations remain compatible. | |
| 11 | + | type powerShellSettingsStore interface { | |
| 12 | + | SetPowerShell(enabled bool) error | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (i *Interactive) powerShellSettingHint() string { | |
| 16 | + | switch { | |
| 17 | + | case i.cfg.NoTools: | |
| 18 | + | return "disabled for this run by --no-tools" | |
| 19 | + | case i.cfg.ToolSelectionExplicit: | |
| 20 | + | return "controlled for this run by --tools" | |
| 21 | + | } | |
| 22 | + | if i.agent != nil { | |
| 23 | + | if tool := i.agent.Tools["powershell"]; tool != nil { | |
| 24 | + | if _, builtin := tool.(*tools.PowerShellTool); !builtin { | |
| 25 | + | return "controlled by a custom tool" | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | return "" | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (i *Interactive) appendPowerShellSetting(items []settingsItem, goos string) []settingsItem { | |
| 33 | + | if goos != "windows" { | |
| 34 | + | return items | |
| 35 | + | } | |
| 36 | + | hint := i.powerShellSettingHint() | |
| 37 | + | value := i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled | |
| 38 | + | if hint != "" && i.agent != nil { | |
| 39 | + | value = i.agent.Tools["powershell"] != nil | |
| 40 | + | } | |
| 41 | + | return append(items, settingsItem{ | |
| 42 | + | key: "powershell_enabled", label: "PowerShell tool", | |
| 43 | + | desc: "enable native PowerShell alongside the default tools; execution remains blocked while jailed", | |
| 44 | + | value: value, disabled: hint != "", hint: hint, | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (i *Interactive) applyPowerShellSetting(value bool, goos string) { | |
| 49 | + | var err error | |
| 50 | + | switch { | |
| 51 | + | case goos != "windows": | |
| 52 | + | err = fmt.Errorf("PowerShell is only available on Windows") | |
| 53 | + | case i.powerShellSettingHint() != "": | |
| 54 | + | err = fmt.Errorf("%s", i.powerShellSettingHint()) | |
| 55 | + | default: | |
| 56 | + | if i.cfg.SettingsStore != nil { | |
| 57 | + | if store, ok := i.cfg.SettingsStore.(powerShellSettingsStore); ok { | |
| 58 | + | err = store.SetPowerShell(value) | |
| 59 | + | } else { | |
| 60 | + | err = fmt.Errorf("settings store does not support PowerShell") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if err != nil { | |
| 65 | + | i.mu.Lock() | |
| 66 | + | i.statusErr = "settings: " + err.Error() | |
| 67 | + | i.statusOK = "" | |
| 68 | + | i.mu.Unlock() | |
| 69 | + | // Restore the row that the dialog optimistically toggled. | |
| 70 | + | if i.settingsDialog != nil { | |
| 71 | + | for idx := range i.settingsDialog.items { | |
| 72 | + | if i.settingsDialog.items[idx].key == "powershell_enabled" { | |
| 73 | + | i.settingsDialog.items[idx].value = i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | i.cfg.PowerShellEnabled = &value | |
| 81 | + | if i.agent != nil { | |
| 82 | + | next := core.Registry{} | |
| 83 | + | for name, tool := range i.agent.Tools { | |
| 84 | + | if name != "powershell" { | |
| 85 | + | next[name] = tool | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | if value { | |
| 89 | + | next["powershell"] = &tools.PowerShellTool{CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox} | |
| 90 | + | } | |
| 91 | + | i.agent.SetTools(next) | |
| 92 | + | } | |
| 93 | + | i.mu.Lock() | |
| 94 | + | i.statusOK = "PowerShell tool " + onOff(value) | |
| 95 | + | i.statusErr = "" | |
| 96 | + | i.mu.Unlock() | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Optional so existing SettingsStore implementations remain compatible. | |||
| 11 | + | type powerShellSettingsStore interface { | |||
| 12 | + | SetPowerShell(enabled bool) error | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (i *Interactive) powerShellSettingHint() string { | |||
| 16 | + | switch { | |||
| 17 | + | case i.cfg.NoTools: | |||
| 18 | + | return "disabled for this run by --no-tools" | |||
| 19 | + | case i.cfg.ToolSelectionExplicit: | |||
| 20 | + | return "controlled for this run by --tools" | |||
| 21 | + | } | |||
| 22 | + | if i.agent != nil { | |||
| 23 | + | if tool := i.agent.Tools["powershell"]; tool != nil { | |||
| 24 | + | if _, builtin := tool.(*tools.PowerShellTool); !builtin { | |||
| 25 | + | return "controlled by a custom tool" | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | return "" | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (i *Interactive) appendPowerShellSetting(items []settingsItem, goos string) []settingsItem { | |||
| 33 | + | if goos != "windows" { | |||
| 34 | + | return items | |||
| 35 | + | } | |||
| 36 | + | hint := i.powerShellSettingHint() | |||
| 37 | + | value := i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled | |||
| 38 | + | if hint != "" && i.agent != nil { | |||
| 39 | + | value = i.agent.Tools["powershell"] != nil | |||
| 40 | + | } | |||
| 41 | + | return append(items, settingsItem{ | |||
| 42 | + | key: "powershell_enabled", label: "PowerShell tool", | |||
| 43 | + | desc: "enable native PowerShell alongside the default tools; execution remains blocked while jailed", | |||
| 44 | + | value: value, disabled: hint != "", hint: hint, | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (i *Interactive) applyPowerShellSetting(value bool, goos string) { | |||
| 49 | + | var err error | |||
| 50 | + | switch { | |||
| 51 | + | case goos != "windows": | |||
| 52 | + | err = fmt.Errorf("PowerShell is only available on Windows") | |||
| 53 | + | case i.powerShellSettingHint() != "": | |||
| 54 | + | err = fmt.Errorf("%s", i.powerShellSettingHint()) | |||
| 55 | + | default: | |||
| 56 | + | if i.cfg.SettingsStore != nil { | |||
| 57 | + | if store, ok := i.cfg.SettingsStore.(powerShellSettingsStore); ok { | |||
| 58 | + | err = store.SetPowerShell(value) | |||
| 59 | + | } else { | |||
| 60 | + | err = fmt.Errorf("settings store does not support PowerShell") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if err != nil { | |||
| 65 | + | i.mu.Lock() | |||
| 66 | + | i.statusErr = "settings: " + err.Error() | |||
| 67 | + | i.statusOK = "" | |||
| 68 | + | i.mu.Unlock() | |||
| 69 | + | // Restore the row that the dialog optimistically toggled. | |||
| 70 | + | if i.settingsDialog != nil { | |||
| 71 | + | for idx := range i.settingsDialog.items { | |||
| 72 | + | if i.settingsDialog.items[idx].key == "powershell_enabled" { | |||
| 73 | + | i.settingsDialog.items[idx].value = i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | i.cfg.PowerShellEnabled = &value | |||
| 81 | + | if i.agent != nil { | |||
| 82 | + | next := core.Registry{} | |||
| 83 | + | for name, tool := range i.agent.Tools { | |||
| 84 | + | if name != "powershell" { | |||
| 85 | + | next[name] = tool | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | if value { | |||
| 89 | + | next["powershell"] = &tools.PowerShellTool{CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox} | |||
| 90 | + | } | |||
| 91 | + | i.agent.SetTools(next) | |||
| 92 | + | } | |||
| 93 | + | i.mu.Lock() | |||
| 94 | + | i.statusOK = "PowerShell tool " + onOff(value) | |||
| 95 | + | i.statusErr = "" | |||
| 96 | + | i.mu.Unlock() | |||
| 97 | + | } | |||
A packages/agent/modes/powershell_setting_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type testPowerShellStore struct { | |
| 13 | + | SettingsStore | |
| 14 | + | values []bool | |
| 15 | + | err error | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s *testPowerShellStore) SetPowerShell(value bool) error { | |
| 19 | + | if s.err != nil { | |
| 20 | + | return s.err | |
| 21 | + | } | |
| 22 | + | s.values = append(s.values, value) | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestPowerShellSettingLiveToggle(t *testing.T) { | |
| 27 | + | cwd := t.TempDir() | |
| 28 | + | sb := tools.NewSandbox(cwd) | |
| 29 | + | store := &testPowerShellStore{} | |
| 30 | + | read := &tools.ReadTool{CWD: cwd} | |
| 31 | + | original := core.Registry{"read": read, "custom": read} | |
| 32 | + | i := &Interactive{ | |
| 33 | + | cfg: InteractiveConfig{CWD: cwd, Sandbox: sb, SettingsStore: store}, | |
| 34 | + | agent: core.NewAgent(nil, "", "", original), | |
| 35 | + | } | |
| 36 | + | for _, value := range []bool{true, false} { | |
| 37 | + | i.applyPowerShellSetting(value, "windows") | |
| 38 | + | if i.statusErr != "" { | |
| 39 | + | t.Fatal(i.statusErr) | |
| 40 | + | } | |
| 41 | + | if i.cfg.PowerShellEnabled == nil || *i.cfg.PowerShellEnabled != value { | |
| 42 | + | t.Fatal("preference not updated") | |
| 43 | + | } | |
| 44 | + | if (i.agent.Tools["powershell"] != nil) != value { | |
| 45 | + | t.Fatal("live registry not updated") | |
| 46 | + | } | |
| 47 | + | if value { | |
| 48 | + | ps := i.agent.Tools["powershell"].(*tools.PowerShellTool) | |
| 49 | + | if ps.CWD != cwd || ps.Sandbox != sb { | |
| 50 | + | t.Fatal("tool lost session context") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | if i.agent.Tools["custom"] != read || i.agent.Tools["read"] != read { | |
| 54 | + | t.Fatal("unrelated tools changed") | |
| 55 | + | } | |
| 56 | + | if original["powershell"] != nil { | |
| 57 | + | t.Fatal("original registry mutated") | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if len(store.values) != 2 || !store.values[0] || store.values[1] { | |
| 61 | + | t.Fatalf("saved: %v", store.values) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestPowerShellSettingFailurePreservesState(t *testing.T) { | |
| 66 | + | store := &testPowerShellStore{err: errors.New("save failed")} | |
| 67 | + | i := &Interactive{ | |
| 68 | + | cfg: InteractiveConfig{SettingsStore: store}, | |
| 69 | + | agent: core.NewAgent(nil, "", "", core.Registry{}), | |
| 70 | + | settingsDialog: newSettingsDialog(), | |
| 71 | + | } | |
| 72 | + | i.settingsDialog.Open(i.appendPowerShellSetting(nil, "windows")) | |
| 73 | + | action := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 74 | + | if !action.Toggle || !action.Value { | |
| 75 | + | t.Fatal("dialog did not enable") | |
| 76 | + | } | |
| 77 | + | i.applyPowerShellSetting(action.Value, "windows") | |
| 78 | + | if i.statusErr != "settings: save failed" { | |
| 79 | + | t.Fatalf("error: %s", i.statusErr) | |
| 80 | + | } | |
| 81 | + | if i.cfg.PowerShellEnabled != nil || i.agent.Tools["powershell"] != nil || i.settingsDialog.items[0].value { | |
| 82 | + | t.Fatal("failed save changed state") | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPowerShellSettingPlatformAndOverrides(t *testing.T) { | |
| 87 | + | for _, tc := range []struct { | |
| 88 | + | name, goos string | |
| 89 | + | cfg InteractiveConfig | |
| 90 | + | custom bool | |
| 91 | + | }{ | |
| 92 | + | {"non-Windows", "darwin", InteractiveConfig{}, false}, | |
| 93 | + | {"no tools", "windows", InteractiveConfig{NoTools: true}, false}, | |
| 94 | + | {"explicit tools", "windows", InteractiveConfig{ToolSelectionExplicit: true}, false}, | |
| 95 | + | {"custom tool", "windows", InteractiveConfig{}, true}, | |
| 96 | + | } { | |
| 97 | + | t.Run(tc.name, func(t *testing.T) { | |
| 98 | + | store := &testPowerShellStore{} | |
| 99 | + | tc.cfg.SettingsStore = store | |
| 100 | + | reg := core.Registry{} | |
| 101 | + | if tc.custom { | |
| 102 | + | reg["powershell"] = &tools.ReadTool{} | |
| 103 | + | } | |
| 104 | + | i := &Interactive{cfg: tc.cfg, agent: core.NewAgent(nil, "", "", reg)} | |
| 105 | + | items := i.appendPowerShellSetting(nil, tc.goos) | |
| 106 | + | if tc.goos != "windows" { | |
| 107 | + | if len(items) != 0 { | |
| 108 | + | t.Fatal("setting shown on non-Windows") | |
| 109 | + | } | |
| 110 | + | } else if len(items) != 1 || !items[0].disabled || items[0].hint == "" { | |
| 111 | + | t.Fatalf("setting: %+v", items) | |
| 112 | + | } | |
| 113 | + | i.applyPowerShellSetting(true, tc.goos) | |
| 114 | + | if i.statusErr == "" || len(store.values) != 0 || i.cfg.PowerShellEnabled != nil { | |
| 115 | + | t.Fatal("override not respected") | |
| 116 | + | } | |
| 117 | + | if i.agent.Tools["powershell"] != reg["powershell"] { | |
| 118 | + | t.Fatal("registry changed") | |
| 119 | + | } | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestPowerShellSettingReflectsPreference(t *testing.T) { | |
| 125 | + | for _, enabled := range []bool{true, false} { | |
| 126 | + | i := &Interactive{cfg: InteractiveConfig{PowerShellEnabled: &enabled}} | |
| 127 | + | items := i.appendPowerShellSetting(nil, "windows") | |
| 128 | + | if len(items) != 1 || items[0].value != enabled || items[0].disabled { | |
| 129 | + | t.Fatalf("setting: %+v", items) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type testPowerShellStore struct { | |||
| 13 | + | SettingsStore | |||
| 14 | + | values []bool | |||
| 15 | + | err error | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (s *testPowerShellStore) SetPowerShell(value bool) error { | |||
| 19 | + | if s.err != nil { | |||
| 20 | + | return s.err | |||
| 21 | + | } | |||
| 22 | + | s.values = append(s.values, value) | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestPowerShellSettingLiveToggle(t *testing.T) { | |||
| 27 | + | cwd := t.TempDir() | |||
| 28 | + | sb := tools.NewSandbox(cwd) | |||
| 29 | + | store := &testPowerShellStore{} | |||
| 30 | + | read := &tools.ReadTool{CWD: cwd} | |||
| 31 | + | original := core.Registry{"read": read, "custom": read} | |||
| 32 | + | i := &Interactive{ | |||
| 33 | + | cfg: InteractiveConfig{CWD: cwd, Sandbox: sb, SettingsStore: store}, | |||
| 34 | + | agent: core.NewAgent(nil, "", "", original), | |||
| 35 | + | } | |||
| 36 | + | for _, value := range []bool{true, false} { | |||
| 37 | + | i.applyPowerShellSetting(value, "windows") | |||
| 38 | + | if i.statusErr != "" { | |||
| 39 | + | t.Fatal(i.statusErr) | |||
| 40 | + | } | |||
| 41 | + | if i.cfg.PowerShellEnabled == nil || *i.cfg.PowerShellEnabled != value { | |||
| 42 | + | t.Fatal("preference not updated") | |||
| 43 | + | } | |||
| 44 | + | if (i.agent.Tools["powershell"] != nil) != value { | |||
| 45 | + | t.Fatal("live registry not updated") | |||
| 46 | + | } | |||
| 47 | + | if value { | |||
| 48 | + | ps := i.agent.Tools["powershell"].(*tools.PowerShellTool) | |||
| 49 | + | if ps.CWD != cwd || ps.Sandbox != sb { | |||
| 50 | + | t.Fatal("tool lost session context") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | if i.agent.Tools["custom"] != read || i.agent.Tools["read"] != read { | |||
| 54 | + | t.Fatal("unrelated tools changed") | |||
| 55 | + | } | |||
| 56 | + | if original["powershell"] != nil { | |||
| 57 | + | t.Fatal("original registry mutated") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if len(store.values) != 2 || !store.values[0] || store.values[1] { | |||
| 61 | + | t.Fatalf("saved: %v", store.values) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestPowerShellSettingFailurePreservesState(t *testing.T) { | |||
| 66 | + | store := &testPowerShellStore{err: errors.New("save failed")} | |||
| 67 | + | i := &Interactive{ | |||
| 68 | + | cfg: InteractiveConfig{SettingsStore: store}, | |||
| 69 | + | agent: core.NewAgent(nil, "", "", core.Registry{}), | |||
| 70 | + | settingsDialog: newSettingsDialog(), | |||
| 71 | + | } | |||
| 72 | + | i.settingsDialog.Open(i.appendPowerShellSetting(nil, "windows")) | |||
| 73 | + | action := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 74 | + | if !action.Toggle || !action.Value { | |||
| 75 | + | t.Fatal("dialog did not enable") | |||
| 76 | + | } | |||
| 77 | + | i.applyPowerShellSetting(action.Value, "windows") | |||
| 78 | + | if i.statusErr != "settings: save failed" { | |||
| 79 | + | t.Fatalf("error: %s", i.statusErr) | |||
| 80 | + | } | |||
| 81 | + | if i.cfg.PowerShellEnabled != nil || i.agent.Tools["powershell"] != nil || i.settingsDialog.items[0].value { | |||
| 82 | + | t.Fatal("failed save changed state") | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestPowerShellSettingPlatformAndOverrides(t *testing.T) { | |||
| 87 | + | for _, tc := range []struct { | |||
| 88 | + | name, goos string | |||
| 89 | + | cfg InteractiveConfig | |||
| 90 | + | custom bool | |||
| 91 | + | }{ | |||
| 92 | + | {"non-Windows", "darwin", InteractiveConfig{}, false}, | |||
| 93 | + | {"no tools", "windows", InteractiveConfig{NoTools: true}, false}, | |||
| 94 | + | {"explicit tools", "windows", InteractiveConfig{ToolSelectionExplicit: true}, false}, | |||
| 95 | + | {"custom tool", "windows", InteractiveConfig{}, true}, | |||
| 96 | + | } { | |||
| 97 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 98 | + | store := &testPowerShellStore{} | |||
| 99 | + | tc.cfg.SettingsStore = store | |||
| 100 | + | reg := core.Registry{} | |||
| 101 | + | if tc.custom { | |||
| 102 | + | reg["powershell"] = &tools.ReadTool{} | |||
| 103 | + | } | |||
| 104 | + | i := &Interactive{cfg: tc.cfg, agent: core.NewAgent(nil, "", "", reg)} | |||
| 105 | + | items := i.appendPowerShellSetting(nil, tc.goos) | |||
| 106 | + | if tc.goos != "windows" { | |||
| 107 | + | if len(items) != 0 { | |||
| 108 | + | t.Fatal("setting shown on non-Windows") | |||
| 109 | + | } | |||
| 110 | + | } else if len(items) != 1 || !items[0].disabled || items[0].hint == "" { | |||
| 111 | + | t.Fatalf("setting: %+v", items) | |||
| 112 | + | } | |||
| 113 | + | i.applyPowerShellSetting(true, tc.goos) | |||
| 114 | + | if i.statusErr == "" || len(store.values) != 0 || i.cfg.PowerShellEnabled != nil { | |||
| 115 | + | t.Fatal("override not respected") | |||
| 116 | + | } | |||
| 117 | + | if i.agent.Tools["powershell"] != reg["powershell"] { | |||
| 118 | + | t.Fatal("registry changed") | |||
| 119 | + | } | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestPowerShellSettingReflectsPreference(t *testing.T) { | |||
| 125 | + | for _, enabled := range []bool{true, false} { | |||
| 126 | + | i := &Interactive{cfg: InteractiveConfig{PowerShellEnabled: &enabled}} | |||
| 127 | + | items := i.appendPowerShellSetting(nil, "windows") | |||
| 128 | + | if len(items) != 1 || items[0].value != enabled || items[0].disabled { | |||
| 129 | + | t.Fatalf("setting: %+v", items) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
A packages/agent/powershell_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPowerShellConfigToolSelection(t *testing.T) { | |
| 10 | + | enabled, disabled := true, false | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | name, goos string | |
| 13 | + | preference *bool | |
| 14 | + | args Args | |
| 15 | + | want bool | |
| 16 | + | }{ | |
| 17 | + | {"default", "windows", nil, Args{}, false}, | |
| 18 | + | {"enabled", "windows", &enabled, Args{}, true}, | |
| 19 | + | {"disabled", "windows", &disabled, Args{}, false}, | |
| 20 | + | {"other OS", "darwin", &enabled, Args{}, false}, | |
| 21 | + | {"no tools", "windows", &enabled, Args{NoTools: true}, false}, | |
| 22 | + | {"explicit exclusion", "windows", &enabled, Args{Tools: []string{"read"}}, false}, | |
| 23 | + | {"explicit inclusion", "windows", &disabled, Args{Tools: []string{"powershell"}}, true}, | |
| 24 | + | } { | |
| 25 | + | t.Run(tc.name, func(t *testing.T) { | |
| 26 | + | reg := buildConfiguredToolRegistry(tc.args, t.TempDir(), nil, Config{PowerShellEnabled: tc.preference}, tc.goos) | |
| 27 | + | if (reg["powershell"] != nil) != tc.want { | |
| 28 | + | t.Fatalf("registry: %v", reg) | |
| 29 | + | } | |
| 30 | + | if tc.name == "enabled" && reg["bash"] == nil { | |
| 31 | + | t.Fatal("setting removed bash") | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPowerShellSettingPersistence(t *testing.T) { | |
| 38 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 39 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | for _, enabled := range []bool{true, false} { | |
| 43 | + | if err := (configSettingsStore{}).SetPowerShell(enabled); err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | cfg, err := LoadConfig() | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | if cfg.PowerShellEnabled == nil || *cfg.PowerShellEnabled != enabled || cfg.Theme != "dark" { | |
| 51 | + | t.Fatalf("config not preserved: %+v", cfg) | |
| 52 | + | } | |
| 53 | + | reg := buildConfiguredToolRegistry(Args{}, t.TempDir(), nil, cfg, "windows") | |
| 54 | + | if (reg["powershell"] != nil) != enabled { | |
| 55 | + | t.Fatal("saved preference not applied") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestPowerShellToolSelection(t *testing.T) { | |
| 61 | + | cwd := t.TempDir() | |
| 62 | + | sb := tools.NewSandbox(cwd) | |
| 63 | + | if _, ok := buildToolRegistry(Args{}, cwd, sb)["powershell"]; ok { | |
| 64 | + | t.Fatal("PowerShell enabled by default") | |
| 65 | + | } | |
| 66 | + | args, err := ParseArgs([]string{"--tools", "read,powershell,glob"}) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | reg := buildToolRegistry(args, cwd, sb) | |
| 71 | + | ps, ok := reg["powershell"].(*tools.PowerShellTool) | |
| 72 | + | if !ok || ps.CWD != cwd || ps.Sandbox != sb { | |
| 73 | + | t.Fatalf("tool: %#v", reg["powershell"]) | |
| 74 | + | } | |
| 75 | + | if len(reg) != 3 || reg["bash"] != nil { | |
| 76 | + | t.Fatalf("registry: %v", reg) | |
| 77 | + | } | |
| 78 | + | found := false | |
| 79 | + | for _, summary := range toolSummaries(reg, args) { | |
| 80 | + | if summary.Name == "powershell" { | |
| 81 | + | found = true | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if !found { | |
| 85 | + | t.Fatal("PowerShell missing from prompt summaries") | |
| 86 | + | } | |
| 87 | + | replacement := tools.NewSandbox(cwd) | |
| 88 | + | r := &Resolved{ToolRegistry: reg, Sandbox: sb} | |
| 89 | + | r.UseSandbox(replacement) | |
| 90 | + | if ps.Sandbox != replacement { | |
| 91 | + | t.Fatal("sandbox not replaced") | |
| 92 | + | } | |
| 93 | + | if len(buildToolRegistry(Args{NoTools: true, Tools: []string{"powershell"}}, cwd, sb)) != 0 { | |
| 94 | + | t.Fatal("--no-tools ignored") | |
| 95 | + | } | |
| 96 | + | both := buildToolRegistry(Args{Tools: []string{"bash", "powershell"}}, cwd, sb) | |
| 97 | + | if both["bash"] == nil || both["powershell"] == nil { | |
| 98 | + | t.Fatal("cannot select both shells") | |
| 99 | + | } | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPowerShellConfigToolSelection(t *testing.T) { | |||
| 10 | + | enabled, disabled := true, false | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | name, goos string | |||
| 13 | + | preference *bool | |||
| 14 | + | args Args | |||
| 15 | + | want bool | |||
| 16 | + | }{ | |||
| 17 | + | {"default", "windows", nil, Args{}, false}, | |||
| 18 | + | {"enabled", "windows", &enabled, Args{}, true}, | |||
| 19 | + | {"disabled", "windows", &disabled, Args{}, false}, | |||
| 20 | + | {"other OS", "darwin", &enabled, Args{}, false}, | |||
| 21 | + | {"no tools", "windows", &enabled, Args{NoTools: true}, false}, | |||
| 22 | + | {"explicit exclusion", "windows", &enabled, Args{Tools: []string{"read"}}, false}, | |||
| 23 | + | {"explicit inclusion", "windows", &disabled, Args{Tools: []string{"powershell"}}, true}, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 26 | + | reg := buildConfiguredToolRegistry(tc.args, t.TempDir(), nil, Config{PowerShellEnabled: tc.preference}, tc.goos) | |||
| 27 | + | if (reg["powershell"] != nil) != tc.want { | |||
| 28 | + | t.Fatalf("registry: %v", reg) | |||
| 29 | + | } | |||
| 30 | + | if tc.name == "enabled" && reg["bash"] == nil { | |||
| 31 | + | t.Fatal("setting removed bash") | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPowerShellSettingPersistence(t *testing.T) { | |||
| 38 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 39 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | for _, enabled := range []bool{true, false} { | |||
| 43 | + | if err := (configSettingsStore{}).SetPowerShell(enabled); err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | cfg, err := LoadConfig() | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | if cfg.PowerShellEnabled == nil || *cfg.PowerShellEnabled != enabled || cfg.Theme != "dark" { | |||
| 51 | + | t.Fatalf("config not preserved: %+v", cfg) | |||
| 52 | + | } | |||
| 53 | + | reg := buildConfiguredToolRegistry(Args{}, t.TempDir(), nil, cfg, "windows") | |||
| 54 | + | if (reg["powershell"] != nil) != enabled { | |||
| 55 | + | t.Fatal("saved preference not applied") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestPowerShellToolSelection(t *testing.T) { | |||
| 61 | + | cwd := t.TempDir() | |||
| 62 | + | sb := tools.NewSandbox(cwd) | |||
| 63 | + | if _, ok := buildToolRegistry(Args{}, cwd, sb)["powershell"]; ok { | |||
| 64 | + | t.Fatal("PowerShell enabled by default") | |||
| 65 | + | } | |||
| 66 | + | args, err := ParseArgs([]string{"--tools", "read,powershell,glob"}) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | reg := buildToolRegistry(args, cwd, sb) | |||
| 71 | + | ps, ok := reg["powershell"].(*tools.PowerShellTool) | |||
| 72 | + | if !ok || ps.CWD != cwd || ps.Sandbox != sb { | |||
| 73 | + | t.Fatalf("tool: %#v", reg["powershell"]) | |||
| 74 | + | } | |||
| 75 | + | if len(reg) != 3 || reg["bash"] != nil { | |||
| 76 | + | t.Fatalf("registry: %v", reg) | |||
| 77 | + | } | |||
| 78 | + | found := false | |||
| 79 | + | for _, summary := range toolSummaries(reg, args) { | |||
| 80 | + | if summary.Name == "powershell" { | |||
| 81 | + | found = true | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if !found { | |||
| 85 | + | t.Fatal("PowerShell missing from prompt summaries") | |||
| 86 | + | } | |||
| 87 | + | replacement := tools.NewSandbox(cwd) | |||
| 88 | + | r := &Resolved{ToolRegistry: reg, Sandbox: sb} | |||
| 89 | + | r.UseSandbox(replacement) | |||
| 90 | + | if ps.Sandbox != replacement { | |||
| 91 | + | t.Fatal("sandbox not replaced") | |||
| 92 | + | } | |||
| 93 | + | if len(buildToolRegistry(Args{NoTools: true, Tools: []string{"powershell"}}, cwd, sb)) != 0 { | |||
| 94 | + | t.Fatal("--no-tools ignored") | |||
| 95 | + | } | |||
| 96 | + | both := buildToolRegistry(Args{Tools: []string{"bash", "powershell"}}, cwd, sb) | |||
| 97 | + | if both["bash"] == nil || both["powershell"] == nil { | |||
| 98 | + | t.Fatal("cannot select both shells") | |||
| 99 | + | } | |||
| 100 | + | } | |||
M packages/agent/settings_store.go +9 -0
| @@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error { | |||
| 68 | 68 | return SaveConfig(cfg) | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | + | func (configSettingsStore) SetPowerShell(enabled bool) error { | |
| 72 | + | cfg, err := LoadConfig() | |
| 73 | + | if err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | cfg.PowerShellEnabled = &enabled | |
| 77 | + | return SaveConfig(cfg) | |
| 78 | + | } | |
| 79 | + | ||
| 71 | 80 | func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error { | |
| 72 | 81 | cfg, err := LoadConfig() | |
| 73 | 82 | if err != nil { | |
| @@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error { | |||||
| 68 | return SaveConfig(cfg) | 68 | return SaveConfig(cfg) | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | + | func (configSettingsStore) SetPowerShell(enabled bool) error { | |||
| 72 | + | cfg, err := LoadConfig() | |||
| 73 | + | if err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | cfg.PowerShellEnabled = &enabled | |||
| 77 | + | return SaveConfig(cfg) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 71 | func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error { | 80 | func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error { | ||
| 72 | cfg, err := LoadConfig() | 81 | cfg, err := LoadConfig() | ||
| 73 | if err != nil { | 82 | if err != nil { | ||
M packages/agent/tools/bash.go +23 -8
| @@ -42,6 +42,18 @@ func (t *BashTool) Description() string { return shellDescription(currentShe | |||
| 42 | 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | |
| 43 | 43 | ||
| 44 | 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 45 | + | return executeShell(ctx, raw, progress, t.CWD, "$", func(command string) error { | |
| 46 | + | if err := t.Sandbox.CheckCommand(command); err != nil { | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | return t.Sandbox.CheckBashPermission(command) | |
| 50 | + | }, func(ctx context.Context, command string) (*exec.Cmd, error) { | |
| 51 | + | return newShellCmd(ctx, command), nil | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // executeShell shares output streaming, timeouts and result formatting across shells. | |
| 56 | + | func executeShell(ctx context.Context, raw json.RawMessage, progress func(string), cwd, prompt string, check func(string) error, newCommand func(context.Context, string) (*exec.Cmd, error)) (core.ToolResult, error) { | |
| 45 | 57 | var a bashArgs | |
| 46 | 58 | if err := json.Unmarshal(raw, &a); err != nil { | |
| 47 | 59 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| @@ -49,13 +61,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 49 | 61 | if strings.TrimSpace(a.Command) == "" { | |
| 50 | 62 | return core.ToolResult{}, fmt.Errorf("command is required") | |
| 51 | 63 | } | |
| 52 | - | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | |
| 64 | + | if err := check(a.Command); err != nil { | |
| 53 | 65 | return core.ToolResult{}, err | |
| 54 | 66 | } | |
| 55 | - | if err := t.Sandbox.CheckBashPermission(a.Command); err != nil { | |
| 56 | - | return core.ToolResult{}, err | |
| 57 | - | } | |
| 58 | - | cwd := t.CWD | |
| 59 | 67 | if cwd == "" { | |
| 60 | 68 | cwd, _ = os.Getwd() | |
| 61 | 69 | } | |
| @@ -68,13 +76,20 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 68 | 76 | } | |
| 69 | 77 | ||
| 70 | 78 | start := time.Now() | |
| 71 | - | cmd := newShellCmd(runCtx, a.Command) | |
| 79 | + | cmd, err := newCommand(runCtx, a.Command) | |
| 80 | + | if err != nil { | |
| 81 | + | return core.ToolResult{}, err | |
| 82 | + | } | |
| 72 | 83 | cmd.Dir = cwd | |
| 73 | 84 | cmd.Env = os.Environ() | |
| 85 | + | // Bound output draining if descendants retain handles after cancellation. | |
| 86 | + | cmd.WaitDelay = 2 * time.Second | |
| 74 | 87 | setProcessGroup(cmd) | |
| 75 | 88 | ||
| 76 | 89 | // Capture merged stdout+stderr with line-by-line streaming. | |
| 77 | 90 | pr, pw := io.Pipe() | |
| 91 | + | defer pr.Close() | |
| 92 | + | defer pw.Close() | |
| 78 | 93 | cmd.Stdout = pw | |
| 79 | 94 | cmd.Stderr = pw | |
| 80 | 95 | ||
| @@ -155,7 +170,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 155 | 170 | // a human would see if they ran the command themselves, which | |
| 156 | 171 | // makes the model's reasoning about it more natural too. | |
| 157 | 172 | var sb strings.Builder | |
| 158 | - | fmt.Fprintf(&sb, "$ %s\n", a.Command) | |
| 173 | + | fmt.Fprintf(&sb, "%s %s\n", prompt, a.Command) | |
| 159 | 174 | if trimmed != "" { | |
| 160 | 175 | sb.WriteString("\n") | |
| 161 | 176 | sb.WriteString(trimmed) | |
| @@ -185,7 +200,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 185 | 200 | } | |
| 186 | 201 | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | |
| 187 | 202 | ||
| 188 | - | isErr := exitCode != 0 || ctx.Err() != nil | |
| 203 | + | isErr := exitCode != 0 || runCtx.Err() != nil | |
| 189 | 204 | return core.ToolResult{ | |
| 190 | 205 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 191 | 206 | IsError: isErr, | |
| @@ -42,6 +42,18 @@ func (t *BashTool) Description() string { return shellDescription(currentShe | |||||
| 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | ||
| 43 | 43 | ||||
| 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | ||
| 45 | + | return executeShell(ctx, raw, progress, t.CWD, "$", func(command string) error { | |||
| 46 | + | if err := t.Sandbox.CheckCommand(command); err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | return t.Sandbox.CheckBashPermission(command) | |||
| 50 | + | }, func(ctx context.Context, command string) (*exec.Cmd, error) { | |||
| 51 | + | return newShellCmd(ctx, command), nil | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // executeShell shares output streaming, timeouts and result formatting across shells. | |||
| 56 | + | func executeShell(ctx context.Context, raw json.RawMessage, progress func(string), cwd, prompt string, check func(string) error, newCommand func(context.Context, string) (*exec.Cmd, error)) (core.ToolResult, error) { | |||
| 45 | var a bashArgs | 57 | var a bashArgs | ||
| 46 | if err := json.Unmarshal(raw, &a); err != nil { | 58 | if err := json.Unmarshal(raw, &a); err != nil { | ||
| 47 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | 59 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | ||
| @@ -49,13 +61,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 49 | if strings.TrimSpace(a.Command) == "" { | 61 | if strings.TrimSpace(a.Command) == "" { | ||
| 50 | return core.ToolResult{}, fmt.Errorf("command is required") | 62 | return core.ToolResult{}, fmt.Errorf("command is required") | ||
| 51 | } | 63 | } | ||
| 52 | - | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | 64 | + | if err := check(a.Command); err != nil { |
| 53 | return core.ToolResult{}, err | 65 | return core.ToolResult{}, err | ||
| 54 | } | 66 | } | ||
| 55 | - | if err := t.Sandbox.CheckBashPermission(a.Command); err != nil { | |||
| 56 | - | return core.ToolResult{}, err | |||
| 57 | - | } | |||
| 58 | - | cwd := t.CWD | |||
| 59 | if cwd == "" { | 67 | if cwd == "" { | ||
| 60 | cwd, _ = os.Getwd() | 68 | cwd, _ = os.Getwd() | ||
| 61 | } | 69 | } | ||
| @@ -68,13 +76,20 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 68 | } | 76 | } | ||
| 69 | 77 | ||||
| 70 | start := time.Now() | 78 | start := time.Now() | ||
| 71 | - | cmd := newShellCmd(runCtx, a.Command) | 79 | + | cmd, err := newCommand(runCtx, a.Command) |
| 80 | + | if err != nil { | |||
| 81 | + | return core.ToolResult{}, err | |||
| 82 | + | } | |||
| 72 | cmd.Dir = cwd | 83 | cmd.Dir = cwd | ||
| 73 | cmd.Env = os.Environ() | 84 | cmd.Env = os.Environ() | ||
| 85 | + | // Bound output draining if descendants retain handles after cancellation. | |||
| 86 | + | cmd.WaitDelay = 2 * time.Second | |||
| 74 | setProcessGroup(cmd) | 87 | setProcessGroup(cmd) | ||
| 75 | 88 | ||||
| 76 | // Capture merged stdout+stderr with line-by-line streaming. | 89 | // Capture merged stdout+stderr with line-by-line streaming. | ||
| 77 | pr, pw := io.Pipe() | 90 | pr, pw := io.Pipe() | ||
| 91 | + | defer pr.Close() | |||
| 92 | + | defer pw.Close() | |||
| 78 | cmd.Stdout = pw | 93 | cmd.Stdout = pw | ||
| 79 | cmd.Stderr = pw | 94 | cmd.Stderr = pw | ||
| 80 | 95 | ||||
| @@ -155,7 +170,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 155 | // a human would see if they ran the command themselves, which | 170 | // a human would see if they ran the command themselves, which | ||
| 156 | // makes the model's reasoning about it more natural too. | 171 | // makes the model's reasoning about it more natural too. | ||
| 157 | var sb strings.Builder | 172 | var sb strings.Builder | ||
| 158 | - | fmt.Fprintf(&sb, "$ %s\n", a.Command) | 173 | + | fmt.Fprintf(&sb, "%s %s\n", prompt, a.Command) |
| 159 | if trimmed != "" { | 174 | if trimmed != "" { | ||
| 160 | sb.WriteString("\n") | 175 | sb.WriteString("\n") | ||
| 161 | sb.WriteString(trimmed) | 176 | sb.WriteString(trimmed) | ||
| @@ -185,7 +200,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 185 | } | 200 | } | ||
| 186 | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | 201 | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | ||
| 187 | 202 | ||||
| 188 | - | isErr := exitCode != 0 || ctx.Err() != nil | 203 | + | isErr := exitCode != 0 || runCtx.Err() != nil |
| 189 | return core.ToolResult{ | 204 | return core.ToolResult{ | ||
| 190 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 205 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | ||
| 191 | IsError: isErr, | 206 | IsError: isErr, | ||
A packages/agent/tools/powershell.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "os/exec" | |
| 10 | + | "runtime" | |
| 11 | + | "strings" | |
| 12 | + | "unicode/utf16" | |
| 13 | + | ||
| 14 | + | "github.com/patriceckhart/zot/packages/core" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PowerShellTool provides opt-in native command execution on Windows. | |
| 18 | + | type PowerShellTool struct { | |
| 19 | + | CWD string | |
| 20 | + | Sandbox *Sandbox | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (t *PowerShellTool) Name() string { return "powershell" } | |
| 24 | + | func (t *PowerShellTool) Description() string { | |
| 25 | + | return "Run a native PowerShell command on Windows (pwsh.exe, falling back to powershell.exe), without profiles or interactive input. stdout+stderr merged. Optional timeout in seconds." | |
| 26 | + | } | |
| 27 | + | func (t *PowerShellTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | |
| 28 | + | ||
| 29 | + | func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 30 | + | return executeShell(ctx, raw, progress, t.CWD, "PS>", t.checkPermission, func(ctx context.Context, command string) (*exec.Cmd, error) { | |
| 31 | + | path, err := resolvePowerShell(runtime.GOOS, exec.LookPath) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | return exec.CommandContext(ctx, path, powerShellArgs(command)...), nil | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (t *PowerShellTool) checkPermission(_ string) error { | |
| 40 | + | // The existing shell heuristics parse neither PowerShell expressions nor | |
| 41 | + | // cmdlets. Do not treat them as enforcement for a different language. | |
| 42 | + | if t.Sandbox.Locked() { | |
| 43 | + | return fmt.Errorf("jailed: PowerShell execution is unavailable while jailed") | |
| 44 | + | } | |
| 45 | + | if t.Sandbox != nil && t.Sandbox.Permissions != nil { | |
| 46 | + | if strings.ToLower(strings.TrimSpace(t.Sandbox.Permissions.Bash.Mode)) != "ask" { | |
| 47 | + | return fmt.Errorf("permission denied: PowerShell requires bash permission mode ask; bash allowlists do not apply to PowerShell") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func resolvePowerShell(goos string, lookPath func(string) (string, error)) (string, error) { | |
| 54 | + | if goos != "windows" { | |
| 55 | + | return "", fmt.Errorf("the powershell tool is only available on Windows") | |
| 56 | + | } | |
| 57 | + | for _, name := range []string{"pwsh.exe", "powershell.exe"} { | |
| 58 | + | if path, err := lookPath(name); err == nil { | |
| 59 | + | return path, nil | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return "", fmt.Errorf("PowerShell not found: install PowerShell or add pwsh.exe or powershell.exe to PATH") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func powerShellArgs(command string) []string { | |
| 66 | + | // EncodedCommand preserves quotes, multiline scripts and Unicode through | |
| 67 | + | // Windows command-line parsing. PowerShell expects UTF-16LE here. | |
| 68 | + | script := "[Console]::OutputEncoding = $OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" + command | |
| 69 | + | units := utf16.Encode([]rune(script)) | |
| 70 | + | buf := make([]byte, len(units)*2) | |
| 71 | + | for i, unit := range units { | |
| 72 | + | binary.LittleEndian.PutUint16(buf[i*2:], unit) | |
| 73 | + | } | |
| 74 | + | return []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", base64.StdEncoding.EncodeToString(buf)} | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "runtime" | |||
| 11 | + | "strings" | |||
| 12 | + | "unicode/utf16" | |||
| 13 | + | ||||
| 14 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // PowerShellTool provides opt-in native command execution on Windows. | |||
| 18 | + | type PowerShellTool struct { | |||
| 19 | + | CWD string | |||
| 20 | + | Sandbox *Sandbox | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (t *PowerShellTool) Name() string { return "powershell" } | |||
| 24 | + | func (t *PowerShellTool) Description() string { | |||
| 25 | + | return "Run a native PowerShell command on Windows (pwsh.exe, falling back to powershell.exe), without profiles or interactive input. stdout+stderr merged. Optional timeout in seconds." | |||
| 26 | + | } | |||
| 27 | + | func (t *PowerShellTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | |||
| 28 | + | ||||
| 29 | + | func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 30 | + | return executeShell(ctx, raw, progress, t.CWD, "PS>", t.checkPermission, func(ctx context.Context, command string) (*exec.Cmd, error) { | |||
| 31 | + | path, err := resolvePowerShell(runtime.GOOS, exec.LookPath) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return nil, err | |||
| 34 | + | } | |||
| 35 | + | return exec.CommandContext(ctx, path, powerShellArgs(command)...), nil | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (t *PowerShellTool) checkPermission(_ string) error { | |||
| 40 | + | // The existing shell heuristics parse neither PowerShell expressions nor | |||
| 41 | + | // cmdlets. Do not treat them as enforcement for a different language. | |||
| 42 | + | if t.Sandbox.Locked() { | |||
| 43 | + | return fmt.Errorf("jailed: PowerShell execution is unavailable while jailed") | |||
| 44 | + | } | |||
| 45 | + | if t.Sandbox != nil && t.Sandbox.Permissions != nil { | |||
| 46 | + | if strings.ToLower(strings.TrimSpace(t.Sandbox.Permissions.Bash.Mode)) != "ask" { | |||
| 47 | + | return fmt.Errorf("permission denied: PowerShell requires bash permission mode ask; bash allowlists do not apply to PowerShell") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func resolvePowerShell(goos string, lookPath func(string) (string, error)) (string, error) { | |||
| 54 | + | if goos != "windows" { | |||
| 55 | + | return "", fmt.Errorf("the powershell tool is only available on Windows") | |||
| 56 | + | } | |||
| 57 | + | for _, name := range []string{"pwsh.exe", "powershell.exe"} { | |||
| 58 | + | if path, err := lookPath(name); err == nil { | |||
| 59 | + | return path, nil | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | return "", fmt.Errorf("PowerShell not found: install PowerShell or add pwsh.exe or powershell.exe to PATH") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func powerShellArgs(command string) []string { | |||
| 66 | + | // EncodedCommand preserves quotes, multiline scripts and Unicode through | |||
| 67 | + | // Windows command-line parsing. PowerShell expects UTF-16LE here. | |||
| 68 | + | script := "[Console]::OutputEncoding = $OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" + command | |||
| 69 | + | units := utf16.Encode([]rune(script)) | |||
| 70 | + | buf := make([]byte, len(units)*2) | |||
| 71 | + | for i, unit := range units { | |||
| 72 | + | binary.LittleEndian.PutUint16(buf[i*2:], unit) | |||
| 73 | + | } | |||
| 74 | + | return []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", base64.StdEncoding.EncodeToString(buf)} | |||
| 75 | + | } | |||
A packages/agent/tools/powershell_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "os/exec" | |
| 10 | + | "reflect" | |
| 11 | + | "runtime" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | "unicode/utf16" | |
| 16 | + | ||
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestResolvePowerShell(t *testing.T) { | |
| 21 | + | for _, tc := range []struct{ name, goos, available, want string }{ | |
| 22 | + | {"preferred", "windows", "pwsh.exe", "pwsh.exe"}, | |
| 23 | + | {"fallback", "windows", "powershell.exe", "powershell.exe"}, | |
| 24 | + | {"missing", "windows", "", ""}, | |
| 25 | + | {"unsupported", "linux", "pwsh.exe", ""}, | |
| 26 | + | } { | |
| 27 | + | t.Run(tc.name, func(t *testing.T) { | |
| 28 | + | var calls []string | |
| 29 | + | got, err := resolvePowerShell(tc.goos, func(name string) (string, error) { | |
| 30 | + | calls = append(calls, name) | |
| 31 | + | if name == tc.available { | |
| 32 | + | return name, nil | |
| 33 | + | } | |
| 34 | + | return "", fmt.Errorf("not found") | |
| 35 | + | }) | |
| 36 | + | if got != tc.want || (err != nil) != (tc.want == "") { | |
| 37 | + | t.Fatalf("got %q, %v", got, err) | |
| 38 | + | } | |
| 39 | + | if tc.goos != "windows" && len(calls) != 0 { | |
| 40 | + | t.Fatal("searched on unsupported OS") | |
| 41 | + | } | |
| 42 | + | if tc.name == "preferred" && len(calls) != 1 { | |
| 43 | + | t.Fatal("did not prefer pwsh") | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestPowerShellArgs(t *testing.T) { | |
| 50 | + | command := "Write-Output 'Grüße 世界'\nWrite-Output \"quoted\"" | |
| 51 | + | args := powerShellArgs(command) | |
| 52 | + | want := []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand"} | |
| 53 | + | if !reflect.DeepEqual(args[:len(args)-1], want) { | |
| 54 | + | t.Fatalf("args: %v", args) | |
| 55 | + | } | |
| 56 | + | data, err := base64.StdEncoding.DecodeString(args[len(args)-1]) | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | units := make([]uint16, len(data)/2) | |
| 61 | + | for i := range units { | |
| 62 | + | units[i] = binary.LittleEndian.Uint16(data[i*2:]) | |
| 63 | + | } | |
| 64 | + | script := string(utf16.Decode(units)) | |
| 65 | + | if !strings.HasSuffix(script, "\n"+command) || !strings.Contains(script, "UTF8Encoding") { | |
| 66 | + | t.Fatalf("script: %q", script) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestPowerShellPermissions(t *testing.T) { | |
| 71 | + | sb := NewSandbox(t.TempDir()) | |
| 72 | + | tool := &PowerShellTool{Sandbox: sb} | |
| 73 | + | if err := tool.checkPermission(""); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | sb.Lock() | |
| 77 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil); err == nil || !strings.Contains(err.Error(), "jailed") { | |
| 78 | + | t.Fatalf("got %v", err) | |
| 79 | + | } | |
| 80 | + | sb.Unlock() | |
| 81 | + | sb.Permissions = &PermissionSet{} | |
| 82 | + | for _, mode := range []string{"", "none", "allowlist", "invalid", "ask"} { | |
| 83 | + | sb.Permissions.Bash.Mode = mode | |
| 84 | + | sb.Permissions.Bash.Allow = []string{"Get-Location"} | |
| 85 | + | if err := tool.checkPermission("Get-Location"); (err == nil) != (mode == "ask") { | |
| 86 | + | t.Fatalf("mode %q: %v", mode, err) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestPowerShellInvalidArgs(t *testing.T) { | |
| 92 | + | tool := &PowerShellTool{} | |
| 93 | + | for _, raw := range []string{`{`, `{}`, `{"command":" "}`, `{"command":123}`} { | |
| 94 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(raw), nil); err == nil { | |
| 95 | + | t.Fatalf("accepted %s", raw) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if runtime.GOOS != "windows" { | |
| 99 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil) | |
| 100 | + | if err == nil || !strings.Contains(err.Error(), "only available on Windows") { | |
| 101 | + | t.Fatalf("got %v", err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPowerShellExecution(t *testing.T) { | |
| 107 | + | if runtime.GOOS != "windows" { | |
| 108 | + | t.Skip("native Windows execution") | |
| 109 | + | } | |
| 110 | + | if _, err := resolvePowerShell(runtime.GOOS, exec.LookPath); err != nil { | |
| 111 | + | t.Skip(err) | |
| 112 | + | } | |
| 113 | + | cwd := t.TempDir() | |
| 114 | + | tool := &PowerShellTool{CWD: cwd} | |
| 115 | + | for _, tc := range []struct { | |
| 116 | + | name, command, want string | |
| 117 | + | failure bool | |
| 118 | + | timeout int | |
| 119 | + | }{ | |
| 120 | + | {"unicode and quoting", "Write-Output 'Grüße 世界'; Write-Output '\"quoted\"'", "Grüße 世界", false, 0}, | |
| 121 | + | {"cwd", "(Get-Location).Path", cwd, false, 0}, | |
| 122 | + | {"stderr", "[Console]::Error.WriteLine('stderr output')", "stderr output", false, 0}, | |
| 123 | + | {"exit", "exit 7", "[exit 7]", true, 0}, | |
| 124 | + | {"error", "throw 'failure'", "failure", true, 0}, | |
| 125 | + | {"timeout", "Start-Sleep -Seconds 60", "", true, 1}, | |
| 126 | + | } { | |
| 127 | + | t.Run(tc.name, func(t *testing.T) { | |
| 128 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 129 | + | defer cancel() | |
| 130 | + | var streamed strings.Builder | |
| 131 | + | res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"command": tc.command, "timeout": tc.timeout}), func(s string) { streamed.WriteString(s) }) | |
| 132 | + | if err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 136 | + | if res.IsError != tc.failure || !strings.Contains(text, tc.want) || !strings.HasPrefix(text, "PS> ") { | |
| 137 | + | t.Fatalf("result: %+v, %s", res, text) | |
| 138 | + | } | |
| 139 | + | if tc.name == "unicode and quoting" && !strings.Contains(streamed.String(), "Grüße 世界") { | |
| 140 | + | t.Fatalf("stream: %q", streamed.String()) | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | } | |
| 144 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 145 | + | cancel() | |
| 146 | + | res, err := tool.Execute(ctx, json.RawMessage(`{"command":"Get-Location"}`), nil) | |
| 147 | + | if err == nil && !res.IsError { | |
| 148 | + | t.Fatal("cancellation succeeded") | |
| 149 | + | } | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "reflect" | |||
| 11 | + | "runtime" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | "time" | |||
| 15 | + | "unicode/utf16" | |||
| 16 | + | ||||
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestResolvePowerShell(t *testing.T) { | |||
| 21 | + | for _, tc := range []struct{ name, goos, available, want string }{ | |||
| 22 | + | {"preferred", "windows", "pwsh.exe", "pwsh.exe"}, | |||
| 23 | + | {"fallback", "windows", "powershell.exe", "powershell.exe"}, | |||
| 24 | + | {"missing", "windows", "", ""}, | |||
| 25 | + | {"unsupported", "linux", "pwsh.exe", ""}, | |||
| 26 | + | } { | |||
| 27 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 28 | + | var calls []string | |||
| 29 | + | got, err := resolvePowerShell(tc.goos, func(name string) (string, error) { | |||
| 30 | + | calls = append(calls, name) | |||
| 31 | + | if name == tc.available { | |||
| 32 | + | return name, nil | |||
| 33 | + | } | |||
| 34 | + | return "", fmt.Errorf("not found") | |||
| 35 | + | }) | |||
| 36 | + | if got != tc.want || (err != nil) != (tc.want == "") { | |||
| 37 | + | t.Fatalf("got %q, %v", got, err) | |||
| 38 | + | } | |||
| 39 | + | if tc.goos != "windows" && len(calls) != 0 { | |||
| 40 | + | t.Fatal("searched on unsupported OS") | |||
| 41 | + | } | |||
| 42 | + | if tc.name == "preferred" && len(calls) != 1 { | |||
| 43 | + | t.Fatal("did not prefer pwsh") | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestPowerShellArgs(t *testing.T) { | |||
| 50 | + | command := "Write-Output 'Grüße 世界'\nWrite-Output \"quoted\"" | |||
| 51 | + | args := powerShellArgs(command) | |||
| 52 | + | want := []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand"} | |||
| 53 | + | if !reflect.DeepEqual(args[:len(args)-1], want) { | |||
| 54 | + | t.Fatalf("args: %v", args) | |||
| 55 | + | } | |||
| 56 | + | data, err := base64.StdEncoding.DecodeString(args[len(args)-1]) | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | units := make([]uint16, len(data)/2) | |||
| 61 | + | for i := range units { | |||
| 62 | + | units[i] = binary.LittleEndian.Uint16(data[i*2:]) | |||
| 63 | + | } | |||
| 64 | + | script := string(utf16.Decode(units)) | |||
| 65 | + | if !strings.HasSuffix(script, "\n"+command) || !strings.Contains(script, "UTF8Encoding") { | |||
| 66 | + | t.Fatalf("script: %q", script) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestPowerShellPermissions(t *testing.T) { | |||
| 71 | + | sb := NewSandbox(t.TempDir()) | |||
| 72 | + | tool := &PowerShellTool{Sandbox: sb} | |||
| 73 | + | if err := tool.checkPermission(""); err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | sb.Lock() | |||
| 77 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil); err == nil || !strings.Contains(err.Error(), "jailed") { | |||
| 78 | + | t.Fatalf("got %v", err) | |||
| 79 | + | } | |||
| 80 | + | sb.Unlock() | |||
| 81 | + | sb.Permissions = &PermissionSet{} | |||
| 82 | + | for _, mode := range []string{"", "none", "allowlist", "invalid", "ask"} { | |||
| 83 | + | sb.Permissions.Bash.Mode = mode | |||
| 84 | + | sb.Permissions.Bash.Allow = []string{"Get-Location"} | |||
| 85 | + | if err := tool.checkPermission("Get-Location"); (err == nil) != (mode == "ask") { | |||
| 86 | + | t.Fatalf("mode %q: %v", mode, err) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestPowerShellInvalidArgs(t *testing.T) { | |||
| 92 | + | tool := &PowerShellTool{} | |||
| 93 | + | for _, raw := range []string{`{`, `{}`, `{"command":" "}`, `{"command":123}`} { | |||
| 94 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(raw), nil); err == nil { | |||
| 95 | + | t.Fatalf("accepted %s", raw) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if runtime.GOOS != "windows" { | |||
| 99 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil) | |||
| 100 | + | if err == nil || !strings.Contains(err.Error(), "only available on Windows") { | |||
| 101 | + | t.Fatalf("got %v", err) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestPowerShellExecution(t *testing.T) { | |||
| 107 | + | if runtime.GOOS != "windows" { | |||
| 108 | + | t.Skip("native Windows execution") | |||
| 109 | + | } | |||
| 110 | + | if _, err := resolvePowerShell(runtime.GOOS, exec.LookPath); err != nil { | |||
| 111 | + | t.Skip(err) | |||
| 112 | + | } | |||
| 113 | + | cwd := t.TempDir() | |||
| 114 | + | tool := &PowerShellTool{CWD: cwd} | |||
| 115 | + | for _, tc := range []struct { | |||
| 116 | + | name, command, want string | |||
| 117 | + | failure bool | |||
| 118 | + | timeout int | |||
| 119 | + | }{ | |||
| 120 | + | {"unicode and quoting", "Write-Output 'Grüße 世界'; Write-Output '\"quoted\"'", "Grüße 世界", false, 0}, | |||
| 121 | + | {"cwd", "(Get-Location).Path", cwd, false, 0}, | |||
| 122 | + | {"stderr", "[Console]::Error.WriteLine('stderr output')", "stderr output", false, 0}, | |||
| 123 | + | {"exit", "exit 7", "[exit 7]", true, 0}, | |||
| 124 | + | {"error", "throw 'failure'", "failure", true, 0}, | |||
| 125 | + | {"timeout", "Start-Sleep -Seconds 60", "", true, 1}, | |||
| 126 | + | } { | |||
| 127 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 128 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |||
| 129 | + | defer cancel() | |||
| 130 | + | var streamed strings.Builder | |||
| 131 | + | res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"command": tc.command, "timeout": tc.timeout}), func(s string) { streamed.WriteString(s) }) | |||
| 132 | + | if err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 136 | + | if res.IsError != tc.failure || !strings.Contains(text, tc.want) || !strings.HasPrefix(text, "PS> ") { | |||
| 137 | + | t.Fatalf("result: %+v, %s", res, text) | |||
| 138 | + | } | |||
| 139 | + | if tc.name == "unicode and quoting" && !strings.Contains(streamed.String(), "Grüße 世界") { | |||
| 140 | + | t.Fatalf("stream: %q", streamed.String()) | |||
| 141 | + | } | |||
| 142 | + | }) | |||
| 143 | + | } | |||
| 144 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 145 | + | cancel() | |||
| 146 | + | res, err := tool.Execute(ctx, json.RawMessage(`{"command":"Get-Location"}`), nil) | |||
| 147 | + | if err == nil && !res.IsError { | |||
| 148 | + | t.Fatal("cancellation succeeded") | |||
| 149 | + | } | |||
| 150 | + | } | |||
M packages/tui/view.go +8 -4
| @@ -1051,22 +1051,26 @@ func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string { | |||
| 1051 | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 1052 | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 1053 | 1053 | return body | |
| 1054 | - | case "bash", "Bash": | |
| 1054 | + | case "bash", "Bash", "powershell", "PowerShell": | |
| 1055 | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | |
| 1056 | 1056 | if !ok || strings.TrimSpace(command) == "" { | |
| 1057 | 1057 | return nil | |
| 1058 | 1058 | } | |
| 1059 | - | return v.renderLiveBashCommand(command, width) | |
| 1059 | + | prompt := "$ " | |
| 1060 | + | if strings.EqualFold(tc.Name, "powershell") { | |
| 1061 | + | prompt = "PS> " | |
| 1062 | + | } | |
| 1063 | + | return v.renderLiveShellCommand(command, width, prompt) | |
| 1060 | 1064 | } | |
| 1061 | 1065 | return nil | |
| 1062 | 1066 | } | |
| 1063 | 1067 | ||
| 1064 | - | func (v *View) renderLiveBashCommand(command string, width int) []string { | |
| 1068 | + | func (v *View) renderLiveShellCommand(command string, width int, promptText string) []string { | |
| 1065 | 1069 | inner := toolBoxBodyRenderWidth(width) | |
| 1066 | 1070 | if v.FlatTools || v.CompactMode { | |
| 1067 | 1071 | inner = flatToolBodyRenderWidth(width) | |
| 1068 | 1072 | } | |
| 1069 | - | prompt := v.Theme.FG256(v.Theme.Muted, "$ ") | |
| 1073 | + | prompt := v.Theme.FG256(v.Theme.Muted, promptText) | |
| 1070 | 1074 | var out []string | |
| 1071 | 1075 | for i, line := range strings.Split(command, "\n") { | |
| 1072 | 1076 | firstPrefix := " " | |
| @@ -1051,22 +1051,26 @@ func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string { | |||||
| 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | 1051 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | ||
| 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | 1052 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | ||
| 1053 | return body | 1053 | return body | ||
| 1054 | - | case "bash", "Bash": | 1054 | + | case "bash", "Bash", "powershell", "PowerShell": |
| 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | 1055 | command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command") | ||
| 1056 | if !ok || strings.TrimSpace(command) == "" { | 1056 | if !ok || strings.TrimSpace(command) == "" { | ||
| 1057 | return nil | 1057 | return nil | ||
| 1058 | } | 1058 | } | ||
| 1059 | - | return v.renderLiveBashCommand(command, width) | 1059 | + | prompt := "$ " |
| 1060 | + | if strings.EqualFold(tc.Name, "powershell") { | |||
| 1061 | + | prompt = "PS> " | |||
| 1062 | + | } | |||
| 1063 | + | return v.renderLiveShellCommand(command, width, prompt) | |||
| 1060 | } | 1064 | } | ||
| 1061 | return nil | 1065 | return nil | ||
| 1062 | } | 1066 | } | ||
| 1063 | 1067 | ||||
| 1064 | - | func (v *View) renderLiveBashCommand(command string, width int) []string { | 1068 | + | func (v *View) renderLiveShellCommand(command string, width int, promptText string) []string { |
| 1065 | inner := toolBoxBodyRenderWidth(width) | 1069 | inner := toolBoxBodyRenderWidth(width) | ||
| 1066 | if v.FlatTools || v.CompactMode { | 1070 | if v.FlatTools || v.CompactMode { | ||
| 1067 | inner = flatToolBodyRenderWidth(width) | 1071 | inner = flatToolBodyRenderWidth(width) | ||
| 1068 | } | 1072 | } | ||
| 1069 | - | prompt := v.Theme.FG256(v.Theme.Muted, "$ ") | 1073 | + | prompt := v.Theme.FG256(v.Theme.Muted, promptText) |
| 1070 | var out []string | 1074 | var out []string | ||
| 1071 | for i, line := range strings.Split(command, "\n") { | 1075 | for i, line := range strings.Split(command, "\n") { | ||
| 1072 | firstPrefix := " " | 1076 | firstPrefix := " " | ||
M packages/tui/view_tool_overlay_test.go +11 -0
| @@ -82,6 +82,17 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func TestLiveToolOverlayShowsPowerShellCommand(t *testing.T) { | |
| 86 | + | args := json.RawMessage(`{"command":"Get-ChildItem -Force"}`) | |
| 87 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |
| 88 | + | ID: "shell_1", Name: "powershell", RawJSONBuf: string(args), | |
| 89 | + | }}} | |
| 90 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |
| 91 | + | if !strings.Contains(plain, "PS> Get-ChildItem -Force") { | |
| 92 | + | t.Fatalf("PowerShell command missing from live preview: %s", plain) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 85 | 96 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | |
| 86 | 97 | v := View{Theme: Dark} | |
| 87 | 98 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |
| @@ -82,6 +82,17 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func TestLiveToolOverlayShowsPowerShellCommand(t *testing.T) { | |||
| 86 | + | args := json.RawMessage(`{"command":"Get-ChildItem -Force"}`) | |||
| 87 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |||
| 88 | + | ID: "shell_1", Name: "powershell", RawJSONBuf: string(args), | |||
| 89 | + | }}} | |||
| 90 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |||
| 91 | + | if !strings.Contains(plain, "PS> Get-ChildItem -Force") { | |||
| 92 | + | t.Fatalf("PowerShell command missing from live preview: %s", plain) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 85 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | 96 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | ||
| 86 | v := View{Theme: Dark} | 97 | v := View{Theme: Dark} | ||
| 87 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | 98 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | ||