Repository navigation
fix(acp): wrap shell command in bash -c for terminal/create - #2113
kevintruong wants to merge 2 commits into
Conversation
The ACP `Terminal` wrapper that replaces the local `Shell` tool when the client advertises terminal capability was forwarding the LLM's raw shell line as the bare `command` field on `terminal/create`, with `args` omitted entirely. ACP clients follow the spec and exec the request as `Command::new(req.command).args(req.args)`, so any multi-token / quoted / piped command surfaces as a "No such file or directory" failure on the client side — the client looks up a binary literally named `<full shell line>`. The local `Shell` tool already handles this correctly via `_run_shell_command` → `_shell_args(command)` → `(<shell>, "-c", command)`, which is what `kaos.exec` ultimately runs. The ACP wrapper was diverging from that path silently. Fix: have the `Terminal` wrapper reuse the same argv-build logic the local Shell tool uses, then split program/argv into the spec-compliant `command` + `args` fields. Same shell semantics as local execution (pipes, quotes, redirects, `&&`, `;`, etc.) — the difference is just where the shell process spawns (client vs agent). To make the seam explicit and avoid touching private attributes from across class boundaries, the existing `_shell_args` is renamed to a public `shell_argv` (with a back-compat alias kept for one cycle). The new ACP `Terminal` is the only intended caller. Adds `tests/acp/test_terminal_wrapper.py` — six parametrized cases covering single-token, two-token, multi-token-with-args, quoted, and piped commands, plus a `shell_argv` invariance test asserting the ACP and local paths build the same argv. Locks the contract so a future regression flags fast. Verified locally: - `pytest tests/tools/test_shell_bash.py tests/acp/` → 47 passed - Manual probe against an external ACP client: pre-fix 100% of multi- token shell tool calls fail with "No such file or directory"; post-fix same calls succeed identically to running them directly under `kimi`.
…on alias Two follow-up fixes raised in review: 1. Cover the PowerShell `-command` branch end-to-end through `Terminal.__call__` (was previously claimed but only locally verified via `test_shell_powershell.py`, which does not exercise the ACP `create_terminal` payload). The new test monkey-patches `_is_powershell` so it runs on every non-Windows platform without needing a real `pwsh`. Skip-reason on the parametrized POSIX block updated to point at this test instead of overclaiming via the local Shell suite. 2. Convert the `_shell_args = shell_argv` static alias into a wrapper method so dynamic dispatch is preserved if a future subclass overrides `shell_argv`. Behavior unchanged for direct callers. Verification: - `pytest tests/tools/test_shell_bash.py tests/tools/test_shell_powershell.py tests/acp/` → 48 passed, 4 skipped, 0 failed
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e4c64eb06
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| pytestmark = pytest.mark.skipif( | ||
| platform.system() == "Windows", | ||
| reason="POSIX-only — exercises the `bash -c` branch end-to-end. " | ||
| "PowerShell argv build is verified separately by " | ||
| "`test_terminal_wraps_powershell_branch_argv` below, which runs " |
There was a problem hiding this comment.
Remove module-wide Windows skip from terminal wrapper tests
The module-level pytestmark = pytest.mark.skipif(platform.system() == "Windows", ...) skips all tests in this file on Windows, including test_terminal_wraps_powershell_branch_argv; that means the new ACP PowerShell wrapping path is never exercised in Windows CI despite the test comments claiming cross-platform coverage. A regression in the Windows -command branch would therefore ship undetected.
Useful? React with 👍 / 👎.
|
@claude review |
Summary
When
kimi acpis connected to a client that advertises terminal capability, theTerminalACP wrapper atsrc/kimi_cli/acp/tools.pyreplaces the localShelltool and forwards every shell-tool call to the client overterminal/create. It currently does this:The ACP
CreateTerminalRequestspec definescommand: stringas a program path andargs: string[]as a separate argv vector. Conformant clients run the request as the equivalent ofCommand::new(req.command).args(req.args). So when this wrapper packs the LLM's full shell line intocommandand omitsargs, the client tries to spawn a binary literally named<full shell line>and fails withNo such file or directory.This affects every multi-token, quoted, piped, or redirected command — i.e. essentially every real-world shell invocation. Single-token commands like
pwdhappen to work by coincidence becausecommand="pwd"resolves to a real binary path; everything else fails on the first attempt.Root cause: asymmetry with the local
ShelltoolThe local
Shelltool atsrc/kimi_cli/tools/shell/__init__.pyalready does the right thing:So locally, kimi exec()s
bash -c <full shell line>(orpwsh -command …), and the user's host shell parses pipes, quotes, redirects,&&,;, etc. The ACP wrapper diverged from this path — same LLM input, sameShelltool name and schema, but a different (broken) execution model.Fix
Have the ACP
Terminalreuse the localShell's argv-build logic, then split program/argv into the spec-compliant fields:Now the client sees
Command::new("/bin/bash").args(["-c", "<shell-line>"])— semantically identical to the local Shell tool. Pipes, quotes, redirects,&&,;all work the same way they do underkimiinteractive.To make the seam explicit and avoid reaching across private attributes, the existing
_shell_argsis renamed to publicshell_argv(with a back-compat wrapper method retained for one cycle). The wrapper preserves dynamic dispatch — a subclass overridingshell_argvwill be honored via the legacy_shell_argscall too.Empirical reproduction
Tested against an external ACP client that follows the spec strictly (
Command::new(req.command).args(req.args)):pwdecho hellogit rev-parse --short HEADpython3 -c "print(7*8)"cat README.md | head -5Pre-fix: ~3 retries per shell call, all fail, kimi reports "Internal error". Post-fix: same call shape that already worked under
kimiinteractive now works identically over ACP.Reproduction recipe (for maintainers)
A minimal Python ACP client that demonstrates the bug pre-fix and confirms the fix post-fix:
Pre-fix output:
PAYLOAD: {"command": "git rev-parse --short HEAD", ...}— bare shell line, noargs.Post-fix output:
PAYLOAD: {"command": "/bin/bash", "args": ["-c", "git rev-parse --short HEAD"], ...}— proper program/argv split.Test plan
pytest tests/tools/test_shell_bash.py— 20 passed (no regression in local Shell behavior)pytest tests/tools/test_shell_powershell.py— local Shell PowerShell branch unchangedpytest tests/acp/— 21 existing + 7 new passedpytest tests/acp/test_terminal_wrapper.py— 7 new tests:test_shell_argv_helper_matches_local_runasserts ACP and local paths build identical argv (so they can't drift silently)test_terminal_wraps_powershell_branch_argvcovers the-commandbranch end-to-end viaTerminal.__call__(monkey-patches_is_powershellso it runs on every platform without needing a real pwsh)Risks / out of scope
sh -c req.commandworkaround will now see proper argv split. Net effect is strictly safer: the workaround was a security hazard (turns ACP's per-program permission model into shell-string semantics). Note that double-wrapping is benign —Command::new("/bin/bash").args(["-c", "<line>"])invoked under ash -cworkaround becomessh -c "/bin/bash -c '<line>'", which still evaluates correctly becausebash -cis itself idempotent under reentry. No quoting hazards introduced.shell_argv(returns(<pwsh>, "-command", cmd)); now covered end-to-end throughTerminal.__call__bytest_terminal_wraps_powershell_branch_argv._shell_argsretained as a wrapper method (not a static alias) so dynamic dispatch is preserved if a subclass overridesshell_argv. Can be removed in a future cycle once any internal callers migrate.create_terminalcall already acceptscwdandenvkwargs from the ACP schema; this PR doesn't touch those code paths.