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 Unverified
B5690EEEBB952194The 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. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 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 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 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 | 265 | - `edit`: one or more exact-match replacements in an existing file. | |
| 266 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 278 | ## Modes | |
| 278 | 279 | ||
| @@ -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 | ## Modes | 278 | ## 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). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 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 | 229 | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, | |
| 230 | 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`, | |
| 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. | |
| 234 | 234 | ||
| 235 | 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 | ||
| @@ -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 extension | 232 | + | `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) { | |||
| 123 | 123 | // Web capability and other native names remain reserved even when their | |
| 124 | 124 | // policy excludes the current session. An extension must not turn a | |
| 125 | 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 | 127 | continue | |
| 128 | 128 | } | |
| 129 | 129 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -858,6 +858,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 858 | 858 | } | |
| 859 | 859 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false) | |
| 860 | 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 | 863 | docsDir, _ := zutdocs.EnsureInstalled(ZutHome()) | |
| 863 | 864 | ||
| @@ -1309,6 +1310,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1309 | 1310 | v.Sandbox = s | |
| 1310 | 1311 | case *tools.BashTool: | |
| 1311 | 1312 | v.Sandbox = s | |
| 1313 | + | case *tools.PythonTool: | |
| 1314 | + | v.Sandbox = s | |
| 1312 | 1315 | case *tools.CreateWorktreeTool: | |
| 1313 | 1316 | v.Sandbox = s | |
| 1314 | 1317 | case *tools.LSPTool: | |
| @@ -1396,6 +1399,35 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1396 | 1399 | 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 | 1431 | func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool { | |
| 1400 | 1432 | if len(args.Tools) == 0 { | |
| 1401 | 1433 | return true | |
| @@ -1464,7 +1496,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1464 | 1496 | 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 | 1501 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1470 | 1502 | var out []ToolSummary | |
| @@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 123 | // Web capability and other native names remain reserved even when their | 123 | // Web capability and other native names remain reserved even when their | ||
| 124 | // policy excludes the current session. An extension must not turn a | 124 | // 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 | continue | 127 | 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 = s | 1310 | v.Sandbox = s | ||
| 1310 | case *tools.BashTool: | 1311 | case *tools.BashTool: | ||
| 1311 | v.Sandbox = s | 1312 | v.Sandbox = s | ||
| 1313 | + | case *tools.PythonTool: | |||
| 1314 | + | v.Sandbox = s | |||
| 1312 | case *tools.CreateWorktreeTool: | 1315 | case *tools.CreateWorktreeTool: | ||
| 1313 | v.Sandbox = s | 1316 | 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 reg | 1399 | 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 true | 1433 | return true | ||
| @@ -1464,7 +1496,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1464 | return false | 1496 | 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 []ToolSummary | 1502 | 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 { | |||
| 176 | 176 | // (default) and "below_input". | |
| 177 | 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 | 186 | // Insecure skips TLS verification for custom inference endpoints. | |
| 180 | 187 | Insecure bool `json:"insecure,omitempty"` | |
| 181 | 188 | ||
| @@ -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) { | |||
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | 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 | 425 | // 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. | |
| 429 | 429 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 430 | 430 | e.registerTool(name, description, schema, false, fn) | |
| 431 | 431 | } | |
| @@ -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 the | 425 | // 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 | 426 | + | // built-in. Every native public-web name, plus grep, python, schedule, |
| 427 | - | // update_plan, is reserved even when unavailable, so an extension cannot claim | 427 | + | // 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 { | |||
| 78 | 78 | BaseURL string | |
| 79 | 79 | ||
| 80 | 80 | // 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 | |
| 83 | 83 | // explicitly contains "web_search"; that selector enables web_search, | |
| 84 | 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | |
| 85 | 85 | Tools []string | |
| @@ -78,8 +78,8 @@ type Config struct { | |||||
| 78 | BaseURL string | 78 | BaseURL string | ||
| 79 | 79 | ||||
| 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | 80 | // 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 list | 82 | + | // 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 []string | 85 | Tools []string | ||
M packages/agent/systemprompt.go +1 -1
| @@ -99,7 +99,7 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut | |||
| 99 | 99 | ||
| 100 | 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 | 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 | ||
| @@ -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 | + | } | |||