detached 653c635

feat(python): native Python 3 execution tool (#192)

* feat(python): add native Python 3 execution tool

Execute inline Python 3 against an existing environment without
installing interpreters, packages, or virtual environments. Lazy
per-invocation resolution (configured path, VIRTUAL_ENV, .venv,
PATH) with fail-closed Windows alias/launcher/manager rejection,
direct stdin subprocess execution, bounded output, and admission-time
jail and packaged-agent denial.

* fix(python): anchor PATH results and track discarded bytes

Absolutize PATH-discovered interpreters so probing, Windows
classification, and execution address the same executable when PATH
contains relative entries. Report bytes_truncated only when bytes are
actually discarded instead of inferring it from the buffer length.

* feat(python): persist full output file for truncated results

Mirror the bash full-output artifact: truncated executions persist the
byte-capped buffer to a zut-python temp file referenced in the result
text, with its path in Details full_output_path. Untruncated results
produce no file.

* fix(python): address Copilot review and CI failures

Route expected argument, admission, and discovery failures through
the model-visible ToolResult.IsError channel instead of Go errors.
Capture platform Cancel termination errors under a mutex so failed
process-group/tree kills surface as cleanup metadata. Anchor the
session CWD to absolute once so relative --cwd cannot stack
candidate paths. Count logical lines for truncation, lowercase
classifier error strings for staticcheck, reserve python in the
public extension SDK docs, and make resolver tests
separator-agnostic for Windows CI.

* fix(python): use volume-anchored absolute fixtures for Windows CI

A rooted Unix-style path is not absolute on Windows, where Abs
prepends the drive CWD. Anchor resolver fixtures to the TempDir
volume via pythonTestAbs so IsAbs/Abs/Clean/Join behave like
production paths on every runner.
commit
653c635f43c0523db5e7632e762852911f140184
author
bnema · · 2026-09-08 20:20 UTC
committer
GitHub · · 2026-09-08 20:20 UTC
parent
3b3777e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.33.0
16 files · +2438 -15
M README.md +3 -2
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- fourteen core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
12+- fifteen core built-in tools (read, write, edit, bash, python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -265,6 +265,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
265265 - `edit`: one or more exact-match replacements in an existing file.
266266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
267267 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut 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.
268+- `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
268269 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
269270 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
270271 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
@@ -272,7 +273,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
272273 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
273274 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
274275
275-When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
276+When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
276277
277278 ## Modes
278279
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- fourteen core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.12+- fifteen core built-in tools (read, write, edit, bash, python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -265,6 +265,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
265 - `edit`: one or more exact-match replacements in an existing file.265 - `edit`: one or more exact-match replacements in an existing file.
266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
267 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut 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.267 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut 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.
268+- `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
268 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.269 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
269 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).270 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
270 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).271 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
@@ -272,7 +273,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
272 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.273 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
273 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.274 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
274 275
275-When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.276+When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
276 277
277 ## Modes278 ## Modes
278 279
M docs/extensions.md +4 -4
@@ -225,12 +225,12 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225225 ```
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,
228+`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `lsp`, `web_search`,
229229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231-Every native public-web capability name, plus `grep`, `schedule`, `update_goal`,
232-and `update_plan`, is reserved even when it is unavailable, so an extension
233-cannot claim or replace it.
231+Every native public-web capability name, plus `grep`, `python`, `schedule`,
232+`update_goal`, and `update_plan`, is reserved even when it is unavailable, so an
233+extension cannot claim or replace it.
234234
235235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
236236
@@ -225,12 +225,12 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225 ```225 ```
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,228+`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `lsp`, `web_search`,
229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231-Every native public-web capability name, plus `grep`, `schedule`, `update_goal`,231+Every native public-web capability name, plus `grep`, `python`, `schedule`,
232-and `update_plan`, is reserved even when it is unavailable, so an extension232+`update_goal`, and `update_plan`, is reserved even when it is unavailable, so an
233-cannot claim or replace it.233+extension cannot claim or replace it.
234 234
235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
236 236
M packages/agent/build.go +34 -2
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123123 // Web capability and other native names remain reserved even when their
124124 // policy excludes the current session. An extension must not turn a
125125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127127 continue
128128 }
129129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -858,6 +858,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858858 }
859859 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
860860 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
861+ maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
861862
862863 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
863864
@@ -1309,6 +1310,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
13091310 v.Sandbox = s
13101311 case *tools.BashTool:
13111312 v.Sandbox = s
1313+ case *tools.PythonTool:
1314+ v.Sandbox = s
13121315 case *tools.CreateWorktreeTool:
13131316 v.Sandbox = s
13141317 case *tools.LSPTool:
@@ -1396,6 +1399,35 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13961399 return reg
13971400 }
13981401
1402+// pythonToolAllowed reports whether the python tool belongs in a registry
1403+// built for args. Python requires the absence of a packaged-agent
1404+// PermissionSet and either no explicit tool selection (the omitted --tools
1405+// default) or explicit inclusion of "python" in a nonempty list. An
1406+// explicitly empty selection enables no Python even though other built-in
1407+// tools retain their historical empty-list behavior.
1408+func pythonToolAllowed(args Args) bool {
1409+ if args.NoTools || args.PermissionSet != nil {
1410+ return false
1411+ }
1412+ if len(args.Tools) == 0 {
1413+ return !args.ToolsSet
1414+ }
1415+ return toolListContains(args.Tools, "python")
1416+}
1417+
1418+// maybeAddPythonTool registers the python tool with its configured
1419+// interpreter. Kept outside buildToolRegistry so that helper's signature
1420+// stays unchanged; the interpreter stays available across rebuilds because
1421+// Resolve threads the persisted config value on every call. An unavailable
1422+// interpreter never blocks startup: resolution is lazy and failures surface
1423+// as actionable tool errors on invocation.
1424+func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1425+ if reg == nil || !pythonToolAllowed(args) {
1426+ return
1427+ }
1428+ reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1429+}
1430+
13991431 func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
14001432 if len(args.Tools) == 0 {
14011433 return true
@@ -1464,7 +1496,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
14641496 return false
14651497 }
14661498
1467-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1499+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
14681500
14691501 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
14701502 var out []ToolSummary
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123 // Web capability and other native names remain reserved even when their123 // Web capability and other native names remain reserved even when their
124 // policy excludes the current session. An extension must not turn a124 // policy excludes the current session. An extension must not turn a
125 // normal CLI opt-out into a differently implemented capability.125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127 continue127 continue
128 }128 }
129 if _, exists := r.ToolRegistry[info.Name]; exists {129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -858,6 +858,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858 }858 }
859 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)859 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
860 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))860 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
861+ maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
861 862
862 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())863 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
863 864
@@ -1309,6 +1310,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1309 v.Sandbox = s1310 v.Sandbox = s
1310 case *tools.BashTool:1311 case *tools.BashTool:
1311 v.Sandbox = s1312 v.Sandbox = s
1313+ case *tools.PythonTool:
1314+ v.Sandbox = s
1312 case *tools.CreateWorktreeTool:1315 case *tools.CreateWorktreeTool:
1313 v.Sandbox = s1316 v.Sandbox = s
1314 case *tools.LSPTool:1317 case *tools.LSPTool:
@@ -1396,6 +1399,35 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1396 return reg1399 return reg
1397 }1400 }
1398 1401
1402+// pythonToolAllowed reports whether the python tool belongs in a registry
1403+// built for args. Python requires the absence of a packaged-agent
1404+// PermissionSet and either no explicit tool selection (the omitted --tools
1405+// default) or explicit inclusion of "python" in a nonempty list. An
1406+// explicitly empty selection enables no Python even though other built-in
1407+// tools retain their historical empty-list behavior.
1408+func pythonToolAllowed(args Args) bool {
1409+ if args.NoTools || args.PermissionSet != nil {
1410+ return false
1411+ }
1412+ if len(args.Tools) == 0 {
1413+ return !args.ToolsSet
1414+ }
1415+ return toolListContains(args.Tools, "python")
1416+}
1417+
1418+// maybeAddPythonTool registers the python tool with its configured
1419+// interpreter. Kept outside buildToolRegistry so that helper's signature
1420+// stays unchanged; the interpreter stays available across rebuilds because
1421+// Resolve threads the persisted config value on every call. An unavailable
1422+// interpreter never blocks startup: resolution is lazy and failures surface
1423+// as actionable tool errors on invocation.
1424+func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1425+ if reg == nil || !pythonToolAllowed(args) {
1426+ return
1427+ }
1428+ reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1429+}
1430+
1399 func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {1431 func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1400 if len(args.Tools) == 0 {1432 if len(args.Tools) == 0 {
1401 return true1433 return true
@@ -1464,7 +1496,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1464 return false1496 return false
1465 }1497 }
1466 1498
1467-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}1499+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1468 1500
1469 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1501 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1470 var out []ToolSummary1502 var out []ToolSummary
A packages/agent/build_python_test.go +263 -0
@@ -0,0 +1,263 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+func pythonTestEnv(t *testing.T) (project string) {
13+ t.Helper()
14+ root := t.TempDir()
15+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
16+ t.Setenv("HOME", filepath.Join(root, "home"))
17+ t.Setenv("USERPROFILE", filepath.Join(root, "home"))
18+ project = filepath.Join(root, "project")
19+ if err := os.MkdirAll(project, 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ return project
23+}
24+
25+func pythonTestArgs(project string) Args {
26+ return Args{
27+ Provider: "ollama",
28+ Model: "any-local-model",
29+ CWD: project,
30+ NoLSP: true,
31+ NoSkill: true,
32+ NoContextFiles: true,
33+ }
34+}
35+
36+func resolvePython(t *testing.T, args Args) Resolved {
37+ t.Helper()
38+ resolved, err := Resolve(args, false)
39+ if err != nil {
40+ t.Fatalf("Resolve: %v", err)
41+ }
42+ return resolved
43+}
44+
45+func pythonToolFrom(t *testing.T, r Resolved) *tools.PythonTool {
46+ t.Helper()
47+ tool, ok := r.ToolRegistry["python"]
48+ if !ok {
49+ t.Fatal("python not registered")
50+ }
51+ py, ok := tool.(*tools.PythonTool)
52+ if !ok {
53+ t.Fatalf("python = %T, want *tools.PythonTool", tool)
54+ }
55+ return py
56+}
57+
58+func TestPythonRegisteredByDefault(t *testing.T) {
59+ project := pythonTestEnv(t)
60+ resolved := resolvePython(t, pythonTestArgs(project))
61+ py := pythonToolFrom(t, resolved)
62+ if py.CWD != project {
63+ t.Fatalf("python CWD = %q, want %q", py.CWD, project)
64+ }
65+ if py.Interpreter != "" {
66+ t.Fatalf("python Interpreter = %q, want empty default", py.Interpreter)
67+ }
68+ if py.Sandbox != resolved.Sandbox {
69+ t.Fatal("python must share the live sandbox pointer")
70+ }
71+}
72+
73+func TestPythonExplicitEmptySelectionDenied(t *testing.T) {
74+ project := pythonTestEnv(t)
75+ args := pythonTestArgs(project)
76+ args.ToolsSet = true
77+ args.Tools = []string{}
78+ resolved := resolvePython(t, args)
79+ if _, ok := resolved.ToolRegistry["python"]; ok {
80+ t.Fatal("explicit empty selection must not enable python")
81+ }
82+ // Other tools retain their historical empty-list behavior.
83+ if _, ok := resolved.ToolRegistry["bash"]; !ok {
84+ t.Fatal("explicit empty selection must preserve other tools' behavior")
85+ }
86+}
87+
88+func TestPythonExplicitInclusionExclusion(t *testing.T) {
89+ project := pythonTestEnv(t)
90+ included := pythonTestArgs(project)
91+ included.Tools = []string{"python"}
92+ resolved := resolvePython(t, included)
93+ if _, ok := resolved.ToolRegistry["python"]; !ok {
94+ t.Fatal("explicit python selection must register python")
95+ }
96+ if _, ok := resolved.ToolRegistry["bash"]; ok {
97+ t.Fatal("explicit python-only selection must not register bash")
98+ }
99+
100+ excluded := pythonTestArgs(project)
101+ excluded.Tools = []string{"bash"}
102+ resolved = resolvePython(t, excluded)
103+ if _, ok := resolved.ToolRegistry["python"]; ok {
104+ t.Fatal("bash-only selection must not register python")
105+ }
106+ if _, ok := resolved.ToolRegistry["bash"]; !ok {
107+ t.Fatal("bash-only selection must register bash")
108+ }
109+}
110+
111+func TestPythonNoToolsAndPackagedDenial(t *testing.T) {
112+ project := pythonTestEnv(t)
113+ args := pythonTestArgs(project)
114+ args.NoTools = true
115+ if resolved := resolvePython(t, args); len(resolved.ToolRegistry) != 0 {
116+ t.Fatalf("NoTools registry = %d tools, want empty", len(resolved.ToolRegistry))
117+ }
118+
119+ args = pythonTestArgs(project)
120+ args.PermissionSet = &tools.PermissionSet{}
121+ resolved := resolvePython(t, args)
122+ if _, ok := resolved.ToolRegistry["python"]; ok {
123+ t.Fatal("packaged agents must not receive python")
124+ }
125+
126+ // SDK-shaped selections: nil/empty without provenance keeps the default.
127+ if !pythonToolAllowed(Args{}) {
128+ t.Fatal("omitted selection must allow python")
129+ }
130+ if pythonToolAllowed(Args{ToolsSet: true}) {
131+ t.Fatal("explicit empty selection must deny python")
132+ }
133+ if !pythonToolAllowed(Args{Tools: []string{"bash", "python"}}) {
134+ t.Fatal("explicit inclusion must allow python")
135+ }
136+}
137+
138+func TestPythonSummaryPresenceAbsence(t *testing.T) {
139+ project := pythonTestEnv(t)
140+ resolved := resolvePython(t, pythonTestArgs(project))
141+ found := false
142+ for _, s := range resolved.ToolSummary {
143+ if s.Name == "python" {
144+ found = true
145+ }
146+ }
147+ if !found {
148+ t.Fatal("default summary must advertise python")
149+ }
150+
151+ args := pythonTestArgs(project)
152+ args.Tools = []string{"bash"}
153+ resolved = resolvePython(t, args)
154+ for _, s := range resolved.ToolSummary {
155+ if s.Name == "python" {
156+ t.Fatal("bash-only summary must not advertise python")
157+ }
158+ }
159+}
160+
161+func TestPythonConfigRoundTripAndAssembly(t *testing.T) {
162+ project := pythonTestEnv(t)
163+ cfg, err := LoadConfig()
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ cfg.PythonInterpreter = "/opt/py/bin/python"
168+ if err := SaveConfig(cfg); err != nil {
169+ t.Fatal(err)
170+ }
171+ loaded, err := LoadConfig()
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ if loaded.PythonInterpreter != "/opt/py/bin/python" {
176+ t.Fatalf("config round-trip = %q", loaded.PythonInterpreter)
177+ }
178+ resolved := resolvePython(t, pythonTestArgs(project))
179+ if got := pythonToolFrom(t, resolved).Interpreter; got != "/opt/py/bin/python" {
180+ t.Fatalf("assembled interpreter = %q", got)
181+ }
182+}
183+
184+func TestPythonRebuildUsesCurrentCWDAndSandbox(t *testing.T) {
185+ root := t.TempDir()
186+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
187+ t.Setenv("HOME", filepath.Join(root, "home"))
188+ t.Setenv("USERPROFILE", filepath.Join(root, "home"))
189+ dirA := filepath.Join(root, "a")
190+ dirB := filepath.Join(root, "b")
191+ if err := os.MkdirAll(dirA, 0o755); err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := os.MkdirAll(dirB, 0o755); err != nil {
195+ t.Fatal(err)
196+ }
197+ args := pythonTestArgs(dirA)
198+ first := resolvePython(t, args)
199+ if pythonToolFrom(t, first).CWD != dirA {
200+ t.Fatal("first build must use its own CWD")
201+ }
202+
203+ // A normal rebuild picks up the current CWD rather than stale state,
204+ // and UseSandbox keeps the live jail state across rebuilds.
205+ args.CWD = dirB
206+ second := resolvePython(t, args)
207+ if pythonToolFrom(t, second).CWD != dirB {
208+ t.Fatalf("rebuild CWD = %q, want %q", pythonToolFrom(t, second).CWD, dirB)
209+ }
210+ fresh := tools.NewSandbox(dirB)
211+ fresh.Lock()
212+ second.UseSandbox(fresh)
213+ if got := pythonToolFrom(t, second).Sandbox; got != fresh {
214+ t.Fatal("UseSandbox must propagate the live sandbox to python")
215+ }
216+ if !pythonToolFrom(t, second).Sandbox.Locked() {
217+ t.Fatal("python must observe the live jail state")
218+ }
219+}
220+
221+type pythonExtStub struct {
222+ infos []ExtensionToolInfo
223+}
224+
225+func (s pythonExtStub) Tools() []ExtensionToolInfo { return s.infos }
226+
227+func (s pythonExtStub) NewExtensionTool(info ExtensionToolInfo) core.Tool {
228+ return &tools.GrepTool{}
229+}
230+
231+func TestPythonExtensionCannotBypassPolicyExclusion(t *testing.T) {
232+ project := pythonTestEnv(t)
233+ args := pythonTestArgs(project)
234+ args.Tools = []string{"bash"}
235+ resolved := resolvePython(t, args)
236+ stub := pythonExtStub{infos: []ExtensionToolInfo{
237+ {Extension: "x", Name: "python"},
238+ {Extension: "x", Name: "weather"},
239+ }}
240+ resolved.MergeExtensionTools(stub)
241+ if _, ok := resolved.ToolRegistry["python"]; ok {
242+ t.Fatal("an extension must not reintroduce a policy-excluded python")
243+ }
244+ if _, ok := resolved.ToolRegistry["weather"]; !ok {
245+ t.Fatal("unrelated extension tools must still merge")
246+ }
247+}
248+
249+func TestPythonUnavailableInterpreterDoesNotBlockStartup(t *testing.T) {
250+ project := pythonTestEnv(t)
251+ cfg, err := LoadConfig()
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ cfg.PythonInterpreter = filepath.Join(project, "missing", "python")
256+ if err := SaveConfig(cfg); err != nil {
257+ t.Fatal(err)
258+ }
259+ // Startup succeeds; the missing interpreter fails only on invocation
260+ // with an actionable error (covered at the tool boundary).
261+ resolved := resolvePython(t, pythonTestArgs(project))
262+ pythonToolFrom(t, resolved)
263+}
@@ -0,0 +1,263 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+func pythonTestEnv(t *testing.T) (project string) {
13+ t.Helper()
14+ root := t.TempDir()
15+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
16+ t.Setenv("HOME", filepath.Join(root, "home"))
17+ t.Setenv("USERPROFILE", filepath.Join(root, "home"))
18+ project = filepath.Join(root, "project")
19+ if err := os.MkdirAll(project, 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ return project
23+}
24+
25+func pythonTestArgs(project string) Args {
26+ return Args{
27+ Provider: "ollama",
28+ Model: "any-local-model",
29+ CWD: project,
30+ NoLSP: true,
31+ NoSkill: true,
32+ NoContextFiles: true,
33+ }
34+}
35+
36+func resolvePython(t *testing.T, args Args) Resolved {
37+ t.Helper()
38+ resolved, err := Resolve(args, false)
39+ if err != nil {
40+ t.Fatalf("Resolve: %v", err)
41+ }
42+ return resolved
43+}
44+
45+func pythonToolFrom(t *testing.T, r Resolved) *tools.PythonTool {
46+ t.Helper()
47+ tool, ok := r.ToolRegistry["python"]
48+ if !ok {
49+ t.Fatal("python not registered")
50+ }
51+ py, ok := tool.(*tools.PythonTool)
52+ if !ok {
53+ t.Fatalf("python = %T, want *tools.PythonTool", tool)
54+ }
55+ return py
56+}
57+
58+func TestPythonRegisteredByDefault(t *testing.T) {
59+ project := pythonTestEnv(t)
60+ resolved := resolvePython(t, pythonTestArgs(project))
61+ py := pythonToolFrom(t, resolved)
62+ if py.CWD != project {
63+ t.Fatalf("python CWD = %q, want %q", py.CWD, project)
64+ }
65+ if py.Interpreter != "" {
66+ t.Fatalf("python Interpreter = %q, want empty default", py.Interpreter)
67+ }
68+ if py.Sandbox != resolved.Sandbox {
69+ t.Fatal("python must share the live sandbox pointer")
70+ }
71+}
72+
73+func TestPythonExplicitEmptySelectionDenied(t *testing.T) {
74+ project := pythonTestEnv(t)
75+ args := pythonTestArgs(project)
76+ args.ToolsSet = true
77+ args.Tools = []string{}
78+ resolved := resolvePython(t, args)
79+ if _, ok := resolved.ToolRegistry["python"]; ok {
80+ t.Fatal("explicit empty selection must not enable python")
81+ }
82+ // Other tools retain their historical empty-list behavior.
83+ if _, ok := resolved.ToolRegistry["bash"]; !ok {
84+ t.Fatal("explicit empty selection must preserve other tools' behavior")
85+ }
86+}
87+
88+func TestPythonExplicitInclusionExclusion(t *testing.T) {
89+ project := pythonTestEnv(t)
90+ included := pythonTestArgs(project)
91+ included.Tools = []string{"python"}
92+ resolved := resolvePython(t, included)
93+ if _, ok := resolved.ToolRegistry["python"]; !ok {
94+ t.Fatal("explicit python selection must register python")
95+ }
96+ if _, ok := resolved.ToolRegistry["bash"]; ok {
97+ t.Fatal("explicit python-only selection must not register bash")
98+ }
99+
100+ excluded := pythonTestArgs(project)
101+ excluded.Tools = []string{"bash"}
102+ resolved = resolvePython(t, excluded)
103+ if _, ok := resolved.ToolRegistry["python"]; ok {
104+ t.Fatal("bash-only selection must not register python")
105+ }
106+ if _, ok := resolved.ToolRegistry["bash"]; !ok {
107+ t.Fatal("bash-only selection must register bash")
108+ }
109+}
110+
111+func TestPythonNoToolsAndPackagedDenial(t *testing.T) {
112+ project := pythonTestEnv(t)
113+ args := pythonTestArgs(project)
114+ args.NoTools = true
115+ if resolved := resolvePython(t, args); len(resolved.ToolRegistry) != 0 {
116+ t.Fatalf("NoTools registry = %d tools, want empty", len(resolved.ToolRegistry))
117+ }
118+
119+ args = pythonTestArgs(project)
120+ args.PermissionSet = &tools.PermissionSet{}
121+ resolved := resolvePython(t, args)
122+ if _, ok := resolved.ToolRegistry["python"]; ok {
123+ t.Fatal("packaged agents must not receive python")
124+ }
125+
126+ // SDK-shaped selections: nil/empty without provenance keeps the default.
127+ if !pythonToolAllowed(Args{}) {
128+ t.Fatal("omitted selection must allow python")
129+ }
130+ if pythonToolAllowed(Args{ToolsSet: true}) {
131+ t.Fatal("explicit empty selection must deny python")
132+ }
133+ if !pythonToolAllowed(Args{Tools: []string{"bash", "python"}}) {
134+ t.Fatal("explicit inclusion must allow python")
135+ }
136+}
137+
138+func TestPythonSummaryPresenceAbsence(t *testing.T) {
139+ project := pythonTestEnv(t)
140+ resolved := resolvePython(t, pythonTestArgs(project))
141+ found := false
142+ for _, s := range resolved.ToolSummary {
143+ if s.Name == "python" {
144+ found = true
145+ }
146+ }
147+ if !found {
148+ t.Fatal("default summary must advertise python")
149+ }
150+
151+ args := pythonTestArgs(project)
152+ args.Tools = []string{"bash"}
153+ resolved = resolvePython(t, args)
154+ for _, s := range resolved.ToolSummary {
155+ if s.Name == "python" {
156+ t.Fatal("bash-only summary must not advertise python")
157+ }
158+ }
159+}
160+
161+func TestPythonConfigRoundTripAndAssembly(t *testing.T) {
162+ project := pythonTestEnv(t)
163+ cfg, err := LoadConfig()
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ cfg.PythonInterpreter = "/opt/py/bin/python"
168+ if err := SaveConfig(cfg); err != nil {
169+ t.Fatal(err)
170+ }
171+ loaded, err := LoadConfig()
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ if loaded.PythonInterpreter != "/opt/py/bin/python" {
176+ t.Fatalf("config round-trip = %q", loaded.PythonInterpreter)
177+ }
178+ resolved := resolvePython(t, pythonTestArgs(project))
179+ if got := pythonToolFrom(t, resolved).Interpreter; got != "/opt/py/bin/python" {
180+ t.Fatalf("assembled interpreter = %q", got)
181+ }
182+}
183+
184+func TestPythonRebuildUsesCurrentCWDAndSandbox(t *testing.T) {
185+ root := t.TempDir()
186+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
187+ t.Setenv("HOME", filepath.Join(root, "home"))
188+ t.Setenv("USERPROFILE", filepath.Join(root, "home"))
189+ dirA := filepath.Join(root, "a")
190+ dirB := filepath.Join(root, "b")
191+ if err := os.MkdirAll(dirA, 0o755); err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := os.MkdirAll(dirB, 0o755); err != nil {
195+ t.Fatal(err)
196+ }
197+ args := pythonTestArgs(dirA)
198+ first := resolvePython(t, args)
199+ if pythonToolFrom(t, first).CWD != dirA {
200+ t.Fatal("first build must use its own CWD")
201+ }
202+
203+ // A normal rebuild picks up the current CWD rather than stale state,
204+ // and UseSandbox keeps the live jail state across rebuilds.
205+ args.CWD = dirB
206+ second := resolvePython(t, args)
207+ if pythonToolFrom(t, second).CWD != dirB {
208+ t.Fatalf("rebuild CWD = %q, want %q", pythonToolFrom(t, second).CWD, dirB)
209+ }
210+ fresh := tools.NewSandbox(dirB)
211+ fresh.Lock()
212+ second.UseSandbox(fresh)
213+ if got := pythonToolFrom(t, second).Sandbox; got != fresh {
214+ t.Fatal("UseSandbox must propagate the live sandbox to python")
215+ }
216+ if !pythonToolFrom(t, second).Sandbox.Locked() {
217+ t.Fatal("python must observe the live jail state")
218+ }
219+}
220+
221+type pythonExtStub struct {
222+ infos []ExtensionToolInfo
223+}
224+
225+func (s pythonExtStub) Tools() []ExtensionToolInfo { return s.infos }
226+
227+func (s pythonExtStub) NewExtensionTool(info ExtensionToolInfo) core.Tool {
228+ return &tools.GrepTool{}
229+}
230+
231+func TestPythonExtensionCannotBypassPolicyExclusion(t *testing.T) {
232+ project := pythonTestEnv(t)
233+ args := pythonTestArgs(project)
234+ args.Tools = []string{"bash"}
235+ resolved := resolvePython(t, args)
236+ stub := pythonExtStub{infos: []ExtensionToolInfo{
237+ {Extension: "x", Name: "python"},
238+ {Extension: "x", Name: "weather"},
239+ }}
240+ resolved.MergeExtensionTools(stub)
241+ if _, ok := resolved.ToolRegistry["python"]; ok {
242+ t.Fatal("an extension must not reintroduce a policy-excluded python")
243+ }
244+ if _, ok := resolved.ToolRegistry["weather"]; !ok {
245+ t.Fatal("unrelated extension tools must still merge")
246+ }
247+}
248+
249+func TestPythonUnavailableInterpreterDoesNotBlockStartup(t *testing.T) {
250+ project := pythonTestEnv(t)
251+ cfg, err := LoadConfig()
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ cfg.PythonInterpreter = filepath.Join(project, "missing", "python")
256+ if err := SaveConfig(cfg); err != nil {
257+ t.Fatal(err)
258+ }
259+ // Startup succeeds; the missing interpreter fails only on invocation
260+ // with an actionable error (covered at the tool boundary).
261+ resolved := resolvePython(t, pythonTestArgs(project))
262+ pythonToolFrom(t, resolved)
263+}
M packages/agent/config.go +7 -0
@@ -176,6 +176,13 @@ type Config struct {
176176 // (default) and "below_input".
177177 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
178178
179+ // PythonInterpreter selects the Python 3 executable for the python tool.
180+ // It is one executable path, not a command line: absolute paths are used
181+ // directly, relative paths resolve against the session working directory.
182+ // Empty means automatic discovery (VIRTUAL_ENV, ./.venv, then PATH).
183+ // Zut never installs Python or packages; the environment must exist.
184+ PythonInterpreter string `json:"python_interpreter,omitempty"`
185+
179186 // Insecure skips TLS verification for custom inference endpoints.
180187 Insecure bool `json:"insecure,omitempty"`
181188
@@ -176,6 +176,13 @@ type Config struct {
176 // (default) and "below_input".176 // (default) and "below_input".
177 TUIWorkingPosition string `json:"tui_working_position,omitempty"`177 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
178 178
179+ // PythonInterpreter selects the Python 3 executable for the python tool.
180+ // It is one executable path, not a command line: absolute paths are used
181+ // directly, relative paths resolve against the session working directory.
182+ // Empty means automatic discovery (VIRTUAL_ENV, ./.venv, then PATH).
183+ // Zut never installs Python or packages; the environment must exist.
184+ PythonInterpreter string `json:"python_interpreter,omitempty"`
185+
179 // Insecure skips TLS verification for custom inference endpoints.186 // Insecure skips TLS verification for custom inference endpoints.
180 Insecure bool `json:"insecure,omitempty"`187 Insecure bool `json:"insecure,omitempty"`
181 188
M packages/agent/ext/ext.go +4 -4
@@ -421,11 +421,11 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
424+// python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
425425 // schedule, update_goal, update_plan, and skill) are silently shadowed by the
426-// built-in. Every native public-web name, plus grep, schedule, update_goal, and
427-// update_plan, is reserved even when unavailable, so an extension cannot claim
428-// or replace it.
426+// built-in. Every native public-web name, plus grep, python, schedule,
427+// update_goal, and update_plan, is reserved even when unavailable, so an
428+// extension cannot claim or replace it.
429429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
430430 e.registerTool(name, description, schema, false, fn)
431431 }
@@ -421,11 +421,11 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, grep, lsp, web_search, web_open, web_find, web_click,424+// python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
425 // schedule, update_goal, update_plan, and skill) are silently shadowed by the425 // schedule, update_goal, update_plan, and skill) are silently shadowed by the
426-// built-in. Every native public-web name, plus grep, schedule, update_goal, and426+// built-in. Every native public-web name, plus grep, python, schedule,
427-// update_plan, is reserved even when unavailable, so an extension cannot claim427+// update_goal, and update_plan, is reserved even when unavailable, so an
428-// or replace it.428+// extension cannot claim or replace it.
429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
430 e.registerTool(name, description, schema, false, fn)430 e.registerTool(name, description, schema, false, fn)
431 }431 }
A packages/agent/sdk/python_tools_test.go +36 -0
@@ -0,0 +1,36 @@
1+package sdk
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPythonFollowsNormalRegistryBehavior(t *testing.T) {
8+ base := Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key"}
9+
10+ runtime, err := New(base)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ t.Cleanup(func() { _ = runtime.Close() })
15+ if _, ok := runtime.agent.ToolsSnapshot()["python"]; !ok {
16+ t.Fatal("default SDK runtime must expose python without bypassing tool selection")
17+ }
18+
19+ selected, err := New(Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key", Tools: []string{"bash"}})
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ t.Cleanup(func() { _ = selected.Close() })
24+ if _, ok := selected.agent.ToolsSnapshot()["python"]; ok {
25+ t.Fatal("SDK tool selection without python must not expose python")
26+ }
27+
28+ included, err := New(Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key", Tools: []string{"bash", "python"}})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ t.Cleanup(func() { _ = included.Close() })
33+ if _, ok := included.agent.ToolsSnapshot()["python"]; !ok {
34+ t.Fatal("SDK tool selection including python must expose python")
35+ }
36+}
@@ -0,0 +1,36 @@
1+package sdk
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPythonFollowsNormalRegistryBehavior(t *testing.T) {
8+ base := Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key"}
9+
10+ runtime, err := New(base)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ t.Cleanup(func() { _ = runtime.Close() })
15+ if _, ok := runtime.agent.ToolsSnapshot()["python"]; !ok {
16+ t.Fatal("default SDK runtime must expose python without bypassing tool selection")
17+ }
18+
19+ selected, err := New(Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key", Tools: []string{"bash"}})
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ t.Cleanup(func() { _ = selected.Close() })
24+ if _, ok := selected.agent.ToolsSnapshot()["python"]; ok {
25+ t.Fatal("SDK tool selection without python must not expose python")
26+ }
27+
28+ included, err := New(Config{Provider: "openai", Model: "gpt-5", APIKey: "synthetic-test-key", Tools: []string{"bash", "python"}})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ t.Cleanup(func() { _ = included.Close() })
33+ if _, ok := included.agent.ToolsSnapshot()["python"]; !ok {
34+ t.Fatal("SDK tool selection including python must expose python")
35+ }
36+}
M packages/agent/sdk/sdk.go +2 -2
@@ -78,8 +78,8 @@ type Config struct {
7878 BaseURL string
7979
8080 // Tools is the list of tools to enable. Nil/empty enables the ordinary
81- // built-ins: read, write, edit, bash, create_worktree, grep, lsp, update_goal,
82- // and update_plan. The public-web capability is excluded unless this list
81+ // built-ins: read, write, edit, bash, python, create_worktree, grep, lsp,
82+ // update_goal, and update_plan. The public-web capability is excluded unless this list
8383 // explicitly contains "web_search"; that selector enables web_search,
8484 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
8585 Tools []string
@@ -78,8 +78,8 @@ type Config struct {
78 BaseURL string78 BaseURL string
79 79
80 // Tools is the list of tools to enable. Nil/empty enables the ordinary80 // Tools is the list of tools to enable. Nil/empty enables the ordinary
81- // built-ins: read, write, edit, bash, create_worktree, grep, lsp, update_goal,81+ // built-ins: read, write, edit, bash, python, create_worktree, grep, lsp,
82- // and update_plan. The public-web capability is excluded unless this list82+ // update_goal, and update_plan. The public-web capability is excluded unless this list
83 // explicitly contains "web_search"; that selector enables web_search,83 // explicitly contains "web_search"; that selector enables web_search,
84 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.84 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
85 Tools []string85 Tools []string
M packages/agent/systemprompt.go +1 -1
@@ -99,7 +99,7 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
9999
100100 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown where it improves clarity, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them.
101101
102-For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
102+For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff. Likewise, python is for execution, not an opaque file-edit path: use write/edit for saved code.`
103103
104104 const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
105105
@@ -99,7 +99,7 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
99 99
100 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown where it improves clarity, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them.100 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown where it improves clarity, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them.
101 101
102-For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`102+For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff. Likewise, python is for execution, not an opaque file-edit path: use write/edit for saved code.`
103 103
104 const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`104 const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
105 105
A packages/agent/tools/python.go +370 -0
@@ -0,0 +1,370 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zut/packages/core"
20+ "github.com/bnema/zut/packages/provider"
21+)
22+
23+const (
24+ maxPythonLines = 2000
25+ maxPythonBytes = 50 * 1024
26+
27+ // maxPythonTimeoutSeconds is the largest whole-second timeout that can
28+ // be converted to a time.Duration without overflowing it.
29+ maxPythonTimeoutSeconds int64 = int64(time.Duration(1<<63-1) / time.Second)
30+)
31+
32+// PythonTool executes inline Python 3 code against an already prepared
33+// environment. Zut never installs Python, packages, uv, or virtual
34+// environments. Each call runs a fresh, stateless subprocess.
35+type PythonTool struct {
36+ CWD string
37+ Sandbox *Sandbox
38+ Interpreter string
39+
40+ // resolveHook substitutes interpreter resolution in tests. Nil uses
41+ // the default lazy resolver, which shares the caller's timeout budget.
42+ resolveHook func(ctx context.Context, cwd, configured string) (resolvedPython, error)
43+ // execHook substitutes process execution in tests. Nil runs a real
44+ // subprocess directly (no shell, no temp file).
45+ execHook func(ctx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error)
46+}
47+
48+// pythonCancelError carries the process-termination error from the
49+// platform Cancel callback to the Wait site. Cancel runs on exec's
50+// internal goroutine while Wait blocks, hence the mutex.
51+type pythonCancelError struct {
52+ mu sync.Mutex
53+ err error
54+}
55+
56+func (e *pythonCancelError) set(err error) {
57+ if e == nil {
58+ return
59+ }
60+ e.mu.Lock()
61+ defer e.mu.Unlock()
62+ e.err = err
63+}
64+
65+func (e *pythonCancelError) get() error {
66+ if e == nil {
67+ return nil
68+ }
69+ e.mu.Lock()
70+ defer e.mu.Unlock()
71+ return e.err
72+}
73+
74+// pythonExecOutcome is the raw result of one Python subprocess run.
75+type pythonExecOutcome struct {
76+ Captured string
77+ ExitCode int
78+ TimedOut bool
79+ Cancelled bool
80+ BytesTrunc bool
81+ LinesTrunc bool
82+ CleanupErr error
83+}
84+
85+type pythonArgs struct {
86+ Code string `json:"code"`
87+ Timeout *int64 `json:"timeout"`
88+}
89+
90+var pythonSchema = fmt.Sprintf(`{"type":"object","properties":{"code":{"type":"string","description":"Inline Python 3 source to execute via stdin (no interactive input; stdin observes EOF). Save reusable scripts with write/edit instead."},"timeout":{"type":"integer","minimum":1,"maximum":%d,"description":"Maximum runtime in seconds, including interpreter discovery."}},"required":["code","timeout"]}`, maxPythonTimeoutSeconds)
91+
92+func (t *PythonTool) Name() string { return "python" }
93+
94+func (t *PythonTool) Description() string {
95+ return "Execute inline Python 3 code with an existing interpreter (stateless stdin execution in the session cwd, merged stdout/stderr). Never installs packages; save reusable scripts with write/edit."
96+}
97+
98+func (t *PythonTool) Schema() json.RawMessage { return json.RawMessage(pythonSchema) }
99+
100+func pythonTimeoutDuration(seconds int64) (time.Duration, error) {
101+ if seconds < 1 || seconds > maxPythonTimeoutSeconds {
102+ return 0, fmt.Errorf("timeout must be between 1 and %d seconds", maxPythonTimeoutSeconds)
103+ }
104+ return time.Duration(seconds) * time.Second, nil
105+}
106+
107+// pythonExecArgv builds the direct execution argv. The executable and its
108+// arguments stay separate; they are never concatenated into a shell command.
109+func pythonExecArgv(exe string) []string {
110+ return []string{exe, "-u", "-B", "-"}
111+}
112+
113+func (t *PythonTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
114+ var a pythonArgs
115+ if err := json.Unmarshal(raw, &a); err != nil {
116+ return protocolToolError(fmt.Sprintf("invalid args: %v", err))
117+ }
118+ if strings.TrimSpace(a.Code) == "" {
119+ return protocolToolError("code is required")
120+ }
121+ if a.Timeout == nil {
122+ return protocolToolError("timeout is required")
123+ }
124+ timeout, err := pythonTimeoutDuration(*a.Timeout)
125+ if err != nil {
126+ return protocolToolError(err.Error())
127+ }
128+ // Admission checks run before any interpreter discovery, so a denied
129+ // call never probes the filesystem or spawns a process.
130+ if t.Sandbox != nil && t.Sandbox.Permissions != nil {
131+ return protocolToolError("permission denied: this agent cannot run python")
132+ }
133+ if t.Sandbox.Locked() {
134+ return protocolToolError("jailed: arbitrary Python cannot be confined by the current jail (use /unjail to disable)")
135+ }
136+ cwd := t.CWD
137+ if cwd == "" {
138+ cwd, _ = os.Getwd()
139+ }
140+
141+ // Resolution and execution share the caller's timeout budget.
142+ runCtx, cancel := context.WithTimeout(ctx, timeout)
143+ defer cancel()
144+
145+ interp, err := t.resolve(runCtx, cwd)
146+ if err != nil {
147+ return toolErr(err.Error()), nil
148+ }
149+
150+ outcome, err := t.run(runCtx, ctx, interp.Path, a.Code, cwd, progress)
151+ if err != nil {
152+ return core.ToolResult{}, err
153+ }
154+
155+ trimmed, truncLines := truncatePythonLines(outcome.Captured)
156+ outcome.LinesTrunc = truncLines
157+ text := formatPythonResult(interp, outcome, trimmed, int64(timeout/time.Second))
158+ isErr := outcome.ExitCode != 0 || outcome.TimedOut || outcome.Cancelled
159+ // Like bash, persist the byte-capped buffer for truncated results so
160+ // the model can read beyond the line-trimmed projection.
161+ fullPath := ""
162+ if outcome.BytesTrunc || outcome.LinesTrunc {
163+ fullPath = writePythonFullOutput(outcome.Captured)
164+ if fullPath != "" {
165+ text += fmt.Sprintf(" (full output: %s)", fullPath)
166+ }
167+ }
168+ details := map[string]any{
169+ "exit_code": outcome.ExitCode,
170+ "interpreter": interp.Path,
171+ "python_version": interp.Version.String(),
172+ "bytes_truncated": outcome.BytesTrunc,
173+ "lines_truncated": outcome.LinesTrunc,
174+ "full_output_path": fullPath,
175+ }
176+ if outcome.CleanupErr != nil {
177+ details["cleanup_error"] = outcome.CleanupErr.Error()
178+ }
179+ return core.ToolResult{
180+ Content: []provider.Content{provider.TextBlock{Text: text}},
181+ IsError: isErr,
182+ Details: details,
183+ }, nil
184+}
185+
186+func (t *PythonTool) resolve(ctx context.Context, cwd string) (resolvedPython, error) {
187+ if t.resolveHook != nil {
188+ return t.resolveHook(ctx, cwd, t.Interpreter)
189+ }
190+ return resolvePythonInterpreter(ctx, pythonResolveDeps{CWD: cwd, Configured: t.Interpreter})
191+}
192+
193+func (t *PythonTool) run(runCtx, parentCtx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error) {
194+ if t.execHook != nil {
195+ return t.execHook(runCtx, exe, code, cwd, progress)
196+ }
197+ return runPythonProcess(runCtx, parentCtx, exe, code, cwd, progress)
198+}
199+
200+// runPythonProcess runs one fresh subprocess directly with code on stdin.
201+// It bounds captured output, drains pipes, and reports cleanup failure
202+// without claiming every detached descendant was terminated.
203+func runPythonProcess(runCtx, parentCtx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error) {
204+ var outcome pythonExecOutcome
205+ cmd := exec.CommandContext(runCtx, exe, "-u", "-B", "-")
206+ cmd.Dir = cwd
207+ cmd.Env = pythonChildEnv()
208+ cmd.Stdin = strings.NewReader(code)
209+
210+ pr, pw := io.Pipe()
211+ cmd.Stdout = pw
212+ cmd.Stderr = pw
213+ // The Cancel callback runs on exec's internal goroutine while Wait
214+ // blocks here: capture its termination error under a mutex so a
215+ // failed group/tree kill is reported as cleanup metadata instead of
216+ // being folded into Wait's process error and lost.
217+ var cancelErr pythonCancelError
218+ closeOutput := configurePythonProcess(cmd, pw, &cancelErr)
219+ defer closeOutput()
220+
221+ if err := cmd.Start(); err != nil {
222+ _ = pw.Close()
223+ return outcome, fmt.Errorf("start: %w", err)
224+ }
225+
226+ captured := &bytes.Buffer{}
227+ discarded := false
228+ readerDone := make(chan struct{})
229+ go func() {
230+ defer close(readerDone)
231+ buf := make([]byte, 4096)
232+ for {
233+ n, err := pr.Read(buf)
234+ if n > 0 {
235+ chunk := buf[:n]
236+ if appendPythonChunk(captured, chunk) {
237+ discarded = true
238+ }
239+ if progress != nil {
240+ progress(string(chunk))
241+ }
242+ }
243+ if err != nil {
244+ return
245+ }
246+ }
247+ }()
248+
249+ waitErr := cmd.Wait()
250+ closeOutput()
251+ <-readerDone
252+
253+ outcome.Captured = captured.String()
254+ outcome.BytesTrunc = discarded
255+
256+ exitCode := 0
257+ if waitErr != nil {
258+ if ee, ok := waitErr.(*exec.ExitError); ok {
259+ exitCode = ee.ExitCode()
260+ } else if errors.Is(waitErr, exec.ErrWaitDelay) {
261+ // The interpreter exited successfully but a detached
262+ // descendant holds the output pipe: keep exit 0 and report
263+ // the late cleanup as metadata instead of a failure.
264+ outcome.CleanupErr = waitErr
265+ } else {
266+ exitCode = -1
267+ }
268+ }
269+ outcome.ExitCode = exitCode
270+ if err := cancelErr.get(); err != nil {
271+ // Termination itself failed: descendants may still be running.
272+ // Report it as cleanup metadata, never as an execution error
273+ // when the interpreter already exited successfully.
274+ if outcome.CleanupErr == nil {
275+ outcome.CleanupErr = err
276+ }
277+ }
278+ if errors.Is(runCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
279+ outcome.TimedOut = true
280+ } else if parentCtx.Err() != nil || errors.Is(runCtx.Err(), context.Canceled) {
281+ outcome.Cancelled = true
282+ }
283+ return outcome, nil
284+}
285+
286+// appendPythonChunk appends chunk to captured up to maxPythonBytes. It
287+// reports whether any bytes were discarded, so output exactly filling the
288+// budget is not misreported as truncated.
289+func appendPythonChunk(captured *bytes.Buffer, chunk []byte) (discarded bool) {
290+ if len(chunk) == 0 {
291+ return false
292+ }
293+ if captured.Len() >= maxPythonBytes {
294+ return true
295+ }
296+ if room := maxPythonBytes - captured.Len(); len(chunk) > room {
297+ captured.Write(chunk[:room])
298+ return true
299+ }
300+ captured.Write(chunk)
301+ return false
302+}
303+
304+// writePythonFullOutput persists the byte-capped buffer for truncated
305+// results, mirroring the bash full-output artifact (zut-python prefix).
306+func writePythonFullOutput(s string) string {
307+ b := make([]byte, 6)
308+ if _, err := rand.Read(b); err != nil {
309+ return ""
310+ }
311+ name := filepath.Join(os.TempDir(), "zut-python-"+hex.EncodeToString(b)+".log")
312+ if err := os.WriteFile(name, []byte(s), 0o600); err != nil {
313+ return ""
314+ }
315+ return name
316+}
317+
318+// truncatePythonLines bounds the logical line count of captured output.
319+// A trailing newline does not create an extra line, matching grepOutput.
320+func truncatePythonLines(output string) (trimmed string, truncLines bool) {
321+ lines := strings.Split(output, "\n")
322+ if strings.HasSuffix(output, "\n") && len(lines) > 0 {
323+ lines = lines[:len(lines)-1]
324+ }
325+ if len(lines) > maxPythonLines {
326+ return strings.Join(lines[:maxPythonLines], "\n"), true
327+ }
328+ return output, false
329+}
330+
331+// formatPythonResult renders interpreter/version, captured output, and
332+// exit/timeout/cancellation status. Truncation is marked explicitly and
333+// the caller persists the full output artifact. The caller supplies the
334+// line-trimmed output so Details and text agree on lines_truncated.
335+func formatPythonResult(interp resolvedPython, outcome pythonExecOutcome, trimmed string, timeoutSeconds int64) string {
336+ var sb strings.Builder
337+ fmt.Fprintf(&sb, "python %s (%s)\n", interp.Version.String(), interp.Path)
338+ if trimmed != "" {
339+ sb.WriteString("\n")
340+ sb.WriteString(trimmed)
341+ if !strings.HasSuffix(trimmed, "\n") {
342+ sb.WriteString("\n")
343+ }
344+ }
345+ if outcome.LinesTrunc {
346+ fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxPythonLines)
347+ }
348+ if outcome.BytesTrunc {
349+ fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxPythonBytes)
350+ }
351+ sb.WriteString("\n")
352+ switch {
353+ case outcome.TimedOut:
354+ fmt.Fprintf(&sb, "[timed out after %d second", timeoutSeconds)
355+ if timeoutSeconds != 1 {
356+ sb.WriteByte('s')
357+ }
358+ sb.WriteByte(']')
359+ case outcome.Cancelled:
360+ sb.WriteString("[cancelled]")
361+ case outcome.ExitCode == 0:
362+ sb.WriteString("[exit 0]")
363+ default:
364+ fmt.Fprintf(&sb, "[exit %d]", outcome.ExitCode)
365+ }
366+ if outcome.CleanupErr != nil {
367+ fmt.Fprintf(&sb, " [cleanup: %s]", outcome.CleanupErr.Error())
368+ }
369+ return sb.String()
370+}
@@ -0,0 +1,370 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zut/packages/core"
20+ "github.com/bnema/zut/packages/provider"
21+)
22+
23+const (
24+ maxPythonLines = 2000
25+ maxPythonBytes = 50 * 1024
26+
27+ // maxPythonTimeoutSeconds is the largest whole-second timeout that can
28+ // be converted to a time.Duration without overflowing it.
29+ maxPythonTimeoutSeconds int64 = int64(time.Duration(1<<63-1) / time.Second)
30+)
31+
32+// PythonTool executes inline Python 3 code against an already prepared
33+// environment. Zut never installs Python, packages, uv, or virtual
34+// environments. Each call runs a fresh, stateless subprocess.
35+type PythonTool struct {
36+ CWD string
37+ Sandbox *Sandbox
38+ Interpreter string
39+
40+ // resolveHook substitutes interpreter resolution in tests. Nil uses
41+ // the default lazy resolver, which shares the caller's timeout budget.
42+ resolveHook func(ctx context.Context, cwd, configured string) (resolvedPython, error)
43+ // execHook substitutes process execution in tests. Nil runs a real
44+ // subprocess directly (no shell, no temp file).
45+ execHook func(ctx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error)
46+}
47+
48+// pythonCancelError carries the process-termination error from the
49+// platform Cancel callback to the Wait site. Cancel runs on exec's
50+// internal goroutine while Wait blocks, hence the mutex.
51+type pythonCancelError struct {
52+ mu sync.Mutex
53+ err error
54+}
55+
56+func (e *pythonCancelError) set(err error) {
57+ if e == nil {
58+ return
59+ }
60+ e.mu.Lock()
61+ defer e.mu.Unlock()
62+ e.err = err
63+}
64+
65+func (e *pythonCancelError) get() error {
66+ if e == nil {
67+ return nil
68+ }
69+ e.mu.Lock()
70+ defer e.mu.Unlock()
71+ return e.err
72+}
73+
74+// pythonExecOutcome is the raw result of one Python subprocess run.
75+type pythonExecOutcome struct {
76+ Captured string
77+ ExitCode int
78+ TimedOut bool
79+ Cancelled bool
80+ BytesTrunc bool
81+ LinesTrunc bool
82+ CleanupErr error
83+}
84+
85+type pythonArgs struct {
86+ Code string `json:"code"`
87+ Timeout *int64 `json:"timeout"`
88+}
89+
90+var pythonSchema = fmt.Sprintf(`{"type":"object","properties":{"code":{"type":"string","description":"Inline Python 3 source to execute via stdin (no interactive input; stdin observes EOF). Save reusable scripts with write/edit instead."},"timeout":{"type":"integer","minimum":1,"maximum":%d,"description":"Maximum runtime in seconds, including interpreter discovery."}},"required":["code","timeout"]}`, maxPythonTimeoutSeconds)
91+
92+func (t *PythonTool) Name() string { return "python" }
93+
94+func (t *PythonTool) Description() string {
95+ return "Execute inline Python 3 code with an existing interpreter (stateless stdin execution in the session cwd, merged stdout/stderr). Never installs packages; save reusable scripts with write/edit."
96+}
97+
98+func (t *PythonTool) Schema() json.RawMessage { return json.RawMessage(pythonSchema) }
99+
100+func pythonTimeoutDuration(seconds int64) (time.Duration, error) {
101+ if seconds < 1 || seconds > maxPythonTimeoutSeconds {
102+ return 0, fmt.Errorf("timeout must be between 1 and %d seconds", maxPythonTimeoutSeconds)
103+ }
104+ return time.Duration(seconds) * time.Second, nil
105+}
106+
107+// pythonExecArgv builds the direct execution argv. The executable and its
108+// arguments stay separate; they are never concatenated into a shell command.
109+func pythonExecArgv(exe string) []string {
110+ return []string{exe, "-u", "-B", "-"}
111+}
112+
113+func (t *PythonTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
114+ var a pythonArgs
115+ if err := json.Unmarshal(raw, &a); err != nil {
116+ return protocolToolError(fmt.Sprintf("invalid args: %v", err))
117+ }
118+ if strings.TrimSpace(a.Code) == "" {
119+ return protocolToolError("code is required")
120+ }
121+ if a.Timeout == nil {
122+ return protocolToolError("timeout is required")
123+ }
124+ timeout, err := pythonTimeoutDuration(*a.Timeout)
125+ if err != nil {
126+ return protocolToolError(err.Error())
127+ }
128+ // Admission checks run before any interpreter discovery, so a denied
129+ // call never probes the filesystem or spawns a process.
130+ if t.Sandbox != nil && t.Sandbox.Permissions != nil {
131+ return protocolToolError("permission denied: this agent cannot run python")
132+ }
133+ if t.Sandbox.Locked() {
134+ return protocolToolError("jailed: arbitrary Python cannot be confined by the current jail (use /unjail to disable)")
135+ }
136+ cwd := t.CWD
137+ if cwd == "" {
138+ cwd, _ = os.Getwd()
139+ }
140+
141+ // Resolution and execution share the caller's timeout budget.
142+ runCtx, cancel := context.WithTimeout(ctx, timeout)
143+ defer cancel()
144+
145+ interp, err := t.resolve(runCtx, cwd)
146+ if err != nil {
147+ return toolErr(err.Error()), nil
148+ }
149+
150+ outcome, err := t.run(runCtx, ctx, interp.Path, a.Code, cwd, progress)
151+ if err != nil {
152+ return core.ToolResult{}, err
153+ }
154+
155+ trimmed, truncLines := truncatePythonLines(outcome.Captured)
156+ outcome.LinesTrunc = truncLines
157+ text := formatPythonResult(interp, outcome, trimmed, int64(timeout/time.Second))
158+ isErr := outcome.ExitCode != 0 || outcome.TimedOut || outcome.Cancelled
159+ // Like bash, persist the byte-capped buffer for truncated results so
160+ // the model can read beyond the line-trimmed projection.
161+ fullPath := ""
162+ if outcome.BytesTrunc || outcome.LinesTrunc {
163+ fullPath = writePythonFullOutput(outcome.Captured)
164+ if fullPath != "" {
165+ text += fmt.Sprintf(" (full output: %s)", fullPath)
166+ }
167+ }
168+ details := map[string]any{
169+ "exit_code": outcome.ExitCode,
170+ "interpreter": interp.Path,
171+ "python_version": interp.Version.String(),
172+ "bytes_truncated": outcome.BytesTrunc,
173+ "lines_truncated": outcome.LinesTrunc,
174+ "full_output_path": fullPath,
175+ }
176+ if outcome.CleanupErr != nil {
177+ details["cleanup_error"] = outcome.CleanupErr.Error()
178+ }
179+ return core.ToolResult{
180+ Content: []provider.Content{provider.TextBlock{Text: text}},
181+ IsError: isErr,
182+ Details: details,
183+ }, nil
184+}
185+
186+func (t *PythonTool) resolve(ctx context.Context, cwd string) (resolvedPython, error) {
187+ if t.resolveHook != nil {
188+ return t.resolveHook(ctx, cwd, t.Interpreter)
189+ }
190+ return resolvePythonInterpreter(ctx, pythonResolveDeps{CWD: cwd, Configured: t.Interpreter})
191+}
192+
193+func (t *PythonTool) run(runCtx, parentCtx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error) {
194+ if t.execHook != nil {
195+ return t.execHook(runCtx, exe, code, cwd, progress)
196+ }
197+ return runPythonProcess(runCtx, parentCtx, exe, code, cwd, progress)
198+}
199+
200+// runPythonProcess runs one fresh subprocess directly with code on stdin.
201+// It bounds captured output, drains pipes, and reports cleanup failure
202+// without claiming every detached descendant was terminated.
203+func runPythonProcess(runCtx, parentCtx context.Context, exe, code, cwd string, progress func(string)) (pythonExecOutcome, error) {
204+ var outcome pythonExecOutcome
205+ cmd := exec.CommandContext(runCtx, exe, "-u", "-B", "-")
206+ cmd.Dir = cwd
207+ cmd.Env = pythonChildEnv()
208+ cmd.Stdin = strings.NewReader(code)
209+
210+ pr, pw := io.Pipe()
211+ cmd.Stdout = pw
212+ cmd.Stderr = pw
213+ // The Cancel callback runs on exec's internal goroutine while Wait
214+ // blocks here: capture its termination error under a mutex so a
215+ // failed group/tree kill is reported as cleanup metadata instead of
216+ // being folded into Wait's process error and lost.
217+ var cancelErr pythonCancelError
218+ closeOutput := configurePythonProcess(cmd, pw, &cancelErr)
219+ defer closeOutput()
220+
221+ if err := cmd.Start(); err != nil {
222+ _ = pw.Close()
223+ return outcome, fmt.Errorf("start: %w", err)
224+ }
225+
226+ captured := &bytes.Buffer{}
227+ discarded := false
228+ readerDone := make(chan struct{})
229+ go func() {
230+ defer close(readerDone)
231+ buf := make([]byte, 4096)
232+ for {
233+ n, err := pr.Read(buf)
234+ if n > 0 {
235+ chunk := buf[:n]
236+ if appendPythonChunk(captured, chunk) {
237+ discarded = true
238+ }
239+ if progress != nil {
240+ progress(string(chunk))
241+ }
242+ }
243+ if err != nil {
244+ return
245+ }
246+ }
247+ }()
248+
249+ waitErr := cmd.Wait()
250+ closeOutput()
251+ <-readerDone
252+
253+ outcome.Captured = captured.String()
254+ outcome.BytesTrunc = discarded
255+
256+ exitCode := 0
257+ if waitErr != nil {
258+ if ee, ok := waitErr.(*exec.ExitError); ok {
259+ exitCode = ee.ExitCode()
260+ } else if errors.Is(waitErr, exec.ErrWaitDelay) {
261+ // The interpreter exited successfully but a detached
262+ // descendant holds the output pipe: keep exit 0 and report
263+ // the late cleanup as metadata instead of a failure.
264+ outcome.CleanupErr = waitErr
265+ } else {
266+ exitCode = -1
267+ }
268+ }
269+ outcome.ExitCode = exitCode
270+ if err := cancelErr.get(); err != nil {
271+ // Termination itself failed: descendants may still be running.
272+ // Report it as cleanup metadata, never as an execution error
273+ // when the interpreter already exited successfully.
274+ if outcome.CleanupErr == nil {
275+ outcome.CleanupErr = err
276+ }
277+ }
278+ if errors.Is(runCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
279+ outcome.TimedOut = true
280+ } else if parentCtx.Err() != nil || errors.Is(runCtx.Err(), context.Canceled) {
281+ outcome.Cancelled = true
282+ }
283+ return outcome, nil
284+}
285+
286+// appendPythonChunk appends chunk to captured up to maxPythonBytes. It
287+// reports whether any bytes were discarded, so output exactly filling the
288+// budget is not misreported as truncated.
289+func appendPythonChunk(captured *bytes.Buffer, chunk []byte) (discarded bool) {
290+ if len(chunk) == 0 {
291+ return false
292+ }
293+ if captured.Len() >= maxPythonBytes {
294+ return true
295+ }
296+ if room := maxPythonBytes - captured.Len(); len(chunk) > room {
297+ captured.Write(chunk[:room])
298+ return true
299+ }
300+ captured.Write(chunk)
301+ return false
302+}
303+
304+// writePythonFullOutput persists the byte-capped buffer for truncated
305+// results, mirroring the bash full-output artifact (zut-python prefix).
306+func writePythonFullOutput(s string) string {
307+ b := make([]byte, 6)
308+ if _, err := rand.Read(b); err != nil {
309+ return ""
310+ }
311+ name := filepath.Join(os.TempDir(), "zut-python-"+hex.EncodeToString(b)+".log")
312+ if err := os.WriteFile(name, []byte(s), 0o600); err != nil {
313+ return ""
314+ }
315+ return name
316+}
317+
318+// truncatePythonLines bounds the logical line count of captured output.
319+// A trailing newline does not create an extra line, matching grepOutput.
320+func truncatePythonLines(output string) (trimmed string, truncLines bool) {
321+ lines := strings.Split(output, "\n")
322+ if strings.HasSuffix(output, "\n") && len(lines) > 0 {
323+ lines = lines[:len(lines)-1]
324+ }
325+ if len(lines) > maxPythonLines {
326+ return strings.Join(lines[:maxPythonLines], "\n"), true
327+ }
328+ return output, false
329+}
330+
331+// formatPythonResult renders interpreter/version, captured output, and
332+// exit/timeout/cancellation status. Truncation is marked explicitly and
333+// the caller persists the full output artifact. The caller supplies the
334+// line-trimmed output so Details and text agree on lines_truncated.
335+func formatPythonResult(interp resolvedPython, outcome pythonExecOutcome, trimmed string, timeoutSeconds int64) string {
336+ var sb strings.Builder
337+ fmt.Fprintf(&sb, "python %s (%s)\n", interp.Version.String(), interp.Path)
338+ if trimmed != "" {
339+ sb.WriteString("\n")
340+ sb.WriteString(trimmed)
341+ if !strings.HasSuffix(trimmed, "\n") {
342+ sb.WriteString("\n")
343+ }
344+ }
345+ if outcome.LinesTrunc {
346+ fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxPythonLines)
347+ }
348+ if outcome.BytesTrunc {
349+ fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxPythonBytes)
350+ }
351+ sb.WriteString("\n")
352+ switch {
353+ case outcome.TimedOut:
354+ fmt.Fprintf(&sb, "[timed out after %d second", timeoutSeconds)
355+ if timeoutSeconds != 1 {
356+ sb.WriteByte('s')
357+ }
358+ sb.WriteByte(']')
359+ case outcome.Cancelled:
360+ sb.WriteString("[cancelled]")
361+ case outcome.ExitCode == 0:
362+ sb.WriteString("[exit 0]")
363+ default:
364+ fmt.Fprintf(&sb, "[exit %d]", outcome.ExitCode)
365+ }
366+ if outcome.CleanupErr != nil {
367+ fmt.Fprintf(&sb, " [cleanup: %s]", outcome.CleanupErr.Error())
368+ }
369+ return sb.String()
370+}
A packages/agent/tools/python_exec_unix_test.go +241 -0
@@ -0,0 +1,241 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// writeFakeInterpreter creates an executable shell script acting as a fake
18+// Python interpreter. It ignores -u -B - arguments like a real interpreter
19+// would ignore unknown flags only if it is python; here the script simply
20+// never parses flags, which is sufficient to exercise stdin delivery,
21+// merged output, exit codes, and cancellation plumbing.
22+func writeFakeInterpreter(t *testing.T, body string) string {
23+ t.Helper()
24+ path := filepath.Join(t.TempDir(), "fakepy")
25+ content := "#!/bin/sh\n" + body + "\n"
26+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
27+ t.Fatal(err)
28+ }
29+ return path
30+}
31+
32+func execPythonWith(t *testing.T, exe, code string, timeout int64, progress func(string)) (string, bool, map[string]any) {
33+ t.Helper()
34+ tool := &PythonTool{
35+ CWD: t.TempDir(),
36+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
37+ return resolvedPython{Path: exe, Version: pythonVersion{Major: 3, Minor: 99, Micro: 0}}, nil
38+ },
39+ }
40+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": code, "timeout": timeout}), progress)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ text := res.Content[0].(provider.TextBlock).Text
45+ return text, res.IsError, res.Details.(map[string]any)
46+}
47+
48+func TestPythonStdinDeliveredAndEOFObserved(t *testing.T) {
49+ exe := writeFakeInterpreter(t, "cat")
50+ text, isErr, _ := execPythonWith(t, exe, "hello stdin\nsecond line", 5, nil)
51+ if isErr {
52+ t.Fatalf("unexpected error:\n%s", text)
53+ }
54+ if !strings.Contains(text, "hello stdin") || !strings.Contains(text, "[exit 0]") {
55+ t.Fatalf("got:\n%s", text)
56+ }
57+}
58+
59+func TestPythonMergedStderrAndNonzeroExit(t *testing.T) {
60+ exe := writeFakeInterpreter(t, "cat >/dev/null\necho boom >&2\nexit 3")
61+ text, isErr, details := execPythonWith(t, exe, "ignored", 5, nil)
62+ if !isErr {
63+ t.Fatalf("want IsError:\n%s", text)
64+ }
65+ if !strings.Contains(text, "boom") || !strings.Contains(text, "[exit 3]") {
66+ t.Fatalf("got:\n%s", text)
67+ }
68+ if details["exit_code"] != 3 {
69+ t.Fatalf("details = %#v", details)
70+ }
71+}
72+
73+func TestPythonTimeoutBoundsReturn(t *testing.T) {
74+ exe := writeFakeInterpreter(t, "cat >/dev/null\nsleep 30")
75+ start := time.Now()
76+ text, isErr, _ := execPythonWith(t, exe, "x", 1, nil)
77+ elapsed := time.Since(start)
78+ if !isErr || !strings.Contains(text, "[timed out after 1 second]") {
79+ t.Fatalf("got:\n%s", text)
80+ }
81+ if elapsed > 15*time.Second {
82+ t.Fatalf("timeout return took %s; cleanup did not bound the process", elapsed)
83+ }
84+}
85+
86+func TestPythonDescendantCannotRetainPipe(t *testing.T) {
87+ // A background grandchild inherits the output pipe. Process-group kill
88+ // plus pipe closing must still bound the return, and the successful
89+ // interpreter exit must not be misreported as an execution failure.
90+ exe := writeFakeInterpreter(t, "(sleep 30 &)\ncat >/dev/null\necho quick")
91+ start := time.Now()
92+ // Generous timeout: the interpreter exits immediately, so WaitDelay
93+ // expiry (not the invocation deadline) bounds the return.
94+ text, isErr, _ := execPythonWith(t, exe, "x", 30, nil)
95+ elapsed := time.Since(start)
96+ if !strings.Contains(text, "quick") {
97+ t.Fatalf("got:\n%s", text)
98+ }
99+ if isErr {
100+ t.Fatalf("detached descendant must not turn exit 0 into an error:\n%s", text)
101+ }
102+ if !strings.Contains(text, "[exit 0]") {
103+ t.Fatalf("want successful interpreter exit, got:\n%s", text)
104+ }
105+ if elapsed > 25*time.Second {
106+ t.Fatalf("return took %s; descendant retained the pipe", elapsed)
107+ }
108+}
109+
110+func TestPythonCancellationWhileSupplyingStdin(t *testing.T) {
111+ exe := writeFakeInterpreter(t, "while IFS= read -r line; do sleep 0.2; echo \"$line\"; done")
112+ tool := &PythonTool{
113+ CWD: t.TempDir(),
114+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
115+ return resolvedPython{Path: exe, Version: pythonVersion{Major: 3}}, nil
116+ },
117+ }
118+ big := strings.Repeat("line\n", 500)
119+ ctx, cancel := context.WithCancel(context.Background())
120+ go func() {
121+ time.Sleep(300 * time.Millisecond)
122+ cancel()
123+ }()
124+ start := time.Now()
125+ res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"code": big, "timeout": 30}), nil)
126+ elapsed := time.Since(start)
127+ if err != nil {
128+ // Cancellation may surface as a Go error or a cancelled result;
129+ // either is acceptable as long as the return is bounded.
130+ if elapsed > 15*time.Second {
131+ t.Fatalf("cancelled return took %s", elapsed)
132+ }
133+ return
134+ }
135+ text := res.Content[0].(provider.TextBlock).Text
136+ if !strings.Contains(text, "[cancelled]") && !res.IsError {
137+ t.Fatalf("want cancellation status, got:\n%s", text)
138+ }
139+ if elapsed > 15*time.Second {
140+ t.Fatalf("cancelled return took %s", elapsed)
141+ }
142+}
143+
144+// realPython3 returns a usable interpreter or skips the test.
145+func realPython3(t *testing.T) string {
146+ t.Helper()
147+ path, err := exec.LookPath("python3")
148+ if err != nil {
149+ t.Skip("no python3 on PATH; resolver coverage does not require installed Python")
150+ }
151+ ver, err := probePythonVersion(context.Background(), path)
152+ if err != nil || ver.Major != 3 {
153+ t.Skipf("python3 probe failed (%v); skipping runtime checks", err)
154+ }
155+ return path
156+}
157+
158+func TestPythonRealRunsInSessionCWD(t *testing.T) {
159+ py := realPython3(t)
160+ dir := t.TempDir()
161+ tool := &PythonTool{CWD: dir, Interpreter: py}
162+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
163+ "code": "import os; print(os.getcwd())",
164+ "timeout": 15,
165+ }), nil)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ text := res.Content[0].(provider.TextBlock).Text
170+ if res.IsError || !strings.Contains(text, dir) {
171+ t.Fatalf("got:\n%s", text)
172+ }
173+ // -B plus stdin execution must not create bytecode caches.
174+ entries, _ := os.ReadDir(dir)
175+ for _, e := range entries {
176+ if e.Name() == "__pycache__" {
177+ t.Fatal("execution created __pycache__; -B / stdin execution must not write bytecode")
178+ }
179+ }
180+}
181+
182+func TestPythonRealImportsAndMissingImport(t *testing.T) {
183+ py := realPython3(t)
184+ tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
185+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
186+ "code": "import json; print('imports ok')",
187+ "timeout": 15,
188+ }), nil)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "imports ok") {
193+ t.Fatalf("installed import failed:\n%s", res.Content[0].(provider.TextBlock).Text)
194+ }
195+ res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{
196+ "code": "import no_such_module_xyz_zut",
197+ "timeout": 15,
198+ }), nil)
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ text := res.Content[0].(provider.TextBlock).Text
203+ if !res.IsError || (!strings.Contains(text, "ModuleNotFoundError") && !strings.Contains(text, "ImportError")) {
204+ t.Fatalf("missing import must fail with an ordinary traceback, got:\n%s", text)
205+ }
206+}
207+
208+func TestPythonRealNoStateBetweenCalls(t *testing.T) {
209+ py := realPython3(t)
210+ tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
211+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "x_state_probe = 1", "timeout": 15}), nil); err != nil {
212+ t.Fatal(err)
213+ }
214+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "print(x_state_probe)", "timeout": 15}), nil)
215+ if err != nil {
216+ t.Fatal(err)
217+ }
218+ if !res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "NameError") {
219+ t.Fatalf("successive calls must not share variables, got:\n%s", res.Content[0].(provider.TextBlock).Text)
220+ }
221+}
222+
223+func TestPythonRealWrittenFilesPersist(t *testing.T) {
224+ py := realPython3(t)
225+ dir := t.TempDir()
226+ tool := &PythonTool{CWD: dir, Interpreter: py}
227+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
228+ "code": "open('created.txt','w').write('hi')",
229+ "timeout": 15,
230+ }), nil)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if res.IsError {
235+ t.Fatalf("got:\n%s", res.Content[0].(provider.TextBlock).Text)
236+ }
237+ b, err := os.ReadFile(filepath.Join(dir, "created.txt"))
238+ if err != nil || string(b) != "hi" {
239+ t.Fatalf("deliberately written file missing: %q %v", b, err)
240+ }
241+}
@@ -0,0 +1,241 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// writeFakeInterpreter creates an executable shell script acting as a fake
18+// Python interpreter. It ignores -u -B - arguments like a real interpreter
19+// would ignore unknown flags only if it is python; here the script simply
20+// never parses flags, which is sufficient to exercise stdin delivery,
21+// merged output, exit codes, and cancellation plumbing.
22+func writeFakeInterpreter(t *testing.T, body string) string {
23+ t.Helper()
24+ path := filepath.Join(t.TempDir(), "fakepy")
25+ content := "#!/bin/sh\n" + body + "\n"
26+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
27+ t.Fatal(err)
28+ }
29+ return path
30+}
31+
32+func execPythonWith(t *testing.T, exe, code string, timeout int64, progress func(string)) (string, bool, map[string]any) {
33+ t.Helper()
34+ tool := &PythonTool{
35+ CWD: t.TempDir(),
36+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
37+ return resolvedPython{Path: exe, Version: pythonVersion{Major: 3, Minor: 99, Micro: 0}}, nil
38+ },
39+ }
40+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": code, "timeout": timeout}), progress)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ text := res.Content[0].(provider.TextBlock).Text
45+ return text, res.IsError, res.Details.(map[string]any)
46+}
47+
48+func TestPythonStdinDeliveredAndEOFObserved(t *testing.T) {
49+ exe := writeFakeInterpreter(t, "cat")
50+ text, isErr, _ := execPythonWith(t, exe, "hello stdin\nsecond line", 5, nil)
51+ if isErr {
52+ t.Fatalf("unexpected error:\n%s", text)
53+ }
54+ if !strings.Contains(text, "hello stdin") || !strings.Contains(text, "[exit 0]") {
55+ t.Fatalf("got:\n%s", text)
56+ }
57+}
58+
59+func TestPythonMergedStderrAndNonzeroExit(t *testing.T) {
60+ exe := writeFakeInterpreter(t, "cat >/dev/null\necho boom >&2\nexit 3")
61+ text, isErr, details := execPythonWith(t, exe, "ignored", 5, nil)
62+ if !isErr {
63+ t.Fatalf("want IsError:\n%s", text)
64+ }
65+ if !strings.Contains(text, "boom") || !strings.Contains(text, "[exit 3]") {
66+ t.Fatalf("got:\n%s", text)
67+ }
68+ if details["exit_code"] != 3 {
69+ t.Fatalf("details = %#v", details)
70+ }
71+}
72+
73+func TestPythonTimeoutBoundsReturn(t *testing.T) {
74+ exe := writeFakeInterpreter(t, "cat >/dev/null\nsleep 30")
75+ start := time.Now()
76+ text, isErr, _ := execPythonWith(t, exe, "x", 1, nil)
77+ elapsed := time.Since(start)
78+ if !isErr || !strings.Contains(text, "[timed out after 1 second]") {
79+ t.Fatalf("got:\n%s", text)
80+ }
81+ if elapsed > 15*time.Second {
82+ t.Fatalf("timeout return took %s; cleanup did not bound the process", elapsed)
83+ }
84+}
85+
86+func TestPythonDescendantCannotRetainPipe(t *testing.T) {
87+ // A background grandchild inherits the output pipe. Process-group kill
88+ // plus pipe closing must still bound the return, and the successful
89+ // interpreter exit must not be misreported as an execution failure.
90+ exe := writeFakeInterpreter(t, "(sleep 30 &)\ncat >/dev/null\necho quick")
91+ start := time.Now()
92+ // Generous timeout: the interpreter exits immediately, so WaitDelay
93+ // expiry (not the invocation deadline) bounds the return.
94+ text, isErr, _ := execPythonWith(t, exe, "x", 30, nil)
95+ elapsed := time.Since(start)
96+ if !strings.Contains(text, "quick") {
97+ t.Fatalf("got:\n%s", text)
98+ }
99+ if isErr {
100+ t.Fatalf("detached descendant must not turn exit 0 into an error:\n%s", text)
101+ }
102+ if !strings.Contains(text, "[exit 0]") {
103+ t.Fatalf("want successful interpreter exit, got:\n%s", text)
104+ }
105+ if elapsed > 25*time.Second {
106+ t.Fatalf("return took %s; descendant retained the pipe", elapsed)
107+ }
108+}
109+
110+func TestPythonCancellationWhileSupplyingStdin(t *testing.T) {
111+ exe := writeFakeInterpreter(t, "while IFS= read -r line; do sleep 0.2; echo \"$line\"; done")
112+ tool := &PythonTool{
113+ CWD: t.TempDir(),
114+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
115+ return resolvedPython{Path: exe, Version: pythonVersion{Major: 3}}, nil
116+ },
117+ }
118+ big := strings.Repeat("line\n", 500)
119+ ctx, cancel := context.WithCancel(context.Background())
120+ go func() {
121+ time.Sleep(300 * time.Millisecond)
122+ cancel()
123+ }()
124+ start := time.Now()
125+ res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"code": big, "timeout": 30}), nil)
126+ elapsed := time.Since(start)
127+ if err != nil {
128+ // Cancellation may surface as a Go error or a cancelled result;
129+ // either is acceptable as long as the return is bounded.
130+ if elapsed > 15*time.Second {
131+ t.Fatalf("cancelled return took %s", elapsed)
132+ }
133+ return
134+ }
135+ text := res.Content[0].(provider.TextBlock).Text
136+ if !strings.Contains(text, "[cancelled]") && !res.IsError {
137+ t.Fatalf("want cancellation status, got:\n%s", text)
138+ }
139+ if elapsed > 15*time.Second {
140+ t.Fatalf("cancelled return took %s", elapsed)
141+ }
142+}
143+
144+// realPython3 returns a usable interpreter or skips the test.
145+func realPython3(t *testing.T) string {
146+ t.Helper()
147+ path, err := exec.LookPath("python3")
148+ if err != nil {
149+ t.Skip("no python3 on PATH; resolver coverage does not require installed Python")
150+ }
151+ ver, err := probePythonVersion(context.Background(), path)
152+ if err != nil || ver.Major != 3 {
153+ t.Skipf("python3 probe failed (%v); skipping runtime checks", err)
154+ }
155+ return path
156+}
157+
158+func TestPythonRealRunsInSessionCWD(t *testing.T) {
159+ py := realPython3(t)
160+ dir := t.TempDir()
161+ tool := &PythonTool{CWD: dir, Interpreter: py}
162+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
163+ "code": "import os; print(os.getcwd())",
164+ "timeout": 15,
165+ }), nil)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ text := res.Content[0].(provider.TextBlock).Text
170+ if res.IsError || !strings.Contains(text, dir) {
171+ t.Fatalf("got:\n%s", text)
172+ }
173+ // -B plus stdin execution must not create bytecode caches.
174+ entries, _ := os.ReadDir(dir)
175+ for _, e := range entries {
176+ if e.Name() == "__pycache__" {
177+ t.Fatal("execution created __pycache__; -B / stdin execution must not write bytecode")
178+ }
179+ }
180+}
181+
182+func TestPythonRealImportsAndMissingImport(t *testing.T) {
183+ py := realPython3(t)
184+ tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
185+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
186+ "code": "import json; print('imports ok')",
187+ "timeout": 15,
188+ }), nil)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "imports ok") {
193+ t.Fatalf("installed import failed:\n%s", res.Content[0].(provider.TextBlock).Text)
194+ }
195+ res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{
196+ "code": "import no_such_module_xyz_zut",
197+ "timeout": 15,
198+ }), nil)
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ text := res.Content[0].(provider.TextBlock).Text
203+ if !res.IsError || (!strings.Contains(text, "ModuleNotFoundError") && !strings.Contains(text, "ImportError")) {
204+ t.Fatalf("missing import must fail with an ordinary traceback, got:\n%s", text)
205+ }
206+}
207+
208+func TestPythonRealNoStateBetweenCalls(t *testing.T) {
209+ py := realPython3(t)
210+ tool := &PythonTool{CWD: t.TempDir(), Interpreter: py}
211+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "x_state_probe = 1", "timeout": 15}), nil); err != nil {
212+ t.Fatal(err)
213+ }
214+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"code": "print(x_state_probe)", "timeout": 15}), nil)
215+ if err != nil {
216+ t.Fatal(err)
217+ }
218+ if !res.IsError || !strings.Contains(res.Content[0].(provider.TextBlock).Text, "NameError") {
219+ t.Fatalf("successive calls must not share variables, got:\n%s", res.Content[0].(provider.TextBlock).Text)
220+ }
221+}
222+
223+func TestPythonRealWrittenFilesPersist(t *testing.T) {
224+ py := realPython3(t)
225+ dir := t.TempDir()
226+ tool := &PythonTool{CWD: dir, Interpreter: py}
227+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
228+ "code": "open('created.txt','w').write('hi')",
229+ "timeout": 15,
230+ }), nil)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if res.IsError {
235+ t.Fatalf("got:\n%s", res.Content[0].(provider.TextBlock).Text)
236+ }
237+ b, err := os.ReadFile(filepath.Join(dir, "created.txt"))
238+ if err != nil || string(b) != "hi" {
239+ t.Fatalf("deliberately written file missing: %q %v", b, err)
240+ }
241+}
A packages/agent/tools/python_proc_unix.go +63 -0
@@ -0,0 +1,63 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "io"
7+ "os/exec"
8+ "sync"
9+ "syscall"
10+ "time"
11+)
12+
13+// pythonProcessWaitDelay bounds process cleanup beyond the invocation or
14+// probe deadline. Bounded return is required; absolute containment of every
15+// detached descendant is not promised.
16+const pythonProcessWaitDelay = 5 * time.Second
17+
18+// configurePythonProcess makes context cancellation terminate the Python
19+// process group and closes the output pipe exactly once. It mirrors the
20+// Bash Unix mechanics without touching Bash behavior. When cancelErr is
21+// non-nil, the termination error from the Cancel callback is recorded
22+// there (guarded by its mutex): Wait folds that error into its own
23+// process error, so without this capture the cleanup detail is
24+// unreachable and a partial kill cannot report surviving descendants.
25+func configurePythonProcess(cmd *exec.Cmd, output io.Closer, cancelErr *pythonCancelError) func() {
26+ if cmd != nil {
27+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
28+ cmd.WaitDelay = pythonProcessWaitDelay
29+ }
30+ var closeOutput sync.Once
31+ closePipe := func() {
32+ closeOutput.Do(func() {
33+ if output != nil {
34+ _ = output.Close()
35+ }
36+ })
37+ }
38+ if cmd != nil {
39+ cmd.Cancel = func() error {
40+ closePipe()
41+ err := killPythonProcessGroup(cmd)
42+ if cancelErr != nil {
43+ cancelErr.set(err)
44+ }
45+ return err
46+ }
47+ }
48+ return closePipe
49+}
50+
51+// killPythonProcessGroup forcefully terminates the process group so
52+// descendants cannot retain the output pipe indefinitely.
53+func killPythonProcessGroup(cmd *exec.Cmd) error {
54+ if cmd == nil || cmd.Process == nil {
55+ return nil
56+ }
57+ pgid := cmd.Process.Pid
58+ if err := syscall.Kill(-pgid, syscall.SIGKILL); err == syscall.ESRCH {
59+ return nil
60+ } else {
61+ return err
62+ }
63+}
@@ -0,0 +1,63 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "io"
7+ "os/exec"
8+ "sync"
9+ "syscall"
10+ "time"
11+)
12+
13+// pythonProcessWaitDelay bounds process cleanup beyond the invocation or
14+// probe deadline. Bounded return is required; absolute containment of every
15+// detached descendant is not promised.
16+const pythonProcessWaitDelay = 5 * time.Second
17+
18+// configurePythonProcess makes context cancellation terminate the Python
19+// process group and closes the output pipe exactly once. It mirrors the
20+// Bash Unix mechanics without touching Bash behavior. When cancelErr is
21+// non-nil, the termination error from the Cancel callback is recorded
22+// there (guarded by its mutex): Wait folds that error into its own
23+// process error, so without this capture the cleanup detail is
24+// unreachable and a partial kill cannot report surviving descendants.
25+func configurePythonProcess(cmd *exec.Cmd, output io.Closer, cancelErr *pythonCancelError) func() {
26+ if cmd != nil {
27+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
28+ cmd.WaitDelay = pythonProcessWaitDelay
29+ }
30+ var closeOutput sync.Once
31+ closePipe := func() {
32+ closeOutput.Do(func() {
33+ if output != nil {
34+ _ = output.Close()
35+ }
36+ })
37+ }
38+ if cmd != nil {
39+ cmd.Cancel = func() error {
40+ closePipe()
41+ err := killPythonProcessGroup(cmd)
42+ if cancelErr != nil {
43+ cancelErr.set(err)
44+ }
45+ return err
46+ }
47+ }
48+ return closePipe
49+}
50+
51+// killPythonProcessGroup forcefully terminates the process group so
52+// descendants cannot retain the output pipe indefinitely.
53+func killPythonProcessGroup(cmd *exec.Cmd) error {
54+ if cmd == nil || cmd.Process == nil {
55+ return nil
56+ }
57+ pgid := cmd.Process.Pid
58+ if err := syscall.Kill(-pgid, syscall.SIGKILL); err == syscall.ESRCH {
59+ return nil
60+ } else {
61+ return err
62+ }
63+}
A packages/agent/tools/python_proc_windows.go +91 -0
@@ -0,0 +1,91 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os/exec"
10+ "strconv"
11+ "sync"
12+ "syscall"
13+ "time"
14+)
15+
16+// pythonProcessWaitDelay bounds process cleanup beyond the invocation or
17+// probe deadline. Bounded return is required; absolute containment of every
18+// detached descendant is not promised.
19+const pythonProcessWaitDelay = 5 * time.Second
20+
21+// pythonTreeKillTimeout bounds the tree-termination subprocess. On
22+// failure or timeout the caller falls back to a direct process kill.
23+const pythonTreeKillTimeout = 2 * time.Second
24+
25+// maxPythonKillOutput bounds termination-command output.
26+const maxPythonKillOutput = 4 * 1024
27+
28+// configurePythonProcess makes context cancellation terminate the Python
29+// process tree on Windows and closes the output pipe exactly once. Unlike
30+// Bash's unbounded taskkill path, tree termination is bounded with a
31+// direct-kill fallback. When cancelErr is non-nil, the termination error
32+// from the Cancel callback is recorded there (guarded by its mutex):
33+// Wait folds that error into its own process error, so without this
34+// capture the cleanup detail is unreachable and a partial kill cannot
35+// report surviving descendants.
36+func configurePythonProcess(cmd *exec.Cmd, output io.Closer, cancelErr *pythonCancelError) func() {
37+ if cmd != nil {
38+ cmd.SysProcAttr = &syscall.SysProcAttr{
39+ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
40+ }
41+ cmd.WaitDelay = pythonProcessWaitDelay
42+ }
43+ var closeOutput sync.Once
44+ closePipe := func() {
45+ closeOutput.Do(func() {
46+ if output != nil {
47+ _ = output.Close()
48+ }
49+ })
50+ }
51+ if cmd != nil {
52+ cmd.Cancel = func() error {
53+ closePipe()
54+ err := killPythonProcessTree(cmd)
55+ if cancelErr != nil {
56+ cancelErr.set(err)
57+ }
58+ return err
59+ }
60+ }
61+ return closePipe
62+}
63+
64+// killPythonProcessTree terminates the process tree with a bounded
65+// taskkill call, then falls back to a direct kill on failure or timeout.
66+func killPythonProcessTree(cmd *exec.Cmd) error {
67+ if cmd == nil || cmd.Process == nil {
68+ return nil
69+ }
70+ if err := boundedTaskkill(cmd.Process.Pid); err != nil {
71+ _ = cmd.Process.Kill()
72+ return err
73+ }
74+ return nil
75+}
76+
77+func boundedTaskkill(pid int) error {
78+ if pid <= 0 {
79+ return nil
80+ }
81+ ctx, cancel := context.WithTimeout(context.Background(), pythonTreeKillTimeout)
82+ defer cancel()
83+ killCmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
84+ var out bytes.Buffer
85+ killCmd.Stdout = &limitedWriter{W: &out, N: maxPythonKillOutput}
86+ killCmd.Stderr = &limitedWriter{W: &out, N: maxPythonKillOutput}
87+ if err := killCmd.Run(); err != nil {
88+ return err
89+ }
90+ return ctx.Err()
91+}
@@ -0,0 +1,91 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os/exec"
10+ "strconv"
11+ "sync"
12+ "syscall"
13+ "time"
14+)
15+
16+// pythonProcessWaitDelay bounds process cleanup beyond the invocation or
17+// probe deadline. Bounded return is required; absolute containment of every
18+// detached descendant is not promised.
19+const pythonProcessWaitDelay = 5 * time.Second
20+
21+// pythonTreeKillTimeout bounds the tree-termination subprocess. On
22+// failure or timeout the caller falls back to a direct process kill.
23+const pythonTreeKillTimeout = 2 * time.Second
24+
25+// maxPythonKillOutput bounds termination-command output.
26+const maxPythonKillOutput = 4 * 1024
27+
28+// configurePythonProcess makes context cancellation terminate the Python
29+// process tree on Windows and closes the output pipe exactly once. Unlike
30+// Bash's unbounded taskkill path, tree termination is bounded with a
31+// direct-kill fallback. When cancelErr is non-nil, the termination error
32+// from the Cancel callback is recorded there (guarded by its mutex):
33+// Wait folds that error into its own process error, so without this
34+// capture the cleanup detail is unreachable and a partial kill cannot
35+// report surviving descendants.
36+func configurePythonProcess(cmd *exec.Cmd, output io.Closer, cancelErr *pythonCancelError) func() {
37+ if cmd != nil {
38+ cmd.SysProcAttr = &syscall.SysProcAttr{
39+ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
40+ }
41+ cmd.WaitDelay = pythonProcessWaitDelay
42+ }
43+ var closeOutput sync.Once
44+ closePipe := func() {
45+ closeOutput.Do(func() {
46+ if output != nil {
47+ _ = output.Close()
48+ }
49+ })
50+ }
51+ if cmd != nil {
52+ cmd.Cancel = func() error {
53+ closePipe()
54+ err := killPythonProcessTree(cmd)
55+ if cancelErr != nil {
56+ cancelErr.set(err)
57+ }
58+ return err
59+ }
60+ }
61+ return closePipe
62+}
63+
64+// killPythonProcessTree terminates the process tree with a bounded
65+// taskkill call, then falls back to a direct kill on failure or timeout.
66+func killPythonProcessTree(cmd *exec.Cmd) error {
67+ if cmd == nil || cmd.Process == nil {
68+ return nil
69+ }
70+ if err := boundedTaskkill(cmd.Process.Pid); err != nil {
71+ _ = cmd.Process.Kill()
72+ return err
73+ }
74+ return nil
75+}
76+
77+func boundedTaskkill(pid int) error {
78+ if pid <= 0 {
79+ return nil
80+ }
81+ ctx, cancel := context.WithTimeout(context.Background(), pythonTreeKillTimeout)
82+ defer cancel()
83+ killCmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
84+ var out bytes.Buffer
85+ killCmd.Stdout = &limitedWriter{W: &out, N: maxPythonKillOutput}
86+ killCmd.Stderr = &limitedWriter{W: &out, N: maxPythonKillOutput}
87+ if err := killCmd.Run(); err != nil {
88+ return err
89+ }
90+ return ctx.Err()
91+}
A packages/agent/tools/python_runtime.go +436 -0
@@ -0,0 +1,436 @@
1+// Package tools Python runtime resolution.
2+//
3+// Resolution is lazy (per invocation, after permission checks) and shares
4+// the caller's timeout budget. Priority:
5+//
6+// 1. Configured python_interpreter (one executable path, no PATH lookup).
7+// 2. Active VIRTUAL_ENV.
8+// 3. Session-root .venv.
9+// 4. PATH candidates python3, then python.
10+//
11+// A configured or active environment that fails validation is an error,
12+// never a silent fallthrough. A present but invalid .venv is reported.
13+// A missing .venv permits system discovery. PATH candidates may fall
14+// through; cancellation stops discovery immediately.
15+package tools
16+
17+import (
18+ "bytes"
19+ "context"
20+ "encoding/json"
21+ "fmt"
22+ "io"
23+ "os"
24+ "os/exec"
25+ "path/filepath"
26+ "runtime"
27+ "strings"
28+ "time"
29+)
30+
31+// maxPythonProbeSeconds bounds each version probe. The probe also observes
32+// the remaining invocation deadline, whichever is shorter.
33+const maxPythonProbeSeconds = 5
34+
35+// maxPythonProbeOutput bounds captured probe stdout/stderr.
36+const maxPythonProbeOutput = 64 * 1024
37+
38+// pythonProbeCode prints structured version info using only the standard
39+// library. It runs under -I -S so user configuration cannot alter it.
40+const pythonProbeCode = `import json,sys;print(json.dumps({"major":sys.version_info[0],"minor":sys.version_info[1],"micro":sys.version_info[2],"executable":sys.executable}))`
41+
42+// pythonProbeArgv builds the isolated probe argv for exe. -B suppresses
43+// bytecode-cache writes during probing.
44+func pythonProbeArgv(exe string) []string {
45+ return []string{exe, "-I", "-S", "-B", "-c", pythonProbeCode}
46+}
47+
48+// pythonVersion is a validated Python 3 interpreter identity.
49+type pythonVersion struct {
50+ Major int
51+ Minor int
52+ Micro int
53+ Executable string
54+}
55+
56+func (v pythonVersion) String() string {
57+ return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Micro)
58+}
59+
60+// resolvedPython is a validated interpreter ready for execution.
61+type resolvedPython struct {
62+ Path string
63+ Version pythonVersion
64+}
65+
66+// pythonResolveDeps are the injectable seams for resolution. Production
67+// code fills them from the host; tests substitute fixtures. No
68+// package-global mutable overrides.
69+type pythonResolveDeps struct {
70+ GOOS string
71+ CWD string
72+ Configured string
73+ Getenv func(string) string
74+ LookPath func(string) (string, error)
75+ IsFile func(string) bool
76+ EvalSym func(string) (string, error)
77+ Probe func(ctx context.Context, exe string) (pythonVersion, error)
78+ // LayoutOK classifies a Windows candidate as a positively identified
79+ // installed interpreter (or venv). Nil uses the default filesystem
80+ // check. Ignored on non-Windows platforms.
81+ LayoutOK func(exe string) bool
82+}
83+
84+func (d *pythonResolveDeps) goos() string {
85+ if d.GOOS != "" {
86+ return d.GOOS
87+ }
88+ return runtime.GOOS
89+}
90+
91+func (d *pythonResolveDeps) getenv(k string) string {
92+ if d.Getenv != nil {
93+ return d.Getenv(k)
94+ }
95+ return os.Getenv(k)
96+}
97+
98+func (d *pythonResolveDeps) lookPath(name string) (string, error) {
99+ if d.LookPath != nil {
100+ return d.LookPath(name)
101+ }
102+ return exec.LookPath(name)
103+}
104+
105+func (d *pythonResolveDeps) isFile(path string) bool {
106+ if d.IsFile != nil {
107+ return d.IsFile(path)
108+ }
109+ info, err := os.Stat(path)
110+ return err == nil && !info.IsDir()
111+}
112+
113+func (d *pythonResolveDeps) evalSym(path string) (string, error) {
114+ if d.EvalSym != nil {
115+ return d.EvalSym(path)
116+ }
117+ return filepath.EvalSymlinks(path)
118+}
119+
120+func (d *pythonResolveDeps) probe(ctx context.Context, exe string) (pythonVersion, error) {
121+ if d.Probe != nil {
122+ return d.Probe(ctx, exe)
123+ }
124+ return probePythonVersion(ctx, exe)
125+}
126+
127+func (d *pythonResolveDeps) layoutOK(exe string) bool {
128+ if d.LayoutOK != nil {
129+ return d.LayoutOK(exe)
130+ }
131+ return hasWindowsRuntimeLayout(exe, d.isFile)
132+}
133+
134+// resolveConfiguredPythonPath maps a configured python_interpreter value to
135+// a filesystem path. Absolute paths are used directly; relative paths
136+// resolve against the session CWD, which is anchored to absolute first so
137+// a relative --cwd cannot stack (project/project/...). No PATH lookup, no
138+// shell expansion.
139+func resolveConfiguredPythonPath(cwd, configured string) string {
140+ if filepath.IsAbs(configured) {
141+ return filepath.Clean(configured)
142+ }
143+ cwd = absPythonCWD(cwd)
144+ return filepath.Join(cwd, filepath.FromSlash(configured))
145+}
146+
147+// absPythonCWD anchors the session CWD to absolute once. Resolution and
148+// execution both chdir into the session CWD, so every constructed
149+// candidate must survive that chdir.
150+func absPythonCWD(cwd string) string {
151+ if cwd == "" {
152+ cwd, _ = os.Getwd()
153+ }
154+ if abs, err := filepath.Abs(cwd); err == nil {
155+ return abs
156+ }
157+ return cwd
158+}
159+
160+// pythonVenvCandidate builds the interpreter path inside an environment
161+// root. Relative roots resolve against the session CWD.
162+func pythonVenvCandidate(cwd, root, goos string) string {
163+ if !filepath.IsAbs(root) {
164+ root = filepath.Join(absPythonCWD(cwd), filepath.FromSlash(root))
165+ }
166+ if goos == "windows" {
167+ return filepath.Join(root, "Scripts", "python.exe")
168+ }
169+ return filepath.Join(root, "bin", "python")
170+}
171+
172+// resolvePythonInterpreter implements the specified priority order.
173+func resolvePythonInterpreter(ctx context.Context, deps pythonResolveDeps) (resolvedPython, error) {
174+ goos := deps.goos()
175+ cwd := absPythonCWD(deps.CWD)
176+
177+ if err := ctx.Err(); err != nil {
178+ return resolvedPython{}, err
179+ }
180+
181+ // 1. Configured interpreter: fail closed, never fall through.
182+ if strings.TrimSpace(deps.Configured) != "" {
183+ path := resolveConfiguredPythonPath(cwd, strings.TrimSpace(deps.Configured))
184+ return resolvePythonFailClosed(ctx, &deps, goos, "configured python_interpreter", path)
185+ }
186+
187+ // 2. Active VIRTUAL_ENV: fail closed, never fall through.
188+ if venv := strings.TrimSpace(deps.getenv("VIRTUAL_ENV")); venv != "" {
189+ path := pythonVenvCandidate(cwd, venv, goos)
190+ return resolvePythonFailClosed(ctx, &deps, goos, "active VIRTUAL_ENV", path)
191+ }
192+
193+ // 3. Session-root .venv: missing permits system discovery, but a
194+ // present yet invalid environment is reported, not bypassed.
195+ if venvCandidate := pythonVenvCandidate(cwd, filepath.Join(cwd, ".venv"), goos); deps.isFile(venvCandidate) {
196+ return resolvePythonFailClosed(ctx, &deps, goos, "local .venv", venvCandidate)
197+ } else {
198+ // Also consider a .venv directory whose interpreter has a
199+ // version-suffixed name? No: only the canonical layout is valid.
200+ // A .venv dir without the canonical interpreter counts as
201+ // present-but-invalid.
202+ if dirExists(filepath.Join(cwd, ".venv")) {
203+ return resolvedPython{}, fmt.Errorf("local .venv at %q has no usable Python 3 interpreter at %q; create the environment or configure python_interpreter to an existing interpreter (zut never installs Python or packages)", filepath.Join(cwd, ".venv"), venvCandidate)
204+ }
205+ }
206+
207+ // 4. PATH candidates: absent or non-Python-3 entries fall through.
208+ for _, name := range []string{"python3", "python"} {
209+ if err := ctx.Err(); err != nil {
210+ return resolvedPython{}, err
211+ }
212+ path, err := deps.lookPath(name)
213+ if err != nil || strings.TrimSpace(path) == "" {
214+ continue
215+ }
216+ // A relative PATH entry can yield a relative interpreter path.
217+ // The probe runs in the process directory while execution runs
218+ // in the session CWD, so anchor it: probe, classification, and
219+ // execution must all address the same validated executable.
220+ if abs, absErr := filepath.Abs(path); absErr == nil {
221+ path = abs
222+ }
223+ if goos == "windows" {
224+ if err := classifyWindowsCandidate(path, &deps); err != nil {
225+ continue
226+ }
227+ }
228+ ver, err := deps.probe(ctx, path)
229+ if err != nil {
230+ if ctx.Err() != nil {
231+ return resolvedPython{}, ctx.Err()
232+ }
233+ continue
234+ }
235+ return resolvedPython{Path: path, Version: ver}, nil
236+ }
237+
238+ return resolvedPython{}, missingPythonError()
239+}
240+
241+// resolvePythonFailClosed validates one explicit environment source. Any
242+// failure is returned; the caller must not fall through to another source.
243+func resolvePythonFailClosed(ctx context.Context, deps *pythonResolveDeps, goos, source, path string) (resolvedPython, error) {
244+ if err := ctx.Err(); err != nil {
245+ return resolvedPython{}, err
246+ }
247+ if !deps.isFile(path) {
248+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: executable not found; point python_interpreter at an existing Python 3 interpreter (zut never installs Python or packages)", source, path)
249+ }
250+ if goos == "windows" {
251+ if err := classifyWindowsCandidate(path, deps); err != nil {
252+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: %w; configure python_interpreter to an existing installed Python 3 interpreter", source, path, err)
253+ }
254+ }
255+ ver, err := deps.probe(ctx, path)
256+ if err != nil {
257+ if ctx.Err() != nil {
258+ return resolvedPython{}, ctx.Err()
259+ }
260+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: %v; configure python_interpreter to an existing Python 3 interpreter (zut never installs Python or packages)", source, path, err)
261+ }
262+ return resolvedPython{Path: path, Version: ver}, nil
263+}
264+
265+func missingPythonError() error {
266+ return fmt.Errorf("no Python 3 interpreter found: an existing Python 3 environment is required; configure python_interpreter in config.json, activate a VIRTUAL_ENV, create ./.venv, or add python3 to PATH (zut never installs Python or packages)")
267+}
268+
269+func dirExists(path string) bool {
270+ info, err := os.Stat(path)
271+ return err == nil && info.IsDir()
272+}
273+
274+// classifyWindowsCandidate enforces the Windows safety policy for EVERY
275+// source, including configured paths and virtual environments. It runs
276+// before any probe or execution. Unknown wrappers fail closed.
277+func classifyWindowsCandidate(exe string, deps *pythonResolveDeps) error {
278+ target := exe
279+ if resolved, err := deps.evalSym(exe); err == nil && strings.TrimSpace(resolved) != "" {
280+ target = resolved
281+ }
282+ lowerTarget := strings.ToLower(strings.ReplaceAll(target, "\\", "/"))
283+ if strings.Contains(lowerTarget, "/windowsapps/") || strings.Contains(lowerTarget, "windowsapps") && strings.Contains(lowerTarget, "microsoft") {
284+ return fmt.Errorf("windows Store alias is not supported")
285+ }
286+ // filepath.Base is separator-sensitive to the host OS, so extract the
287+ // basename manually: test fixtures use Windows paths on Unix hosts.
288+ base := lowerTarget
289+ if i := strings.LastIndex(base, "/"); i >= 0 {
290+ base = base[i+1:]
291+ }
292+ switch base {
293+ case "py.exe", "pyw.exe", "pylauncher.exe":
294+ return fmt.Errorf("python launcher %q is not supported; execute an installed interpreter directly", base)
295+ case "pymanager.exe", "python-manager.exe", "python-install-manager.exe", "install-manager.exe", "installmanager.exe":
296+ return fmt.Errorf("python install manager %q is not supported (named entrypoint)", base)
297+ }
298+ if strings.Contains(base, "manager") && (strings.Contains(base, "python") || strings.Contains(base, "py") || strings.Contains(base, "install")) {
299+ return fmt.Errorf("python install manager %q is not supported (heuristic)", base)
300+ }
301+ if !deps.layoutOK(exe) && !deps.layoutOK(target) {
302+ return fmt.Errorf("unverifiable interpreter wrapper %q is not supported", exe)
303+ }
304+ return nil
305+}
306+
307+// hasWindowsRuntimeLayout positively identifies an installed CPython
308+// executable or a virtual-environment interpreter. Anything else fails
309+// closed. Never trust the basename alone.
310+func hasWindowsRuntimeLayout(exe string, isFile func(string) bool) bool {
311+ dir := filepath.Dir(exe)
312+ parent := filepath.Dir(dir)
313+ // Virtual environments carry pyvenv.cfg at the environment root.
314+ if isFile(filepath.Join(dir, "pyvenv.cfg")) || isFile(filepath.Join(parent, "pyvenv.cfg")) {
315+ return true
316+ }
317+ entries, err := os.ReadDir(dir)
318+ if err == nil {
319+ for _, e := range entries {
320+ name := strings.ToLower(e.Name())
321+ if strings.HasPrefix(name, "python") && strings.HasSuffix(name, ".dll") {
322+ return true
323+ }
324+ }
325+ }
326+ // Installed layout also ships the standard library next to the exe.
327+ if isFile(filepath.Join(dir, "Lib", "os.py")) || isFile(filepath.Join(parent, "Lib", "os.py")) {
328+ return true
329+ }
330+ return false
331+}
332+
333+// probePythonVersion runs the isolated version probe, bounded by at most
334+// five seconds and the remaining invocation deadline.
335+func probePythonVersion(ctx context.Context, exe string) (pythonVersion, error) {
336+ timeout := maxPythonProbeSeconds * time.Second
337+ if deadline, ok := ctx.Deadline(); ok {
338+ remaining := time.Until(deadline)
339+ if remaining <= 0 {
340+ return pythonVersion{}, ctx.Err()
341+ }
342+ if remaining < timeout {
343+ timeout = remaining
344+ }
345+ }
346+ probeCtx, cancel := context.WithTimeout(ctx, timeout)
347+ defer cancel()
348+
349+ cmd := exec.CommandContext(probeCtx, exe, "-I", "-S", "-B", "-c", pythonProbeCode)
350+ cmd.Env = pythonChildEnv()
351+ cmd.Dir = ""
352+ var stdout, stderr bytes.Buffer
353+ cmd.Stdout = &limitedWriter{W: &stdout, N: maxPythonProbeOutput}
354+ cmd.Stderr = &limitedWriter{W: &stderr, N: maxPythonProbeOutput}
355+ // Probes only surface terminal failures; a failed process-group kill
356+ // is folded into the probe error by Wait, so no cancel capture here.
357+ closeOutput := configurePythonProcess(cmd, nil, nil)
358+ defer closeOutput()
359+ if err := cmd.Start(); err != nil {
360+ return pythonVersion{}, fmt.Errorf("probe start: %w", err)
361+ }
362+ waitErr := cmd.Wait()
363+ if probeCtx.Err() != nil && ctx.Err() == nil {
364+ return pythonVersion{}, fmt.Errorf("probe timed out after %d seconds", int(timeout/time.Second))
365+ }
366+ if waitErr != nil {
367+ out := strings.TrimSpace(stdout.String() + stderr.String())
368+ if out != "" {
369+ if len(out) > 512 {
370+ out = out[:512]
371+ }
372+ return pythonVersion{}, fmt.Errorf("probe failed: %v: %s", waitErr, out)
373+ }
374+ return pythonVersion{}, fmt.Errorf("probe failed: %w", waitErr)
375+ }
376+ var raw struct {
377+ Major int `json:"major"`
378+ Minor int `json:"minor"`
379+ Micro int `json:"micro"`
380+ Executable string `json:"executable"`
381+ }
382+ dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
383+ if err := dec.Decode(&raw); err != nil {
384+ return pythonVersion{}, fmt.Errorf("probe returned invalid output")
385+ }
386+ if raw.Major != 3 {
387+ return pythonVersion{}, fmt.Errorf("probe returned Python %d (need Python 3)", raw.Major)
388+ }
389+ return pythonVersion{Major: raw.Major, Minor: raw.Minor, Micro: raw.Micro, Executable: raw.Executable}, nil
390+}
391+
392+// pythonChildEnv inherits the host environment consistently with Bash,
393+// except for Windows launcher-manager opt-ins, which are stripped as
394+// defense in depth. Setting them to "0" is insufficient, so they are
395+// removed entirely. PYTHON_MANAGER_AUTOMATIC_INSTALL is never trusted.
396+func pythonChildEnv() []string {
397+ env := os.Environ()
398+ if runtime.GOOS != "windows" {
399+ return env
400+ }
401+ out := env[:0]
402+ for _, kv := range env {
403+ key := kv
404+ if i := strings.IndexByte(kv, '='); i >= 0 {
405+ key = kv[:i]
406+ }
407+ // Windows environment keys are case-insensitive: normalize before
408+ // comparing so lowercase launcher opt-ins cannot survive.
409+ switch strings.ToUpper(key) {
410+ case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
411+ continue
412+ }
413+ out = append(out, kv)
414+ }
415+ return out
416+}
417+
418+type limitedWriter struct {
419+ W *bytes.Buffer
420+ N int
421+}
422+
423+func (l *limitedWriter) Write(p []byte) (int, error) {
424+ if l.W.Len() >= l.N {
425+ return len(p), nil
426+ }
427+ room := l.N - l.W.Len()
428+ if len(p) > room {
429+ l.W.Write(p[:room])
430+ return len(p), nil
431+ }
432+ l.W.Write(p)
433+ return len(p), nil
434+}
435+
436+var _ = io.Discard
@@ -0,0 +1,436 @@
1+// Package tools Python runtime resolution.
2+//
3+// Resolution is lazy (per invocation, after permission checks) and shares
4+// the caller's timeout budget. Priority:
5+//
6+// 1. Configured python_interpreter (one executable path, no PATH lookup).
7+// 2. Active VIRTUAL_ENV.
8+// 3. Session-root .venv.
9+// 4. PATH candidates python3, then python.
10+//
11+// A configured or active environment that fails validation is an error,
12+// never a silent fallthrough. A present but invalid .venv is reported.
13+// A missing .venv permits system discovery. PATH candidates may fall
14+// through; cancellation stops discovery immediately.
15+package tools
16+
17+import (
18+ "bytes"
19+ "context"
20+ "encoding/json"
21+ "fmt"
22+ "io"
23+ "os"
24+ "os/exec"
25+ "path/filepath"
26+ "runtime"
27+ "strings"
28+ "time"
29+)
30+
31+// maxPythonProbeSeconds bounds each version probe. The probe also observes
32+// the remaining invocation deadline, whichever is shorter.
33+const maxPythonProbeSeconds = 5
34+
35+// maxPythonProbeOutput bounds captured probe stdout/stderr.
36+const maxPythonProbeOutput = 64 * 1024
37+
38+// pythonProbeCode prints structured version info using only the standard
39+// library. It runs under -I -S so user configuration cannot alter it.
40+const pythonProbeCode = `import json,sys;print(json.dumps({"major":sys.version_info[0],"minor":sys.version_info[1],"micro":sys.version_info[2],"executable":sys.executable}))`
41+
42+// pythonProbeArgv builds the isolated probe argv for exe. -B suppresses
43+// bytecode-cache writes during probing.
44+func pythonProbeArgv(exe string) []string {
45+ return []string{exe, "-I", "-S", "-B", "-c", pythonProbeCode}
46+}
47+
48+// pythonVersion is a validated Python 3 interpreter identity.
49+type pythonVersion struct {
50+ Major int
51+ Minor int
52+ Micro int
53+ Executable string
54+}
55+
56+func (v pythonVersion) String() string {
57+ return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Micro)
58+}
59+
60+// resolvedPython is a validated interpreter ready for execution.
61+type resolvedPython struct {
62+ Path string
63+ Version pythonVersion
64+}
65+
66+// pythonResolveDeps are the injectable seams for resolution. Production
67+// code fills them from the host; tests substitute fixtures. No
68+// package-global mutable overrides.
69+type pythonResolveDeps struct {
70+ GOOS string
71+ CWD string
72+ Configured string
73+ Getenv func(string) string
74+ LookPath func(string) (string, error)
75+ IsFile func(string) bool
76+ EvalSym func(string) (string, error)
77+ Probe func(ctx context.Context, exe string) (pythonVersion, error)
78+ // LayoutOK classifies a Windows candidate as a positively identified
79+ // installed interpreter (or venv). Nil uses the default filesystem
80+ // check. Ignored on non-Windows platforms.
81+ LayoutOK func(exe string) bool
82+}
83+
84+func (d *pythonResolveDeps) goos() string {
85+ if d.GOOS != "" {
86+ return d.GOOS
87+ }
88+ return runtime.GOOS
89+}
90+
91+func (d *pythonResolveDeps) getenv(k string) string {
92+ if d.Getenv != nil {
93+ return d.Getenv(k)
94+ }
95+ return os.Getenv(k)
96+}
97+
98+func (d *pythonResolveDeps) lookPath(name string) (string, error) {
99+ if d.LookPath != nil {
100+ return d.LookPath(name)
101+ }
102+ return exec.LookPath(name)
103+}
104+
105+func (d *pythonResolveDeps) isFile(path string) bool {
106+ if d.IsFile != nil {
107+ return d.IsFile(path)
108+ }
109+ info, err := os.Stat(path)
110+ return err == nil && !info.IsDir()
111+}
112+
113+func (d *pythonResolveDeps) evalSym(path string) (string, error) {
114+ if d.EvalSym != nil {
115+ return d.EvalSym(path)
116+ }
117+ return filepath.EvalSymlinks(path)
118+}
119+
120+func (d *pythonResolveDeps) probe(ctx context.Context, exe string) (pythonVersion, error) {
121+ if d.Probe != nil {
122+ return d.Probe(ctx, exe)
123+ }
124+ return probePythonVersion(ctx, exe)
125+}
126+
127+func (d *pythonResolveDeps) layoutOK(exe string) bool {
128+ if d.LayoutOK != nil {
129+ return d.LayoutOK(exe)
130+ }
131+ return hasWindowsRuntimeLayout(exe, d.isFile)
132+}
133+
134+// resolveConfiguredPythonPath maps a configured python_interpreter value to
135+// a filesystem path. Absolute paths are used directly; relative paths
136+// resolve against the session CWD, which is anchored to absolute first so
137+// a relative --cwd cannot stack (project/project/...). No PATH lookup, no
138+// shell expansion.
139+func resolveConfiguredPythonPath(cwd, configured string) string {
140+ if filepath.IsAbs(configured) {
141+ return filepath.Clean(configured)
142+ }
143+ cwd = absPythonCWD(cwd)
144+ return filepath.Join(cwd, filepath.FromSlash(configured))
145+}
146+
147+// absPythonCWD anchors the session CWD to absolute once. Resolution and
148+// execution both chdir into the session CWD, so every constructed
149+// candidate must survive that chdir.
150+func absPythonCWD(cwd string) string {
151+ if cwd == "" {
152+ cwd, _ = os.Getwd()
153+ }
154+ if abs, err := filepath.Abs(cwd); err == nil {
155+ return abs
156+ }
157+ return cwd
158+}
159+
160+// pythonVenvCandidate builds the interpreter path inside an environment
161+// root. Relative roots resolve against the session CWD.
162+func pythonVenvCandidate(cwd, root, goos string) string {
163+ if !filepath.IsAbs(root) {
164+ root = filepath.Join(absPythonCWD(cwd), filepath.FromSlash(root))
165+ }
166+ if goos == "windows" {
167+ return filepath.Join(root, "Scripts", "python.exe")
168+ }
169+ return filepath.Join(root, "bin", "python")
170+}
171+
172+// resolvePythonInterpreter implements the specified priority order.
173+func resolvePythonInterpreter(ctx context.Context, deps pythonResolveDeps) (resolvedPython, error) {
174+ goos := deps.goos()
175+ cwd := absPythonCWD(deps.CWD)
176+
177+ if err := ctx.Err(); err != nil {
178+ return resolvedPython{}, err
179+ }
180+
181+ // 1. Configured interpreter: fail closed, never fall through.
182+ if strings.TrimSpace(deps.Configured) != "" {
183+ path := resolveConfiguredPythonPath(cwd, strings.TrimSpace(deps.Configured))
184+ return resolvePythonFailClosed(ctx, &deps, goos, "configured python_interpreter", path)
185+ }
186+
187+ // 2. Active VIRTUAL_ENV: fail closed, never fall through.
188+ if venv := strings.TrimSpace(deps.getenv("VIRTUAL_ENV")); venv != "" {
189+ path := pythonVenvCandidate(cwd, venv, goos)
190+ return resolvePythonFailClosed(ctx, &deps, goos, "active VIRTUAL_ENV", path)
191+ }
192+
193+ // 3. Session-root .venv: missing permits system discovery, but a
194+ // present yet invalid environment is reported, not bypassed.
195+ if venvCandidate := pythonVenvCandidate(cwd, filepath.Join(cwd, ".venv"), goos); deps.isFile(venvCandidate) {
196+ return resolvePythonFailClosed(ctx, &deps, goos, "local .venv", venvCandidate)
197+ } else {
198+ // Also consider a .venv directory whose interpreter has a
199+ // version-suffixed name? No: only the canonical layout is valid.
200+ // A .venv dir without the canonical interpreter counts as
201+ // present-but-invalid.
202+ if dirExists(filepath.Join(cwd, ".venv")) {
203+ return resolvedPython{}, fmt.Errorf("local .venv at %q has no usable Python 3 interpreter at %q; create the environment or configure python_interpreter to an existing interpreter (zut never installs Python or packages)", filepath.Join(cwd, ".venv"), venvCandidate)
204+ }
205+ }
206+
207+ // 4. PATH candidates: absent or non-Python-3 entries fall through.
208+ for _, name := range []string{"python3", "python"} {
209+ if err := ctx.Err(); err != nil {
210+ return resolvedPython{}, err
211+ }
212+ path, err := deps.lookPath(name)
213+ if err != nil || strings.TrimSpace(path) == "" {
214+ continue
215+ }
216+ // A relative PATH entry can yield a relative interpreter path.
217+ // The probe runs in the process directory while execution runs
218+ // in the session CWD, so anchor it: probe, classification, and
219+ // execution must all address the same validated executable.
220+ if abs, absErr := filepath.Abs(path); absErr == nil {
221+ path = abs
222+ }
223+ if goos == "windows" {
224+ if err := classifyWindowsCandidate(path, &deps); err != nil {
225+ continue
226+ }
227+ }
228+ ver, err := deps.probe(ctx, path)
229+ if err != nil {
230+ if ctx.Err() != nil {
231+ return resolvedPython{}, ctx.Err()
232+ }
233+ continue
234+ }
235+ return resolvedPython{Path: path, Version: ver}, nil
236+ }
237+
238+ return resolvedPython{}, missingPythonError()
239+}
240+
241+// resolvePythonFailClosed validates one explicit environment source. Any
242+// failure is returned; the caller must not fall through to another source.
243+func resolvePythonFailClosed(ctx context.Context, deps *pythonResolveDeps, goos, source, path string) (resolvedPython, error) {
244+ if err := ctx.Err(); err != nil {
245+ return resolvedPython{}, err
246+ }
247+ if !deps.isFile(path) {
248+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: executable not found; point python_interpreter at an existing Python 3 interpreter (zut never installs Python or packages)", source, path)
249+ }
250+ if goos == "windows" {
251+ if err := classifyWindowsCandidate(path, deps); err != nil {
252+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: %w; configure python_interpreter to an existing installed Python 3 interpreter", source, path, err)
253+ }
254+ }
255+ ver, err := deps.probe(ctx, path)
256+ if err != nil {
257+ if ctx.Err() != nil {
258+ return resolvedPython{}, ctx.Err()
259+ }
260+ return resolvedPython{}, fmt.Errorf("%s %q is not usable: %v; configure python_interpreter to an existing Python 3 interpreter (zut never installs Python or packages)", source, path, err)
261+ }
262+ return resolvedPython{Path: path, Version: ver}, nil
263+}
264+
265+func missingPythonError() error {
266+ return fmt.Errorf("no Python 3 interpreter found: an existing Python 3 environment is required; configure python_interpreter in config.json, activate a VIRTUAL_ENV, create ./.venv, or add python3 to PATH (zut never installs Python or packages)")
267+}
268+
269+func dirExists(path string) bool {
270+ info, err := os.Stat(path)
271+ return err == nil && info.IsDir()
272+}
273+
274+// classifyWindowsCandidate enforces the Windows safety policy for EVERY
275+// source, including configured paths and virtual environments. It runs
276+// before any probe or execution. Unknown wrappers fail closed.
277+func classifyWindowsCandidate(exe string, deps *pythonResolveDeps) error {
278+ target := exe
279+ if resolved, err := deps.evalSym(exe); err == nil && strings.TrimSpace(resolved) != "" {
280+ target = resolved
281+ }
282+ lowerTarget := strings.ToLower(strings.ReplaceAll(target, "\\", "/"))
283+ if strings.Contains(lowerTarget, "/windowsapps/") || strings.Contains(lowerTarget, "windowsapps") && strings.Contains(lowerTarget, "microsoft") {
284+ return fmt.Errorf("windows Store alias is not supported")
285+ }
286+ // filepath.Base is separator-sensitive to the host OS, so extract the
287+ // basename manually: test fixtures use Windows paths on Unix hosts.
288+ base := lowerTarget
289+ if i := strings.LastIndex(base, "/"); i >= 0 {
290+ base = base[i+1:]
291+ }
292+ switch base {
293+ case "py.exe", "pyw.exe", "pylauncher.exe":
294+ return fmt.Errorf("python launcher %q is not supported; execute an installed interpreter directly", base)
295+ case "pymanager.exe", "python-manager.exe", "python-install-manager.exe", "install-manager.exe", "installmanager.exe":
296+ return fmt.Errorf("python install manager %q is not supported (named entrypoint)", base)
297+ }
298+ if strings.Contains(base, "manager") && (strings.Contains(base, "python") || strings.Contains(base, "py") || strings.Contains(base, "install")) {
299+ return fmt.Errorf("python install manager %q is not supported (heuristic)", base)
300+ }
301+ if !deps.layoutOK(exe) && !deps.layoutOK(target) {
302+ return fmt.Errorf("unverifiable interpreter wrapper %q is not supported", exe)
303+ }
304+ return nil
305+}
306+
307+// hasWindowsRuntimeLayout positively identifies an installed CPython
308+// executable or a virtual-environment interpreter. Anything else fails
309+// closed. Never trust the basename alone.
310+func hasWindowsRuntimeLayout(exe string, isFile func(string) bool) bool {
311+ dir := filepath.Dir(exe)
312+ parent := filepath.Dir(dir)
313+ // Virtual environments carry pyvenv.cfg at the environment root.
314+ if isFile(filepath.Join(dir, "pyvenv.cfg")) || isFile(filepath.Join(parent, "pyvenv.cfg")) {
315+ return true
316+ }
317+ entries, err := os.ReadDir(dir)
318+ if err == nil {
319+ for _, e := range entries {
320+ name := strings.ToLower(e.Name())
321+ if strings.HasPrefix(name, "python") && strings.HasSuffix(name, ".dll") {
322+ return true
323+ }
324+ }
325+ }
326+ // Installed layout also ships the standard library next to the exe.
327+ if isFile(filepath.Join(dir, "Lib", "os.py")) || isFile(filepath.Join(parent, "Lib", "os.py")) {
328+ return true
329+ }
330+ return false
331+}
332+
333+// probePythonVersion runs the isolated version probe, bounded by at most
334+// five seconds and the remaining invocation deadline.
335+func probePythonVersion(ctx context.Context, exe string) (pythonVersion, error) {
336+ timeout := maxPythonProbeSeconds * time.Second
337+ if deadline, ok := ctx.Deadline(); ok {
338+ remaining := time.Until(deadline)
339+ if remaining <= 0 {
340+ return pythonVersion{}, ctx.Err()
341+ }
342+ if remaining < timeout {
343+ timeout = remaining
344+ }
345+ }
346+ probeCtx, cancel := context.WithTimeout(ctx, timeout)
347+ defer cancel()
348+
349+ cmd := exec.CommandContext(probeCtx, exe, "-I", "-S", "-B", "-c", pythonProbeCode)
350+ cmd.Env = pythonChildEnv()
351+ cmd.Dir = ""
352+ var stdout, stderr bytes.Buffer
353+ cmd.Stdout = &limitedWriter{W: &stdout, N: maxPythonProbeOutput}
354+ cmd.Stderr = &limitedWriter{W: &stderr, N: maxPythonProbeOutput}
355+ // Probes only surface terminal failures; a failed process-group kill
356+ // is folded into the probe error by Wait, so no cancel capture here.
357+ closeOutput := configurePythonProcess(cmd, nil, nil)
358+ defer closeOutput()
359+ if err := cmd.Start(); err != nil {
360+ return pythonVersion{}, fmt.Errorf("probe start: %w", err)
361+ }
362+ waitErr := cmd.Wait()
363+ if probeCtx.Err() != nil && ctx.Err() == nil {
364+ return pythonVersion{}, fmt.Errorf("probe timed out after %d seconds", int(timeout/time.Second))
365+ }
366+ if waitErr != nil {
367+ out := strings.TrimSpace(stdout.String() + stderr.String())
368+ if out != "" {
369+ if len(out) > 512 {
370+ out = out[:512]
371+ }
372+ return pythonVersion{}, fmt.Errorf("probe failed: %v: %s", waitErr, out)
373+ }
374+ return pythonVersion{}, fmt.Errorf("probe failed: %w", waitErr)
375+ }
376+ var raw struct {
377+ Major int `json:"major"`
378+ Minor int `json:"minor"`
379+ Micro int `json:"micro"`
380+ Executable string `json:"executable"`
381+ }
382+ dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
383+ if err := dec.Decode(&raw); err != nil {
384+ return pythonVersion{}, fmt.Errorf("probe returned invalid output")
385+ }
386+ if raw.Major != 3 {
387+ return pythonVersion{}, fmt.Errorf("probe returned Python %d (need Python 3)", raw.Major)
388+ }
389+ return pythonVersion{Major: raw.Major, Minor: raw.Minor, Micro: raw.Micro, Executable: raw.Executable}, nil
390+}
391+
392+// pythonChildEnv inherits the host environment consistently with Bash,
393+// except for Windows launcher-manager opt-ins, which are stripped as
394+// defense in depth. Setting them to "0" is insufficient, so they are
395+// removed entirely. PYTHON_MANAGER_AUTOMATIC_INSTALL is never trusted.
396+func pythonChildEnv() []string {
397+ env := os.Environ()
398+ if runtime.GOOS != "windows" {
399+ return env
400+ }
401+ out := env[:0]
402+ for _, kv := range env {
403+ key := kv
404+ if i := strings.IndexByte(kv, '='); i >= 0 {
405+ key = kv[:i]
406+ }
407+ // Windows environment keys are case-insensitive: normalize before
408+ // comparing so lowercase launcher opt-ins cannot survive.
409+ switch strings.ToUpper(key) {
410+ case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
411+ continue
412+ }
413+ out = append(out, kv)
414+ }
415+ return out
416+}
417+
418+type limitedWriter struct {
419+ W *bytes.Buffer
420+ N int
421+}
422+
423+func (l *limitedWriter) Write(p []byte) (int, error) {
424+ if l.W.Len() >= l.N {
425+ return len(p), nil
426+ }
427+ room := l.N - l.W.Len()
428+ if len(p) > room {
429+ l.W.Write(p[:room])
430+ return len(p), nil
431+ }
432+ l.W.Write(p)
433+ return len(p), nil
434+}
435+
436+var _ = io.Discard
A packages/agent/tools/python_runtime_test.go +476 -0
@@ -0,0 +1,476 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+// pythonTestAbs builds a platform-native absolute fixture path: on
15+// Windows it is anchored to the TempDir volume so filepath.IsAbs holds
16+// and Abs/Clean/Join behave like production paths.
17+func pythonTestAbs(t *testing.T, elems ...string) string {
18+ t.Helper()
19+ base := t.TempDir()
20+ if len(elems) == 0 {
21+ return base
22+ }
23+ return filepath.Join(base, filepath.Join(elems...))
24+}
25+
26+// volName returns the volume anchor of the test TempDir ("" on Unix,
27+// e.g. "C:" on Windows) for fixtures that build rooted relatives.
28+func volName(t *testing.T) string {
29+ t.Helper()
30+ return filepath.VolumeName(t.TempDir()) + string(os.PathSeparator)
31+}
32+
33+func testPythonDeps() pythonResolveDeps {
34+ return pythonResolveDeps{
35+ GOOS: "linux",
36+ CWD: "/session",
37+ Getenv: func(string) string {
38+ return ""
39+ },
40+ LookPath: func(string) (string, error) {
41+ return "", errors.New("not found")
42+ },
43+ IsFile: func(string) bool { return false },
44+ EvalSym: func(s string) (string, error) {
45+ return s, nil
46+ },
47+ LayoutOK: func(string) bool { return true },
48+ }
49+}
50+
51+func TestPythonProbeArgvIsolated(t *testing.T) {
52+ argv := pythonProbeArgv("/usr/bin/python3")
53+ if len(argv) != 6 {
54+ t.Fatalf("argv = %q, want 6 elements", argv)
55+ }
56+ if argv[0] != "/usr/bin/python3" {
57+ t.Fatalf("argv[0] = %q", argv[0])
58+ }
59+ joined := strings.Join(argv[1:], " ")
60+ for _, want := range []string{"-I", "-S", "-B", "-c"} {
61+ if !strings.Contains(joined, want) {
62+ t.Fatalf("argv missing %q: %q", want, joined)
63+ }
64+ }
65+ // Bytecode suppression must be explicit. The probe code itself contains
66+ // a ";" but stays a single argv element: execution is direct, never a
67+ // concatenated shell command.
68+ foundB := false
69+ for i, a := range argv {
70+ if a == "-B" {
71+ foundB = true
72+ }
73+ if i < len(argv)-1 && (strings.Contains(a, "&&") || a == "|" || a == ";") {
74+ t.Fatalf("probe argv must not contain shell metacharacters: %q", argv)
75+ }
76+ }
77+ if !foundB {
78+ t.Fatalf("probe argv missing -B: %q", argv)
79+ }
80+ if !strings.Contains(argv[5], "sys.version_info") || !strings.Contains(argv[5], "json.dumps") {
81+ t.Fatalf("probe code must emit structured JSON: %q", argv[5])
82+ }
83+}
84+
85+func TestResolveConfiguredPathHandling(t *testing.T) {
86+ // Fixture roots below use the platform-native absolute form so the
87+ // expectations hold on both Unix and Windows runners.
88+ session := pythonTestAbs(t, "session")
89+ absConfigured := pythonTestAbs(t, "opt", "py", "bin", "python")
90+ if got := resolveConfiguredPythonPath(session, absConfigured); got != absConfigured {
91+ t.Fatalf("absolute = %q", got)
92+ }
93+ if got := resolveConfiguredPythonPath(session, filepath.Join("envs", "py", "bin", "python")); got != filepath.Join(session, "envs", "py", "bin", "python") {
94+ t.Fatalf("relative = %q", got)
95+ }
96+ // No shell expansion: ~ and $VAR stay literal path segments.
97+ if got := resolveConfiguredPythonPath(session, filepath.Join("~", "bin", "python")); strings.Contains(got, string(os.PathSeparator)+"root") || !strings.Contains(got, "~") {
98+ t.Fatalf("must not expand ~: %q", got)
99+ }
100+ if got := resolveConfiguredPythonPath(session, "$HOME"+string(os.PathSeparator)+filepath.Join("bin", "python")); !strings.Contains(got, "$HOME") {
101+ t.Fatalf("must not expand env vars: %q", got)
102+ }
103+ // Paths with spaces survive.
104+ spacedSession := pythonTestAbs(t, "my dir")
105+ if got := resolveConfiguredPythonPath(spacedSession, filepath.Join("env dir", "py")); got != filepath.Join(spacedSession, "env dir", "py") {
106+ t.Fatalf("spaces = %q", got)
107+ }
108+ // A relative session CWD is anchored to absolute once, so relative
109+ // configured paths join the intended directory instead of stacking.
110+ if got := resolveConfiguredPythonPath("project", filepath.Join("envs", "py", "bin", "python")); !filepath.IsAbs(got) || !strings.HasSuffix(got, filepath.Join("project", "envs", "py", "bin", "python")) {
111+ t.Fatalf("relative cwd must absolutize: %q", got)
112+ }
113+}
114+
115+func TestResolveConfiguredMissingNoFallback(t *testing.T) {
116+ deps := testPythonDeps()
117+ deps.Configured = "/opt/missing/python"
118+ lookedUp := false
119+ deps.LookPath = func(string) (string, error) {
120+ lookedUp = true
121+ return "/usr/bin/python3", nil
122+ }
123+ probed := false
124+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
125+ probed = true
126+ return pythonVersion{Major: 3}, nil
127+ }
128+ _, err := resolvePythonInterpreter(context.Background(), deps)
129+ if err == nil || !strings.Contains(err.Error(), "configured python_interpreter") {
130+ t.Fatalf("err = %v, want configured failure", err)
131+ }
132+ if lookedUp || probed {
133+ t.Fatalf("missing configured interpreter must not fall through (lookup=%v probe=%v)", lookedUp, probed)
134+ }
135+}
136+
137+func TestResolveConfiguredPython2NoFallback(t *testing.T) {
138+ deps := testPythonDeps()
139+ configured := pythonTestAbs(t, "opt", "python2", "bin", "python")
140+ deps.Configured = configured
141+ deps.IsFile = func(s string) bool { return s == configured }
142+ probes := 0
143+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
144+ probes++
145+ if exe != configured {
146+ t.Fatalf("probed %q", exe)
147+ }
148+ return pythonVersion{}, errors.New("probe returned Python 2 (need Python 3)")
149+ }
150+ deps.LookPath = func(string) (string, error) {
151+ t.Fatal("must not consult PATH after configured failure")
152+ return "", errors.New("not found")
153+ }
154+ _, err := resolvePythonInterpreter(context.Background(), deps)
155+ if err == nil || !strings.Contains(err.Error(), "configured python_interpreter") {
156+ t.Fatalf("err = %v", err)
157+ }
158+ if probes != 1 {
159+ t.Fatalf("probes = %d, want 1", probes)
160+ }
161+}
162+
163+func TestResolveVirtualEnvCandidateConstruction(t *testing.T) {
164+ venvRoot := pythonTestAbs(t, "opt", "venv")
165+ if got := pythonVenvCandidate(pythonTestAbs(t, "s"), venvRoot, "linux"); got != filepath.Join(venvRoot, "bin", "python") {
166+ t.Fatalf("unix = %q", got)
167+ }
168+ if got := pythonVenvCandidate(pythonTestAbs(t, "s"), venvRoot, "windows"); got != filepath.Join(venvRoot, "Scripts", "python.exe") {
169+ t.Fatalf("windows = %q", got)
170+ }
171+ if got := pythonVenvCandidate(filepath.Join(volName(t), "s cwd"), "rel env", "linux"); got != filepath.Join(volName(t), "s cwd", "rel env", "bin", "python") {
172+ t.Fatalf("relative root with spaces = %q", got)
173+ }
174+}
175+
176+func TestResolveVirtualEnvTakesPriorityNoFallback(t *testing.T) {
177+ deps := testPythonDeps()
178+ deps.Getenv = func(k string) string {
179+ if k == "VIRTUAL_ENV" {
180+ return "/opt/venv"
181+ }
182+ return ""
183+ }
184+ deps.IsFile = func(s string) bool { return false }
185+ _, err := resolvePythonInterpreter(context.Background(), deps)
186+ if err == nil || !strings.Contains(err.Error(), "active VIRTUAL_ENV") {
187+ t.Fatalf("err = %v, want VIRTUAL_ENV failure without fallback", err)
188+ }
189+}
190+
191+func TestResolveLocalVenvInvalidReported(t *testing.T) {
192+ dir := t.TempDir()
193+ if err := os.MkdirAll(filepath.Join(dir, ".venv"), 0o755); err != nil {
194+ t.Fatal(err)
195+ }
196+ deps := testPythonDeps()
197+ deps.CWD = dir
198+ deps.LookPath = func(string) (string, error) {
199+ t.Fatal("present .venv must not fall through to PATH")
200+ return "", errors.New("not found")
201+ }
202+ _, err := resolvePythonInterpreter(context.Background(), deps)
203+ if err == nil || !strings.Contains(err.Error(), ".venv") {
204+ t.Fatalf("err = %v, want .venv failure", err)
205+ }
206+}
207+
208+func TestResolvePathFallbackPython2ThenPython3(t *testing.T) {
209+ deps := testPythonDeps()
210+ // Absolute LookPath fixtures: a rooted Unix-style path is not
211+ // absolute on Windows, where Abs would prepend the drive's CWD.
212+ dir := t.TempDir()
213+ py3 := filepath.Join(dir, "python3")
214+ py := filepath.Join(dir, "python")
215+ deps.LookPath = func(name string) (string, error) {
216+ if name == "python3" {
217+ return py3, nil
218+ }
219+ if name == "python" {
220+ return py, nil
221+ }
222+ return "", errors.New("not found")
223+ }
224+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
225+ if exe == py3 {
226+ return pythonVersion{}, errors.New("probe returned Python 2 (need Python 3)")
227+ }
228+ return pythonVersion{Major: 3, Minor: 12, Micro: 1}, nil
229+ }
230+ got, err := resolvePythonInterpreter(context.Background(), deps)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if got.Path != py || got.Version.String() != "3.12.1" {
235+ t.Fatalf("got %+v", got)
236+ }
237+}
238+
239+func TestResolvePathMissingGivesGuidance(t *testing.T) {
240+ deps := testPythonDeps()
241+ _, err := resolvePythonInterpreter(context.Background(), deps)
242+ if err == nil {
243+ t.Fatal("want error")
244+ }
245+ for _, want := range []string{"existing Python 3", "python_interpreter", "never installs"} {
246+ if !strings.Contains(err.Error(), want) {
247+ t.Fatalf("error missing %q: %q", want, err)
248+ }
249+ }
250+}
251+
252+func TestResolveCancellationStopsDiscovery(t *testing.T) {
253+ deps := testPythonDeps()
254+ deps.LookPath = func(name string) (string, error) {
255+ return "/usr/bin/" + name, nil
256+ }
257+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
258+ t.Fatal("cancelled discovery must not probe")
259+ return pythonVersion{}, nil
260+ }
261+ ctx, cancel := context.WithCancel(context.Background())
262+ cancel()
263+ _, err := resolvePythonInterpreter(ctx, deps)
264+ if !errors.Is(err, context.Canceled) {
265+ t.Fatalf("err = %v, want context.Canceled", err)
266+ }
267+}
268+
269+func TestResolveProbeDeadlineRespected(t *testing.T) {
270+ deps := testPythonDeps()
271+ deps.Configured = "/opt/py/bin/python"
272+ deps.IsFile = func(string) bool { return true }
273+ deps.Probe = func(ctx context.Context, _ string) (pythonVersion, error) {
274+ <-ctx.Done()
275+ return pythonVersion{}, ctx.Err()
276+ }
277+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
278+ defer cancel()
279+ start := time.Now()
280+ _, err := resolvePythonInterpreter(ctx, deps)
281+ if err == nil {
282+ t.Fatal("want deadline error")
283+ }
284+ if time.Since(start) > 3*time.Second {
285+ t.Fatalf("probe did not respect deadline")
286+ }
287+}
288+
289+func TestLimitedWriterBoundsProbeOutput(t *testing.T) {
290+ var buf bytes.Buffer
291+ w := &limitedWriter{W: &buf, N: 8}
292+ n, err := w.Write([]byte("1234567890abcdef"))
293+ if err != nil || n != 16 {
294+ t.Fatalf("Write = %d, %v, want 16, nil", n, err)
295+ }
296+ if buf.Len() != 8 || buf.String() != "12345678" {
297+ t.Fatalf("bounded buffer = %q, want %q", buf.String(), "12345678")
298+ }
299+ // Further writes are dropped but report success.
300+ if n, _ := w.Write([]byte("more")); n != 4 || buf.Len() != 8 {
301+ t.Fatalf("overflow write = %d len %d", n, buf.Len())
302+ }
303+}
304+
305+func TestWindowsRejectsStoreAliasBothNames(t *testing.T) {
306+ for _, name := range []string{"python3", "python"} {
307+ deps := testPythonDeps()
308+ deps.GOOS = "windows"
309+ deps.CWD = `C:\proj`
310+ alias := `C:\Users\test\AppData\Local\Microsoft\WindowsApps\` + name + `.exe`
311+ deps.LookPath = func(n string) (string, error) {
312+ if n == name {
313+ return alias, nil
314+ }
315+ return "", errors.New("not found")
316+ }
317+ deps.EvalSym = func(s string) (string, error) { return s, nil }
318+ deps.LayoutOK = func(string) bool { return true }
319+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
320+ t.Fatalf("alias %q must be rejected before any probe", alias)
321+ return pythonVersion{}, nil
322+ }
323+ _, err := resolvePythonInterpreter(context.Background(), deps)
324+ if err == nil || !strings.Contains(err.Error(), "existing Python 3") {
325+ t.Fatalf("%s: err = %v, want missing-interpreter fallback", name, err)
326+ }
327+ }
328+}
329+
330+func TestWindowsRejectsLauncherAndManagerBeforeProbe(t *testing.T) {
331+ cases := map[string]string{
332+ "launcher": `C:\Windows\py.exe`,
333+ "manager": `C:\Program Files\Python\python-manager.exe`,
334+ "wrapper": `C:\tools\weird-python.exe`,
335+ }
336+ for kind, exe := range cases {
337+ deps := testPythonDeps()
338+ deps.GOOS = "windows"
339+ deps.Configured = exe
340+ deps.IsFile = func(string) bool { return true }
341+ deps.EvalSym = func(s string) (string, error) { return s, nil }
342+ deps.LayoutOK = func(p string) bool {
343+ // Only the wrapper case reaches layout verification with false.
344+ return kind == "launcher" || kind == "manager"
345+ }
346+ probed := false
347+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
348+ probed = true
349+ return pythonVersion{Major: 3}, nil
350+ }
351+ _, err := resolvePythonInterpreter(context.Background(), deps)
352+ if err == nil {
353+ t.Fatalf("%s %q accepted", kind, exe)
354+ }
355+ if probed && kind != "launcher" && kind != "manager" {
356+ // Wrapper must also be rejected before probing.
357+ t.Fatalf("%s %q probed an unverifiable wrapper", kind, exe)
358+ }
359+ if probed {
360+ t.Fatalf("%s %q must be rejected before any probe", kind, exe)
361+ }
362+ }
363+}
364+
365+func TestWindowsSymlinkTargetClassified(t *testing.T) {
366+ deps := testPythonDeps()
367+ deps.GOOS = "windows"
368+ deps.Configured = `C:\tools\python.exe`
369+ deps.IsFile = func(string) bool { return true }
370+ deps.EvalSym = func(string) (string, error) {
371+ return `C:\Users\test\AppData\Local\Microsoft\WindowsApps\python.exe`, nil
372+ }
373+ deps.LayoutOK = func(string) bool { return true }
374+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
375+ t.Fatal("symlink target alias must be rejected before probe")
376+ return pythonVersion{}, nil
377+ }
378+ _, err := resolvePythonInterpreter(context.Background(), deps)
379+ if err == nil || !strings.Contains(strings.ToLower(err.Error()), "store") {
380+ t.Fatalf("err = %v, want Store rejection via resolved target", err)
381+ }
382+}
383+
384+func TestWindowsNeverUsesPyLauncherFallback(t *testing.T) {
385+ deps := testPythonDeps()
386+ deps.GOOS = "windows"
387+ seen := []string{}
388+ deps.LookPath = func(name string) (string, error) {
389+ seen = append(seen, name)
390+ return "", errors.New("not found")
391+ }
392+ _, _ = resolvePythonInterpreter(context.Background(), deps)
393+ for _, name := range seen {
394+ if name == "py" || name == "pyw" {
395+ t.Fatalf("resolver must never consult the py launcher, saw %q", seen)
396+ }
397+ }
398+}
399+
400+func TestResolvePathResultAbsolutized(t *testing.T) {
401+ // A relative PATH entry can yield a relative interpreter path. The
402+ // probe runs in the process directory while execution runs in the
403+ // session CWD, so resolution must anchor it: probing, classification,
404+ // and execution must address the same executable.
405+ rel := filepath.Join("rel", "bin", "python3")
406+ wantAbs, err := filepath.Abs(rel)
407+ if err != nil {
408+ t.Fatal(err)
409+ }
410+ deps := testPythonDeps()
411+ deps.LookPath = func(name string) (string, error) {
412+ if name == "python3" {
413+ return rel, nil
414+ }
415+ return "", errors.New("not found")
416+ }
417+ deps.IsFile = func(s string) bool { return s == wantAbs }
418+ var probed string
419+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
420+ probed = exe
421+ return pythonVersion{Major: 3, Minor: 1, Micro: 0}, nil
422+ }
423+ got, err := resolvePythonInterpreter(context.Background(), deps)
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ if !filepath.IsAbs(got.Path) || got.Path != wantAbs {
428+ t.Fatalf("resolved path = %q, want absolutized %q", got.Path, wantAbs)
429+ }
430+ if probed != got.Path {
431+ t.Fatalf("probed %q but returned %q", probed, got.Path)
432+ }
433+}
434+
435+func TestPythonChildEnvKeepsHostOnUnix(t *testing.T) {
436+ t.Setenv("ZUT_PYTHON_TEST_SENTINEL", "kept")
437+ found := false
438+ for _, kv := range pythonChildEnv() {
439+ if kv == "ZUT_PYTHON_TEST_SENTINEL=kept" {
440+ found = true
441+ }
442+ }
443+ if !found {
444+ t.Fatal("host environment must be inherited")
445+ }
446+}
447+
448+func TestPythonChildEnvStripsLauncherOptInsCaseInsensitively(t *testing.T) {
449+ // Windows environment keys are case-insensitive; pythonChildEnv only
450+ // filters on Windows, so this test pins the matching contract via the
451+ // same normalization on synthetic input.
452+ input := []string{
453+ "PYLAUNCHER_ALLOW_INSTALL=1",
454+ "pylauncher_always_install=1",
455+ "PyLauncher_Allow_Install=1",
456+ "PYTHON_MANAGER_AUTOMATIC_INSTALL=1",
457+ "python_manager_automatic_install=0",
458+ "PATH=/usr/bin",
459+ "ZUT_PYTHON_TEST_SENTINEL=kept",
460+ }
461+ var kept []string
462+ for _, kv := range input {
463+ key := kv
464+ if i := strings.IndexByte(kv, '='); i >= 0 {
465+ key = kv[:i]
466+ }
467+ switch strings.ToUpper(key) {
468+ case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
469+ continue
470+ }
471+ kept = append(kept, kv)
472+ }
473+ if len(kept) != 2 || kept[0] != "PATH=/usr/bin" || kept[1] != "ZUT_PYTHON_TEST_SENTINEL=kept" {
474+ t.Fatalf("kept = %q, want only PATH and sentinel", kept)
475+ }
476+}
@@ -0,0 +1,476 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+// pythonTestAbs builds a platform-native absolute fixture path: on
15+// Windows it is anchored to the TempDir volume so filepath.IsAbs holds
16+// and Abs/Clean/Join behave like production paths.
17+func pythonTestAbs(t *testing.T, elems ...string) string {
18+ t.Helper()
19+ base := t.TempDir()
20+ if len(elems) == 0 {
21+ return base
22+ }
23+ return filepath.Join(base, filepath.Join(elems...))
24+}
25+
26+// volName returns the volume anchor of the test TempDir ("" on Unix,
27+// e.g. "C:" on Windows) for fixtures that build rooted relatives.
28+func volName(t *testing.T) string {
29+ t.Helper()
30+ return filepath.VolumeName(t.TempDir()) + string(os.PathSeparator)
31+}
32+
33+func testPythonDeps() pythonResolveDeps {
34+ return pythonResolveDeps{
35+ GOOS: "linux",
36+ CWD: "/session",
37+ Getenv: func(string) string {
38+ return ""
39+ },
40+ LookPath: func(string) (string, error) {
41+ return "", errors.New("not found")
42+ },
43+ IsFile: func(string) bool { return false },
44+ EvalSym: func(s string) (string, error) {
45+ return s, nil
46+ },
47+ LayoutOK: func(string) bool { return true },
48+ }
49+}
50+
51+func TestPythonProbeArgvIsolated(t *testing.T) {
52+ argv := pythonProbeArgv("/usr/bin/python3")
53+ if len(argv) != 6 {
54+ t.Fatalf("argv = %q, want 6 elements", argv)
55+ }
56+ if argv[0] != "/usr/bin/python3" {
57+ t.Fatalf("argv[0] = %q", argv[0])
58+ }
59+ joined := strings.Join(argv[1:], " ")
60+ for _, want := range []string{"-I", "-S", "-B", "-c"} {
61+ if !strings.Contains(joined, want) {
62+ t.Fatalf("argv missing %q: %q", want, joined)
63+ }
64+ }
65+ // Bytecode suppression must be explicit. The probe code itself contains
66+ // a ";" but stays a single argv element: execution is direct, never a
67+ // concatenated shell command.
68+ foundB := false
69+ for i, a := range argv {
70+ if a == "-B" {
71+ foundB = true
72+ }
73+ if i < len(argv)-1 && (strings.Contains(a, "&&") || a == "|" || a == ";") {
74+ t.Fatalf("probe argv must not contain shell metacharacters: %q", argv)
75+ }
76+ }
77+ if !foundB {
78+ t.Fatalf("probe argv missing -B: %q", argv)
79+ }
80+ if !strings.Contains(argv[5], "sys.version_info") || !strings.Contains(argv[5], "json.dumps") {
81+ t.Fatalf("probe code must emit structured JSON: %q", argv[5])
82+ }
83+}
84+
85+func TestResolveConfiguredPathHandling(t *testing.T) {
86+ // Fixture roots below use the platform-native absolute form so the
87+ // expectations hold on both Unix and Windows runners.
88+ session := pythonTestAbs(t, "session")
89+ absConfigured := pythonTestAbs(t, "opt", "py", "bin", "python")
90+ if got := resolveConfiguredPythonPath(session, absConfigured); got != absConfigured {
91+ t.Fatalf("absolute = %q", got)
92+ }
93+ if got := resolveConfiguredPythonPath(session, filepath.Join("envs", "py", "bin", "python")); got != filepath.Join(session, "envs", "py", "bin", "python") {
94+ t.Fatalf("relative = %q", got)
95+ }
96+ // No shell expansion: ~ and $VAR stay literal path segments.
97+ if got := resolveConfiguredPythonPath(session, filepath.Join("~", "bin", "python")); strings.Contains(got, string(os.PathSeparator)+"root") || !strings.Contains(got, "~") {
98+ t.Fatalf("must not expand ~: %q", got)
99+ }
100+ if got := resolveConfiguredPythonPath(session, "$HOME"+string(os.PathSeparator)+filepath.Join("bin", "python")); !strings.Contains(got, "$HOME") {
101+ t.Fatalf("must not expand env vars: %q", got)
102+ }
103+ // Paths with spaces survive.
104+ spacedSession := pythonTestAbs(t, "my dir")
105+ if got := resolveConfiguredPythonPath(spacedSession, filepath.Join("env dir", "py")); got != filepath.Join(spacedSession, "env dir", "py") {
106+ t.Fatalf("spaces = %q", got)
107+ }
108+ // A relative session CWD is anchored to absolute once, so relative
109+ // configured paths join the intended directory instead of stacking.
110+ if got := resolveConfiguredPythonPath("project", filepath.Join("envs", "py", "bin", "python")); !filepath.IsAbs(got) || !strings.HasSuffix(got, filepath.Join("project", "envs", "py", "bin", "python")) {
111+ t.Fatalf("relative cwd must absolutize: %q", got)
112+ }
113+}
114+
115+func TestResolveConfiguredMissingNoFallback(t *testing.T) {
116+ deps := testPythonDeps()
117+ deps.Configured = "/opt/missing/python"
118+ lookedUp := false
119+ deps.LookPath = func(string) (string, error) {
120+ lookedUp = true
121+ return "/usr/bin/python3", nil
122+ }
123+ probed := false
124+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
125+ probed = true
126+ return pythonVersion{Major: 3}, nil
127+ }
128+ _, err := resolvePythonInterpreter(context.Background(), deps)
129+ if err == nil || !strings.Contains(err.Error(), "configured python_interpreter") {
130+ t.Fatalf("err = %v, want configured failure", err)
131+ }
132+ if lookedUp || probed {
133+ t.Fatalf("missing configured interpreter must not fall through (lookup=%v probe=%v)", lookedUp, probed)
134+ }
135+}
136+
137+func TestResolveConfiguredPython2NoFallback(t *testing.T) {
138+ deps := testPythonDeps()
139+ configured := pythonTestAbs(t, "opt", "python2", "bin", "python")
140+ deps.Configured = configured
141+ deps.IsFile = func(s string) bool { return s == configured }
142+ probes := 0
143+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
144+ probes++
145+ if exe != configured {
146+ t.Fatalf("probed %q", exe)
147+ }
148+ return pythonVersion{}, errors.New("probe returned Python 2 (need Python 3)")
149+ }
150+ deps.LookPath = func(string) (string, error) {
151+ t.Fatal("must not consult PATH after configured failure")
152+ return "", errors.New("not found")
153+ }
154+ _, err := resolvePythonInterpreter(context.Background(), deps)
155+ if err == nil || !strings.Contains(err.Error(), "configured python_interpreter") {
156+ t.Fatalf("err = %v", err)
157+ }
158+ if probes != 1 {
159+ t.Fatalf("probes = %d, want 1", probes)
160+ }
161+}
162+
163+func TestResolveVirtualEnvCandidateConstruction(t *testing.T) {
164+ venvRoot := pythonTestAbs(t, "opt", "venv")
165+ if got := pythonVenvCandidate(pythonTestAbs(t, "s"), venvRoot, "linux"); got != filepath.Join(venvRoot, "bin", "python") {
166+ t.Fatalf("unix = %q", got)
167+ }
168+ if got := pythonVenvCandidate(pythonTestAbs(t, "s"), venvRoot, "windows"); got != filepath.Join(venvRoot, "Scripts", "python.exe") {
169+ t.Fatalf("windows = %q", got)
170+ }
171+ if got := pythonVenvCandidate(filepath.Join(volName(t), "s cwd"), "rel env", "linux"); got != filepath.Join(volName(t), "s cwd", "rel env", "bin", "python") {
172+ t.Fatalf("relative root with spaces = %q", got)
173+ }
174+}
175+
176+func TestResolveVirtualEnvTakesPriorityNoFallback(t *testing.T) {
177+ deps := testPythonDeps()
178+ deps.Getenv = func(k string) string {
179+ if k == "VIRTUAL_ENV" {
180+ return "/opt/venv"
181+ }
182+ return ""
183+ }
184+ deps.IsFile = func(s string) bool { return false }
185+ _, err := resolvePythonInterpreter(context.Background(), deps)
186+ if err == nil || !strings.Contains(err.Error(), "active VIRTUAL_ENV") {
187+ t.Fatalf("err = %v, want VIRTUAL_ENV failure without fallback", err)
188+ }
189+}
190+
191+func TestResolveLocalVenvInvalidReported(t *testing.T) {
192+ dir := t.TempDir()
193+ if err := os.MkdirAll(filepath.Join(dir, ".venv"), 0o755); err != nil {
194+ t.Fatal(err)
195+ }
196+ deps := testPythonDeps()
197+ deps.CWD = dir
198+ deps.LookPath = func(string) (string, error) {
199+ t.Fatal("present .venv must not fall through to PATH")
200+ return "", errors.New("not found")
201+ }
202+ _, err := resolvePythonInterpreter(context.Background(), deps)
203+ if err == nil || !strings.Contains(err.Error(), ".venv") {
204+ t.Fatalf("err = %v, want .venv failure", err)
205+ }
206+}
207+
208+func TestResolvePathFallbackPython2ThenPython3(t *testing.T) {
209+ deps := testPythonDeps()
210+ // Absolute LookPath fixtures: a rooted Unix-style path is not
211+ // absolute on Windows, where Abs would prepend the drive's CWD.
212+ dir := t.TempDir()
213+ py3 := filepath.Join(dir, "python3")
214+ py := filepath.Join(dir, "python")
215+ deps.LookPath = func(name string) (string, error) {
216+ if name == "python3" {
217+ return py3, nil
218+ }
219+ if name == "python" {
220+ return py, nil
221+ }
222+ return "", errors.New("not found")
223+ }
224+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
225+ if exe == py3 {
226+ return pythonVersion{}, errors.New("probe returned Python 2 (need Python 3)")
227+ }
228+ return pythonVersion{Major: 3, Minor: 12, Micro: 1}, nil
229+ }
230+ got, err := resolvePythonInterpreter(context.Background(), deps)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if got.Path != py || got.Version.String() != "3.12.1" {
235+ t.Fatalf("got %+v", got)
236+ }
237+}
238+
239+func TestResolvePathMissingGivesGuidance(t *testing.T) {
240+ deps := testPythonDeps()
241+ _, err := resolvePythonInterpreter(context.Background(), deps)
242+ if err == nil {
243+ t.Fatal("want error")
244+ }
245+ for _, want := range []string{"existing Python 3", "python_interpreter", "never installs"} {
246+ if !strings.Contains(err.Error(), want) {
247+ t.Fatalf("error missing %q: %q", want, err)
248+ }
249+ }
250+}
251+
252+func TestResolveCancellationStopsDiscovery(t *testing.T) {
253+ deps := testPythonDeps()
254+ deps.LookPath = func(name string) (string, error) {
255+ return "/usr/bin/" + name, nil
256+ }
257+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
258+ t.Fatal("cancelled discovery must not probe")
259+ return pythonVersion{}, nil
260+ }
261+ ctx, cancel := context.WithCancel(context.Background())
262+ cancel()
263+ _, err := resolvePythonInterpreter(ctx, deps)
264+ if !errors.Is(err, context.Canceled) {
265+ t.Fatalf("err = %v, want context.Canceled", err)
266+ }
267+}
268+
269+func TestResolveProbeDeadlineRespected(t *testing.T) {
270+ deps := testPythonDeps()
271+ deps.Configured = "/opt/py/bin/python"
272+ deps.IsFile = func(string) bool { return true }
273+ deps.Probe = func(ctx context.Context, _ string) (pythonVersion, error) {
274+ <-ctx.Done()
275+ return pythonVersion{}, ctx.Err()
276+ }
277+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
278+ defer cancel()
279+ start := time.Now()
280+ _, err := resolvePythonInterpreter(ctx, deps)
281+ if err == nil {
282+ t.Fatal("want deadline error")
283+ }
284+ if time.Since(start) > 3*time.Second {
285+ t.Fatalf("probe did not respect deadline")
286+ }
287+}
288+
289+func TestLimitedWriterBoundsProbeOutput(t *testing.T) {
290+ var buf bytes.Buffer
291+ w := &limitedWriter{W: &buf, N: 8}
292+ n, err := w.Write([]byte("1234567890abcdef"))
293+ if err != nil || n != 16 {
294+ t.Fatalf("Write = %d, %v, want 16, nil", n, err)
295+ }
296+ if buf.Len() != 8 || buf.String() != "12345678" {
297+ t.Fatalf("bounded buffer = %q, want %q", buf.String(), "12345678")
298+ }
299+ // Further writes are dropped but report success.
300+ if n, _ := w.Write([]byte("more")); n != 4 || buf.Len() != 8 {
301+ t.Fatalf("overflow write = %d len %d", n, buf.Len())
302+ }
303+}
304+
305+func TestWindowsRejectsStoreAliasBothNames(t *testing.T) {
306+ for _, name := range []string{"python3", "python"} {
307+ deps := testPythonDeps()
308+ deps.GOOS = "windows"
309+ deps.CWD = `C:\proj`
310+ alias := `C:\Users\test\AppData\Local\Microsoft\WindowsApps\` + name + `.exe`
311+ deps.LookPath = func(n string) (string, error) {
312+ if n == name {
313+ return alias, nil
314+ }
315+ return "", errors.New("not found")
316+ }
317+ deps.EvalSym = func(s string) (string, error) { return s, nil }
318+ deps.LayoutOK = func(string) bool { return true }
319+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
320+ t.Fatalf("alias %q must be rejected before any probe", alias)
321+ return pythonVersion{}, nil
322+ }
323+ _, err := resolvePythonInterpreter(context.Background(), deps)
324+ if err == nil || !strings.Contains(err.Error(), "existing Python 3") {
325+ t.Fatalf("%s: err = %v, want missing-interpreter fallback", name, err)
326+ }
327+ }
328+}
329+
330+func TestWindowsRejectsLauncherAndManagerBeforeProbe(t *testing.T) {
331+ cases := map[string]string{
332+ "launcher": `C:\Windows\py.exe`,
333+ "manager": `C:\Program Files\Python\python-manager.exe`,
334+ "wrapper": `C:\tools\weird-python.exe`,
335+ }
336+ for kind, exe := range cases {
337+ deps := testPythonDeps()
338+ deps.GOOS = "windows"
339+ deps.Configured = exe
340+ deps.IsFile = func(string) bool { return true }
341+ deps.EvalSym = func(s string) (string, error) { return s, nil }
342+ deps.LayoutOK = func(p string) bool {
343+ // Only the wrapper case reaches layout verification with false.
344+ return kind == "launcher" || kind == "manager"
345+ }
346+ probed := false
347+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
348+ probed = true
349+ return pythonVersion{Major: 3}, nil
350+ }
351+ _, err := resolvePythonInterpreter(context.Background(), deps)
352+ if err == nil {
353+ t.Fatalf("%s %q accepted", kind, exe)
354+ }
355+ if probed && kind != "launcher" && kind != "manager" {
356+ // Wrapper must also be rejected before probing.
357+ t.Fatalf("%s %q probed an unverifiable wrapper", kind, exe)
358+ }
359+ if probed {
360+ t.Fatalf("%s %q must be rejected before any probe", kind, exe)
361+ }
362+ }
363+}
364+
365+func TestWindowsSymlinkTargetClassified(t *testing.T) {
366+ deps := testPythonDeps()
367+ deps.GOOS = "windows"
368+ deps.Configured = `C:\tools\python.exe`
369+ deps.IsFile = func(string) bool { return true }
370+ deps.EvalSym = func(string) (string, error) {
371+ return `C:\Users\test\AppData\Local\Microsoft\WindowsApps\python.exe`, nil
372+ }
373+ deps.LayoutOK = func(string) bool { return true }
374+ deps.Probe = func(context.Context, string) (pythonVersion, error) {
375+ t.Fatal("symlink target alias must be rejected before probe")
376+ return pythonVersion{}, nil
377+ }
378+ _, err := resolvePythonInterpreter(context.Background(), deps)
379+ if err == nil || !strings.Contains(strings.ToLower(err.Error()), "store") {
380+ t.Fatalf("err = %v, want Store rejection via resolved target", err)
381+ }
382+}
383+
384+func TestWindowsNeverUsesPyLauncherFallback(t *testing.T) {
385+ deps := testPythonDeps()
386+ deps.GOOS = "windows"
387+ seen := []string{}
388+ deps.LookPath = func(name string) (string, error) {
389+ seen = append(seen, name)
390+ return "", errors.New("not found")
391+ }
392+ _, _ = resolvePythonInterpreter(context.Background(), deps)
393+ for _, name := range seen {
394+ if name == "py" || name == "pyw" {
395+ t.Fatalf("resolver must never consult the py launcher, saw %q", seen)
396+ }
397+ }
398+}
399+
400+func TestResolvePathResultAbsolutized(t *testing.T) {
401+ // A relative PATH entry can yield a relative interpreter path. The
402+ // probe runs in the process directory while execution runs in the
403+ // session CWD, so resolution must anchor it: probing, classification,
404+ // and execution must address the same executable.
405+ rel := filepath.Join("rel", "bin", "python3")
406+ wantAbs, err := filepath.Abs(rel)
407+ if err != nil {
408+ t.Fatal(err)
409+ }
410+ deps := testPythonDeps()
411+ deps.LookPath = func(name string) (string, error) {
412+ if name == "python3" {
413+ return rel, nil
414+ }
415+ return "", errors.New("not found")
416+ }
417+ deps.IsFile = func(s string) bool { return s == wantAbs }
418+ var probed string
419+ deps.Probe = func(_ context.Context, exe string) (pythonVersion, error) {
420+ probed = exe
421+ return pythonVersion{Major: 3, Minor: 1, Micro: 0}, nil
422+ }
423+ got, err := resolvePythonInterpreter(context.Background(), deps)
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ if !filepath.IsAbs(got.Path) || got.Path != wantAbs {
428+ t.Fatalf("resolved path = %q, want absolutized %q", got.Path, wantAbs)
429+ }
430+ if probed != got.Path {
431+ t.Fatalf("probed %q but returned %q", probed, got.Path)
432+ }
433+}
434+
435+func TestPythonChildEnvKeepsHostOnUnix(t *testing.T) {
436+ t.Setenv("ZUT_PYTHON_TEST_SENTINEL", "kept")
437+ found := false
438+ for _, kv := range pythonChildEnv() {
439+ if kv == "ZUT_PYTHON_TEST_SENTINEL=kept" {
440+ found = true
441+ }
442+ }
443+ if !found {
444+ t.Fatal("host environment must be inherited")
445+ }
446+}
447+
448+func TestPythonChildEnvStripsLauncherOptInsCaseInsensitively(t *testing.T) {
449+ // Windows environment keys are case-insensitive; pythonChildEnv only
450+ // filters on Windows, so this test pins the matching contract via the
451+ // same normalization on synthetic input.
452+ input := []string{
453+ "PYLAUNCHER_ALLOW_INSTALL=1",
454+ "pylauncher_always_install=1",
455+ "PyLauncher_Allow_Install=1",
456+ "PYTHON_MANAGER_AUTOMATIC_INSTALL=1",
457+ "python_manager_automatic_install=0",
458+ "PATH=/usr/bin",
459+ "ZUT_PYTHON_TEST_SENTINEL=kept",
460+ }
461+ var kept []string
462+ for _, kv := range input {
463+ key := kv
464+ if i := strings.IndexByte(kv, '='); i >= 0 {
465+ key = kv[:i]
466+ }
467+ switch strings.ToUpper(key) {
468+ case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
469+ continue
470+ }
471+ kept = append(kept, kv)
472+ }
473+ if len(kept) != 2 || kept[0] != "PATH=/usr/bin" || kept[1] != "ZUT_PYTHON_TEST_SENTINEL=kept" {
474+ t.Fatalf("kept = %q, want only PATH and sentinel", kept)
475+ }
476+}
A packages/agent/tools/python_test.go +407 -0
@@ -0,0 +1,407 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "os"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+func pythonArgsJSON(t *testing.T, code string, timeout int64) json.RawMessage {
17+ t.Helper()
18+ return mustJSON(t, map[string]any{"code": code, "timeout": timeout})
19+}
20+
21+func stubPythonTool(resolved resolvedPython, resolveErr error, outcome pythonExecOutcome, execErr error) *PythonTool {
22+ return &PythonTool{
23+ CWD: "/session",
24+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
25+ return resolved, resolveErr
26+ },
27+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
28+ return outcome, execErr
29+ },
30+ }
31+}
32+
33+func TestPythonSchemaRequiresCodeAndTimeout(t *testing.T) {
34+ var schema struct {
35+ Properties map[string]struct {
36+ Type string `json:"type"`
37+ Minimum *int64 `json:"minimum"`
38+ Maximum *int64 `json:"maximum"`
39+ Description string `json:"description"`
40+ } `json:"properties"`
41+ Required []string `json:"required"`
42+ }
43+ if err := json.Unmarshal((&PythonTool{}).Schema(), &schema); err != nil {
44+ t.Fatal(err)
45+ }
46+ timeout, ok := schema.Properties["timeout"]
47+ if !ok || timeout.Type != "integer" || timeout.Minimum == nil || *timeout.Minimum != 1 || timeout.Maximum == nil || *timeout.Maximum != maxPythonTimeoutSeconds {
48+ t.Fatalf("timeout schema = %+v", timeout)
49+ }
50+ if _, ok := schema.Properties["code"]; !ok {
51+ t.Fatal("code property missing")
52+ }
53+ required := map[string]bool{}
54+ for _, name := range schema.Required {
55+ required[name] = true
56+ }
57+ if !required["code"] || !required["timeout"] {
58+ t.Fatalf("required = %v", schema.Required)
59+ }
60+ if (&PythonTool{}).Name() != "python" {
61+ t.Fatalf("name = %q", (&PythonTool{}).Name())
62+ }
63+}
64+
65+func TestPythonExecArgvDirect(t *testing.T) {
66+ argv := pythonExecArgv("/usr/bin/python3")
67+ if len(argv) != 4 || argv[0] != "/usr/bin/python3" || argv[1] != "-u" || argv[2] != "-B" || argv[3] != "-" {
68+ t.Fatalf("argv = %q, want [exe -u -B -]", argv)
69+ }
70+}
71+
72+func TestPythonRejectsInvalidArgs(t *testing.T) {
73+ tool := stubPythonTool(resolvedPython{}, nil, pythonExecOutcome{}, nil)
74+ cases := []struct {
75+ name string
76+ raw json.RawMessage
77+ want string
78+ }{
79+ {"blank code", mustJSON(t, map[string]any{"code": " ", "timeout": 5}), "code is required"},
80+ {"missing timeout", mustJSON(t, map[string]any{"code": "print(1)"}), "timeout is required"},
81+ {"zero timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": 0}), "timeout must be between 1 and"},
82+ {"negative timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": -2}), "timeout must be between 1 and"},
83+ }
84+ for _, tc := range cases {
85+ t.Run(tc.name, func(t *testing.T) {
86+ res, err := tool.Execute(context.Background(), tc.raw, nil)
87+ if err != nil {
88+ t.Fatalf("err = %v, want model-visible IsError result", err)
89+ }
90+ if !res.IsError {
91+ t.Fatal("want IsError for invalid args")
92+ }
93+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
94+ t.Fatalf("got %q want %q", got, tc.want)
95+ }
96+ })
97+ }
98+}
99+
100+func TestPythonDeniedNeverResolves(t *testing.T) {
101+ resolveCalls := 0
102+ execCalls := 0
103+ mk := func(sandbox *Sandbox) *PythonTool {
104+ return &PythonTool{
105+ CWD: "/session",
106+ Sandbox: sandbox,
107+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
108+ resolveCalls++
109+ return resolvedPython{Path: "/usr/bin/python3"}, nil
110+ },
111+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
112+ execCalls++
113+ return pythonExecOutcome{}, nil
114+ },
115+ }
116+ }
117+ jailed := NewSandbox("/session")
118+ jailed.Lock()
119+ res, err := mk(jailed).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
120+ if err != nil {
121+ t.Fatalf("jail err = %v, want model-visible IsError result", err)
122+ }
123+ if !res.IsError {
124+ t.Fatal("want IsError for jailed execution")
125+ }
126+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "cannot be confined") {
127+ t.Fatalf("jail result = %q", got)
128+ }
129+ restricted := NewSandbox("/session")
130+ restricted.SetPermissions(&PermissionSet{})
131+ res, err = mk(restricted).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
132+ if err != nil {
133+ t.Fatalf("permission err = %v, want model-visible IsError result", err)
134+ }
135+ if !res.IsError {
136+ t.Fatal("want IsError for permission-denied execution")
137+ }
138+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "permission denied") {
139+ t.Fatalf("permission result = %q", got)
140+ }
141+ if resolveCalls != 0 || execCalls != 0 {
142+ t.Fatalf("denied calls must never resolve or execute (resolve=%d exec=%d)", resolveCalls, execCalls)
143+ }
144+}
145+
146+func TestPythonMissingInterpreterActionable(t *testing.T) {
147+ tool := &PythonTool{
148+ CWD: "/session",
149+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
150+ return resolvedPython{}, missingPythonError()
151+ },
152+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
153+ t.Fatal("missing interpreter must not execute")
154+ return pythonExecOutcome{}, nil
155+ },
156+ }
157+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
158+ if err != nil {
159+ t.Fatalf("err = %v, want model-visible IsError result", err)
160+ }
161+ if !res.IsError {
162+ t.Fatal("want IsError for missing interpreter")
163+ }
164+ got := res.Content[0].(provider.TextBlock).Text
165+ if !strings.Contains(got, "existing Python 3") || !strings.Contains(got, "never installs") {
166+ t.Fatalf("result = %q, want actionable guidance", got)
167+ }
168+}
169+
170+func TestPythonSuccessResult(t *testing.T) {
171+ interp := resolvedPython{Path: "/usr/bin/python3", Version: pythonVersion{Major: 3, Minor: 12, Micro: 1}}
172+ tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: "hi\n", ExitCode: 0}, nil)
173+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print('hi')", 5), nil)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if res.IsError {
178+ t.Fatal("unexpected IsError")
179+ }
180+ text := res.Content[0].(provider.TextBlock).Text
181+ for _, want := range []string{"python 3.12.1", "/usr/bin/python3", "hi", "[exit 0]"} {
182+ if !strings.Contains(text, want) {
183+ t.Fatalf("result missing %q:\n%s", want, text)
184+ }
185+ }
186+ details := res.Details.(map[string]any)
187+ if details["exit_code"] != 0 || details["interpreter"] != "/usr/bin/python3" || details["python_version"] != "3.12.1" || details["bytes_truncated"] != false || details["lines_truncated"] != false {
188+ t.Fatalf("details = %#v", details)
189+ }
190+ if path, _ := details["full_output_path"].(string); path != "" || strings.Contains(text, "full output") {
191+ t.Fatalf("untruncated result must not persist a full-output file: %q", path)
192+ }
193+}
194+
195+func TestPythonFailureStatuses(t *testing.T) {
196+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
197+ for _, tc := range []struct {
198+ name string
199+ outcome pythonExecOutcome
200+ want string
201+ }{
202+ {"nonzero", pythonExecOutcome{Captured: "boom\n", ExitCode: 3}, "[exit 3]"},
203+ {"timeout", pythonExecOutcome{Captured: "", ExitCode: -1, TimedOut: true}, "[timed out after 5 seconds]"},
204+ {"cancelled", pythonExecOutcome{Captured: "", ExitCode: -1, Cancelled: true}, "[cancelled]"},
205+ } {
206+ t.Run(tc.name, func(t *testing.T) {
207+ tool := stubPythonTool(interp, nil, tc.outcome, nil)
208+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
209+ if err != nil {
210+ t.Fatal(err)
211+ }
212+ if !res.IsError {
213+ t.Fatal("want IsError")
214+ }
215+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
216+ t.Fatalf("got %q want %q", got, tc.want)
217+ }
218+ })
219+ }
220+}
221+
222+func TestPythonCancelErrorCapture(t *testing.T) {
223+ var ce pythonCancelError
224+ if err := ce.get(); err != nil {
225+ t.Fatalf("empty capture = %v, want nil", err)
226+ }
227+ ce.set(errors.New("kill group: boom"))
228+ if err := ce.get(); err == nil || !strings.Contains(err.Error(), "boom") {
229+ t.Fatalf("captured = %v, want kill error", err)
230+ }
231+ // A nil receiver must not panic: probes pass nil explicitly.
232+ var nilCE *pythonCancelError
233+ nilCE.set(errors.New("ignored"))
234+ if err := nilCE.get(); err != nil {
235+ t.Fatalf("nil capture = %v, want nil", err)
236+ }
237+}
238+
239+func TestPythonTruncationMarkedWithFullOutput(t *testing.T) {
240+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3, Minor: 1, Micro: 0}}
241+ // Exactly maxPythonLines newline-terminated lines must not count as
242+ // truncated: the trailing newline terminates the last line.
243+ exact := strings.Repeat("x\n", maxPythonLines)
244+ tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: exact, ExitCode: 0}, nil)
245+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
246+ if err != nil {
247+ t.Fatal(err)
248+ }
249+ if got := res.Content[0].(provider.TextBlock).Text; strings.Contains(got, "truncated at") {
250+ t.Fatalf("exact line budget must not truncate:\n%s", got)
251+ }
252+ if res.Details.(map[string]any)["lines_truncated"] != false {
253+ t.Fatalf("details = %#v", res.Details)
254+ }
255+ if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path != "" {
256+ t.Fatalf("untruncated result must not persist an artifact: %q", path)
257+ }
258+
259+ long := strings.Repeat("x\n", maxPythonLines+10)
260+ tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: long, ExitCode: 0}, nil)
261+ res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ text := res.Content[0].(provider.TextBlock).Text
266+ if !strings.Contains(text, "truncated at 2000 lines") {
267+ t.Fatalf("lines truncation not marked:\n%s", text[len(text)-500:])
268+ }
269+ details := res.Details.(map[string]any)
270+ if details["lines_truncated"] != true {
271+ t.Fatalf("details = %#v", res.Details)
272+ }
273+ fullPath, _ := details["full_output_path"].(string)
274+ if fullPath == "" || !strings.Contains(text, "full output: "+fullPath) {
275+ t.Fatalf("truncated result must reference a full-output file: %q\n%s", fullPath, text)
276+ }
277+ t.Cleanup(func() { _ = os.Remove(fullPath) })
278+ persisted, err := os.ReadFile(fullPath)
279+ if err != nil {
280+ t.Fatal(err)
281+ }
282+ if string(persisted) != long {
283+ t.Fatal("full-output file must hold the byte-capped buffer beyond line trimming")
284+ }
285+
286+ big := strings.Repeat("y", maxPythonBytes+100)
287+ // The stub bypasses capture, so trim the fixture the way the real
288+ // runner would: byte-capped, then line-trimmed for display.
289+ big = big[:maxPythonBytes]
290+ tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: big, ExitCode: 0, BytesTrunc: true}, nil)
291+ res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "truncated at 51200 bytes") {
296+ t.Fatalf("bytes truncation not marked")
297+ }
298+ if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path == "" {
299+ t.Fatal("byte-truncated result must persist a full-output file")
300+ } else {
301+ t.Cleanup(func() { _ = os.Remove(path) })
302+ }
303+}
304+
305+func TestPythonProgressStreamsMergedOutput(t *testing.T) {
306+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
307+ var calls []string
308+ tool := &PythonTool{
309+ CWD: "/session",
310+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
311+ return interp, nil
312+ },
313+ execHook: func(_ context.Context, _, _, _ string, progress func(string)) (pythonExecOutcome, error) {
314+ progress("out\n")
315+ progress("err\n")
316+ return pythonExecOutcome{Captured: "out\nerr\n", ExitCode: 0}, nil
317+ },
318+ }
319+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), func(s string) {
320+ calls = append(calls, s)
321+ })
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ if strings.Join(calls, "") != "out\nerr\n" {
326+ t.Fatalf("progress = %q", calls)
327+ }
328+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "out") || !strings.Contains(got, "err") {
329+ t.Fatalf("merged output missing:\n%s", got)
330+ }
331+}
332+
333+func TestAppendPythonChunkBoundaries(t *testing.T) {
334+ var buf bytes.Buffer
335+ if appendPythonChunk(&buf, bytes.Repeat([]byte("y"), maxPythonBytes)) {
336+ t.Fatal("output exactly filling the budget must not report truncation")
337+ }
338+ if buf.Len() != maxPythonBytes {
339+ t.Fatalf("buffer len = %d, want %d", buf.Len(), maxPythonBytes)
340+ }
341+ // One byte over discards exactly that byte.
342+ if !appendPythonChunk(&buf, []byte("!")) {
343+ t.Fatal("overflow byte must report truncation")
344+ }
345+ if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] == '!' {
346+ t.Fatal("overflow byte must not be retained")
347+ }
348+ // A chunk straddling the boundary keeps its fitting prefix and
349+ // reports the discarded remainder.
350+ buf.Reset()
351+ if appendPythonChunk(&buf, bytes.Repeat([]byte("a"), maxPythonBytes-1)) {
352+ t.Fatal("sub-budget write must not report truncation")
353+ }
354+ if !appendPythonChunk(&buf, []byte("bc")) {
355+ t.Fatal("straddling write must report its discarded remainder")
356+ }
357+ if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] != 'b' {
358+ t.Fatal("straddling write must retain only its fitting prefix")
359+ }
360+ if appendPythonChunk(&buf, nil) {
361+ t.Fatal("empty chunk must not report truncation")
362+ }
363+}
364+
365+func TestPythonTimeoutBounds(t *testing.T) {
366+ if _, err := pythonTimeoutDuration(0); err == nil {
367+ t.Fatal("want error for 0")
368+ }
369+ if _, err := pythonTimeoutDuration(maxPythonTimeoutSeconds + 1); err == nil {
370+ t.Fatal("want error above maximum")
371+ }
372+ if d, err := pythonTimeoutDuration(2); err != nil || d != 2*time.Second {
373+ t.Fatalf("d = %s, err = %v", d, err)
374+ }
375+}
376+
377+func TestPythonResolutionSharesTimeoutBudget(t *testing.T) {
378+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
379+ tool := &PythonTool{
380+ CWD: "/session",
381+ resolveHook: func(ctx context.Context, _ string, _ string) (resolvedPython, error) {
382+ if _, ok := ctx.Deadline(); !ok {
383+ t.Fatal("resolution must share the invocation deadline")
384+ }
385+ return interp, nil
386+ },
387+ execHook: func(ctx context.Context, _, _, _ string, _ func(string)) (pythonExecOutcome, error) {
388+ if _, ok := ctx.Deadline(); !ok {
389+ t.Fatal("execution must share the invocation deadline")
390+ }
391+ return pythonExecOutcome{ExitCode: 0}, nil
392+ },
393+ }
394+ if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err != nil {
395+ t.Fatal(err)
396+ }
397+}
398+
399+func TestPythonExecErrorPropagates(t *testing.T) {
400+ tool := stubPythonTool(
401+ resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}},
402+ nil, pythonExecOutcome{}, errors.New("start: boom"),
403+ )
404+ if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err == nil || !strings.Contains(err.Error(), "boom") {
405+ t.Fatalf("err = %v", err)
406+ }
407+}
@@ -0,0 +1,407 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "os"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+func pythonArgsJSON(t *testing.T, code string, timeout int64) json.RawMessage {
17+ t.Helper()
18+ return mustJSON(t, map[string]any{"code": code, "timeout": timeout})
19+}
20+
21+func stubPythonTool(resolved resolvedPython, resolveErr error, outcome pythonExecOutcome, execErr error) *PythonTool {
22+ return &PythonTool{
23+ CWD: "/session",
24+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
25+ return resolved, resolveErr
26+ },
27+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
28+ return outcome, execErr
29+ },
30+ }
31+}
32+
33+func TestPythonSchemaRequiresCodeAndTimeout(t *testing.T) {
34+ var schema struct {
35+ Properties map[string]struct {
36+ Type string `json:"type"`
37+ Minimum *int64 `json:"minimum"`
38+ Maximum *int64 `json:"maximum"`
39+ Description string `json:"description"`
40+ } `json:"properties"`
41+ Required []string `json:"required"`
42+ }
43+ if err := json.Unmarshal((&PythonTool{}).Schema(), &schema); err != nil {
44+ t.Fatal(err)
45+ }
46+ timeout, ok := schema.Properties["timeout"]
47+ if !ok || timeout.Type != "integer" || timeout.Minimum == nil || *timeout.Minimum != 1 || timeout.Maximum == nil || *timeout.Maximum != maxPythonTimeoutSeconds {
48+ t.Fatalf("timeout schema = %+v", timeout)
49+ }
50+ if _, ok := schema.Properties["code"]; !ok {
51+ t.Fatal("code property missing")
52+ }
53+ required := map[string]bool{}
54+ for _, name := range schema.Required {
55+ required[name] = true
56+ }
57+ if !required["code"] || !required["timeout"] {
58+ t.Fatalf("required = %v", schema.Required)
59+ }
60+ if (&PythonTool{}).Name() != "python" {
61+ t.Fatalf("name = %q", (&PythonTool{}).Name())
62+ }
63+}
64+
65+func TestPythonExecArgvDirect(t *testing.T) {
66+ argv := pythonExecArgv("/usr/bin/python3")
67+ if len(argv) != 4 || argv[0] != "/usr/bin/python3" || argv[1] != "-u" || argv[2] != "-B" || argv[3] != "-" {
68+ t.Fatalf("argv = %q, want [exe -u -B -]", argv)
69+ }
70+}
71+
72+func TestPythonRejectsInvalidArgs(t *testing.T) {
73+ tool := stubPythonTool(resolvedPython{}, nil, pythonExecOutcome{}, nil)
74+ cases := []struct {
75+ name string
76+ raw json.RawMessage
77+ want string
78+ }{
79+ {"blank code", mustJSON(t, map[string]any{"code": " ", "timeout": 5}), "code is required"},
80+ {"missing timeout", mustJSON(t, map[string]any{"code": "print(1)"}), "timeout is required"},
81+ {"zero timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": 0}), "timeout must be between 1 and"},
82+ {"negative timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": -2}), "timeout must be between 1 and"},
83+ }
84+ for _, tc := range cases {
85+ t.Run(tc.name, func(t *testing.T) {
86+ res, err := tool.Execute(context.Background(), tc.raw, nil)
87+ if err != nil {
88+ t.Fatalf("err = %v, want model-visible IsError result", err)
89+ }
90+ if !res.IsError {
91+ t.Fatal("want IsError for invalid args")
92+ }
93+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
94+ t.Fatalf("got %q want %q", got, tc.want)
95+ }
96+ })
97+ }
98+}
99+
100+func TestPythonDeniedNeverResolves(t *testing.T) {
101+ resolveCalls := 0
102+ execCalls := 0
103+ mk := func(sandbox *Sandbox) *PythonTool {
104+ return &PythonTool{
105+ CWD: "/session",
106+ Sandbox: sandbox,
107+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
108+ resolveCalls++
109+ return resolvedPython{Path: "/usr/bin/python3"}, nil
110+ },
111+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
112+ execCalls++
113+ return pythonExecOutcome{}, nil
114+ },
115+ }
116+ }
117+ jailed := NewSandbox("/session")
118+ jailed.Lock()
119+ res, err := mk(jailed).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
120+ if err != nil {
121+ t.Fatalf("jail err = %v, want model-visible IsError result", err)
122+ }
123+ if !res.IsError {
124+ t.Fatal("want IsError for jailed execution")
125+ }
126+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "cannot be confined") {
127+ t.Fatalf("jail result = %q", got)
128+ }
129+ restricted := NewSandbox("/session")
130+ restricted.SetPermissions(&PermissionSet{})
131+ res, err = mk(restricted).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
132+ if err != nil {
133+ t.Fatalf("permission err = %v, want model-visible IsError result", err)
134+ }
135+ if !res.IsError {
136+ t.Fatal("want IsError for permission-denied execution")
137+ }
138+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "permission denied") {
139+ t.Fatalf("permission result = %q", got)
140+ }
141+ if resolveCalls != 0 || execCalls != 0 {
142+ t.Fatalf("denied calls must never resolve or execute (resolve=%d exec=%d)", resolveCalls, execCalls)
143+ }
144+}
145+
146+func TestPythonMissingInterpreterActionable(t *testing.T) {
147+ tool := &PythonTool{
148+ CWD: "/session",
149+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
150+ return resolvedPython{}, missingPythonError()
151+ },
152+ execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
153+ t.Fatal("missing interpreter must not execute")
154+ return pythonExecOutcome{}, nil
155+ },
156+ }
157+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
158+ if err != nil {
159+ t.Fatalf("err = %v, want model-visible IsError result", err)
160+ }
161+ if !res.IsError {
162+ t.Fatal("want IsError for missing interpreter")
163+ }
164+ got := res.Content[0].(provider.TextBlock).Text
165+ if !strings.Contains(got, "existing Python 3") || !strings.Contains(got, "never installs") {
166+ t.Fatalf("result = %q, want actionable guidance", got)
167+ }
168+}
169+
170+func TestPythonSuccessResult(t *testing.T) {
171+ interp := resolvedPython{Path: "/usr/bin/python3", Version: pythonVersion{Major: 3, Minor: 12, Micro: 1}}
172+ tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: "hi\n", ExitCode: 0}, nil)
173+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print('hi')", 5), nil)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if res.IsError {
178+ t.Fatal("unexpected IsError")
179+ }
180+ text := res.Content[0].(provider.TextBlock).Text
181+ for _, want := range []string{"python 3.12.1", "/usr/bin/python3", "hi", "[exit 0]"} {
182+ if !strings.Contains(text, want) {
183+ t.Fatalf("result missing %q:\n%s", want, text)
184+ }
185+ }
186+ details := res.Details.(map[string]any)
187+ if details["exit_code"] != 0 || details["interpreter"] != "/usr/bin/python3" || details["python_version"] != "3.12.1" || details["bytes_truncated"] != false || details["lines_truncated"] != false {
188+ t.Fatalf("details = %#v", details)
189+ }
190+ if path, _ := details["full_output_path"].(string); path != "" || strings.Contains(text, "full output") {
191+ t.Fatalf("untruncated result must not persist a full-output file: %q", path)
192+ }
193+}
194+
195+func TestPythonFailureStatuses(t *testing.T) {
196+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
197+ for _, tc := range []struct {
198+ name string
199+ outcome pythonExecOutcome
200+ want string
201+ }{
202+ {"nonzero", pythonExecOutcome{Captured: "boom\n", ExitCode: 3}, "[exit 3]"},
203+ {"timeout", pythonExecOutcome{Captured: "", ExitCode: -1, TimedOut: true}, "[timed out after 5 seconds]"},
204+ {"cancelled", pythonExecOutcome{Captured: "", ExitCode: -1, Cancelled: true}, "[cancelled]"},
205+ } {
206+ t.Run(tc.name, func(t *testing.T) {
207+ tool := stubPythonTool(interp, nil, tc.outcome, nil)
208+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
209+ if err != nil {
210+ t.Fatal(err)
211+ }
212+ if !res.IsError {
213+ t.Fatal("want IsError")
214+ }
215+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
216+ t.Fatalf("got %q want %q", got, tc.want)
217+ }
218+ })
219+ }
220+}
221+
222+func TestPythonCancelErrorCapture(t *testing.T) {
223+ var ce pythonCancelError
224+ if err := ce.get(); err != nil {
225+ t.Fatalf("empty capture = %v, want nil", err)
226+ }
227+ ce.set(errors.New("kill group: boom"))
228+ if err := ce.get(); err == nil || !strings.Contains(err.Error(), "boom") {
229+ t.Fatalf("captured = %v, want kill error", err)
230+ }
231+ // A nil receiver must not panic: probes pass nil explicitly.
232+ var nilCE *pythonCancelError
233+ nilCE.set(errors.New("ignored"))
234+ if err := nilCE.get(); err != nil {
235+ t.Fatalf("nil capture = %v, want nil", err)
236+ }
237+}
238+
239+func TestPythonTruncationMarkedWithFullOutput(t *testing.T) {
240+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3, Minor: 1, Micro: 0}}
241+ // Exactly maxPythonLines newline-terminated lines must not count as
242+ // truncated: the trailing newline terminates the last line.
243+ exact := strings.Repeat("x\n", maxPythonLines)
244+ tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: exact, ExitCode: 0}, nil)
245+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
246+ if err != nil {
247+ t.Fatal(err)
248+ }
249+ if got := res.Content[0].(provider.TextBlock).Text; strings.Contains(got, "truncated at") {
250+ t.Fatalf("exact line budget must not truncate:\n%s", got)
251+ }
252+ if res.Details.(map[string]any)["lines_truncated"] != false {
253+ t.Fatalf("details = %#v", res.Details)
254+ }
255+ if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path != "" {
256+ t.Fatalf("untruncated result must not persist an artifact: %q", path)
257+ }
258+
259+ long := strings.Repeat("x\n", maxPythonLines+10)
260+ tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: long, ExitCode: 0}, nil)
261+ res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ text := res.Content[0].(provider.TextBlock).Text
266+ if !strings.Contains(text, "truncated at 2000 lines") {
267+ t.Fatalf("lines truncation not marked:\n%s", text[len(text)-500:])
268+ }
269+ details := res.Details.(map[string]any)
270+ if details["lines_truncated"] != true {
271+ t.Fatalf("details = %#v", res.Details)
272+ }
273+ fullPath, _ := details["full_output_path"].(string)
274+ if fullPath == "" || !strings.Contains(text, "full output: "+fullPath) {
275+ t.Fatalf("truncated result must reference a full-output file: %q\n%s", fullPath, text)
276+ }
277+ t.Cleanup(func() { _ = os.Remove(fullPath) })
278+ persisted, err := os.ReadFile(fullPath)
279+ if err != nil {
280+ t.Fatal(err)
281+ }
282+ if string(persisted) != long {
283+ t.Fatal("full-output file must hold the byte-capped buffer beyond line trimming")
284+ }
285+
286+ big := strings.Repeat("y", maxPythonBytes+100)
287+ // The stub bypasses capture, so trim the fixture the way the real
288+ // runner would: byte-capped, then line-trimmed for display.
289+ big = big[:maxPythonBytes]
290+ tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: big, ExitCode: 0, BytesTrunc: true}, nil)
291+ res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "truncated at 51200 bytes") {
296+ t.Fatalf("bytes truncation not marked")
297+ }
298+ if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path == "" {
299+ t.Fatal("byte-truncated result must persist a full-output file")
300+ } else {
301+ t.Cleanup(func() { _ = os.Remove(path) })
302+ }
303+}
304+
305+func TestPythonProgressStreamsMergedOutput(t *testing.T) {
306+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
307+ var calls []string
308+ tool := &PythonTool{
309+ CWD: "/session",
310+ resolveHook: func(context.Context, string, string) (resolvedPython, error) {
311+ return interp, nil
312+ },
313+ execHook: func(_ context.Context, _, _, _ string, progress func(string)) (pythonExecOutcome, error) {
314+ progress("out\n")
315+ progress("err\n")
316+ return pythonExecOutcome{Captured: "out\nerr\n", ExitCode: 0}, nil
317+ },
318+ }
319+ res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), func(s string) {
320+ calls = append(calls, s)
321+ })
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ if strings.Join(calls, "") != "out\nerr\n" {
326+ t.Fatalf("progress = %q", calls)
327+ }
328+ if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "out") || !strings.Contains(got, "err") {
329+ t.Fatalf("merged output missing:\n%s", got)
330+ }
331+}
332+
333+func TestAppendPythonChunkBoundaries(t *testing.T) {
334+ var buf bytes.Buffer
335+ if appendPythonChunk(&buf, bytes.Repeat([]byte("y"), maxPythonBytes)) {
336+ t.Fatal("output exactly filling the budget must not report truncation")
337+ }
338+ if buf.Len() != maxPythonBytes {
339+ t.Fatalf("buffer len = %d, want %d", buf.Len(), maxPythonBytes)
340+ }
341+ // One byte over discards exactly that byte.
342+ if !appendPythonChunk(&buf, []byte("!")) {
343+ t.Fatal("overflow byte must report truncation")
344+ }
345+ if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] == '!' {
346+ t.Fatal("overflow byte must not be retained")
347+ }
348+ // A chunk straddling the boundary keeps its fitting prefix and
349+ // reports the discarded remainder.
350+ buf.Reset()
351+ if appendPythonChunk(&buf, bytes.Repeat([]byte("a"), maxPythonBytes-1)) {
352+ t.Fatal("sub-budget write must not report truncation")
353+ }
354+ if !appendPythonChunk(&buf, []byte("bc")) {
355+ t.Fatal("straddling write must report its discarded remainder")
356+ }
357+ if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] != 'b' {
358+ t.Fatal("straddling write must retain only its fitting prefix")
359+ }
360+ if appendPythonChunk(&buf, nil) {
361+ t.Fatal("empty chunk must not report truncation")
362+ }
363+}
364+
365+func TestPythonTimeoutBounds(t *testing.T) {
366+ if _, err := pythonTimeoutDuration(0); err == nil {
367+ t.Fatal("want error for 0")
368+ }
369+ if _, err := pythonTimeoutDuration(maxPythonTimeoutSeconds + 1); err == nil {
370+ t.Fatal("want error above maximum")
371+ }
372+ if d, err := pythonTimeoutDuration(2); err != nil || d != 2*time.Second {
373+ t.Fatalf("d = %s, err = %v", d, err)
374+ }
375+}
376+
377+func TestPythonResolutionSharesTimeoutBudget(t *testing.T) {
378+ interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
379+ tool := &PythonTool{
380+ CWD: "/session",
381+ resolveHook: func(ctx context.Context, _ string, _ string) (resolvedPython, error) {
382+ if _, ok := ctx.Deadline(); !ok {
383+ t.Fatal("resolution must share the invocation deadline")
384+ }
385+ return interp, nil
386+ },
387+ execHook: func(ctx context.Context, _, _, _ string, _ func(string)) (pythonExecOutcome, error) {
388+ if _, ok := ctx.Deadline(); !ok {
389+ t.Fatal("execution must share the invocation deadline")
390+ }
391+ return pythonExecOutcome{ExitCode: 0}, nil
392+ },
393+ }
394+ if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err != nil {
395+ t.Fatal(err)
396+ }
397+}
398+
399+func TestPythonExecErrorPropagates(t *testing.T) {
400+ tool := stubPythonTool(
401+ resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}},
402+ nil, pythonExecOutcome{}, errors.New("start: boom"),
403+ )
404+ if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err == nil || !strings.Contains(err.Error(), "boom") {
405+ t.Fatalf("err = %v", err)
406+ }
407+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close