Skip to content

fix(terminal): run unsplit command lines through the system shell [AI-assisted]#313

Open
xdjyxu wants to merge 1 commit into
openclaw:mainfrom
xdjyxu:fix/terminal-shell-wrap-when-args-empty
Open

fix(terminal): run unsplit command lines through the system shell [AI-assisted]#313
xdjyxu wants to merge 1 commit into
openclaw:mainfrom
xdjyxu:fix/terminal-shell-wrap-when-args-empty

Conversation

@xdjyxu
Copy link
Copy Markdown

@xdjyxu xdjyxu commented May 12, 2026

Problem

When an ACP agent sends terminal/create with the entire shell command line in the command field and no args, acpx fails with:

spawn ls -la /Users/foo/ ENOENT

This is because terminal-manager.ts does:

spawn(params.command, params.args ?? [], options);

so the whole string \"ls -la /Users/foo/\" is treated as an executable name.

This affects multiple real-world agents that don't pre-split their Bash tool input, including:

Why other ACP clients aren't affected

Zed always wraps the request through its ShellBuilder regardless of args shape:

https://github.com/zed-industries/zed/blob/main/crates/acp_thread/src/acp_thread.rs (search for ShellBuilder::new(&Shell::Program(shell), is_windows) near create_terminal_entity)

So those agents "just work" against Zed and never hit this code path. acpx's stricter literal interpretation of the schema is the outlier in practice.

Fix

Introduce buildShellExec(command, args, platform) in src/spawn-command-options.ts:

  • args non-empty → return {command, args} unchanged. Honor the literal ACP contract; well-behaved agents stay on the existing direct-spawn fast path with zero behavior change.
  • args empty/missing → return {command: \"/bin/sh\", args: [\"-c\", command]} on Unix, or {command: \"cmd.exe\", args: [\"/d\", \"/s\", \"/c\", command]} on Windows.

terminal-manager.ts calls buildShellExec before spawn(). The existing Windows .bat/.cmd auto-shell path in buildSpawnCommandOptions is unaffected — it still triggers for the legitimate "command is a batch file, args is the argv" case.

Why /bin/sh not bash -lc

  • ACP doesn't ask for login-shell semantics (~/.zshrc, ~/.bash_profile); environment should come from params.env, not the operator's dotfiles.
  • /bin/sh is universally available; bash may not be in containers.
  • Matches Zed's Shell::System choice in spirit.

Why "always wrap when args empty" rather than "detect metacharacters"

A heuristic like "only wrap if command contains | && ; > * …" would misfire on the most common case in the wild — \"ls -la /path\", which is plain whitespace with no metacharacters and would still ENOENT. Routing all empty-args requests through the shell is simpler, has no false negatives, and at most adds one shell fork for bare commands like \"ls\".

Permission prompt

Unaffected. toCommandLine(params.command, params.args) still computes the user-facing display string from the original request, so the prompt shows \"ls -la /Users/foo/\" not \"/bin/sh -c ls -la /Users/foo/\".

Tests

  • test/spawn-options.test.ts: 4 new cases for buildShellExec (non-empty args passthrough, Unix wrapping, Windows wrapping, bare command).
  • test/terminal.test.ts: 2 new end-to-end cases — one for \"echo hello-from-shell\" (whitespace only), one for a pipeline (printf … | wc -l). Both skip on Windows where the POSIX-shell path doesn't apply.
  • All 608 existing tests still pass: pnpm test# pass 608 # fail 0.
  • pnpm typecheck, pnpm lint, and pnpm format:check (on the changed files) all clean.

Compatibility

  • Well-behaved agents (command: \"ls\", args: [\"-la\"]): unchanged. args.length > 0 short-circuits to direct spawn.
  • Raw-shell-line agents (command: \"ls -la /path\", no args): now work via /bin/sh -c.
  • Windows batch wrappers (command: \"npx\", args: [\"create-foo\"]): unchanged. buildSpawnCommandOptions still detects the resolved .cmd/.bat and adds shell: true.

The only theoretical break is an agent that intentionally sends command: \"/path/with spaces/exe\" with empty args expecting direct spawn. That's already an awkward request shape under the schema (args exists for exactly this kind of thing) and I haven't found such a caller in the wild.

AI-assisted disclosure

  • AI-assisted (CodeBuddy / Claude). Marked in title.
  • Lightly tested: full pnpm test suite passes locally on macOS arm64. Have not exercised the Windows cmd.exe /d /s /c path on a real Windows machine, but the unit test covers the argv shape.
  • I understand what the code does and reviewed every line.
  • Will resolve any bot review conversations after addressing them.

The session that produced this PR was a back-and-forth investigation: dumping ACP history JSONL to see the actual failing requests, comparing claude-code-acp's command: input.command pattern with Zed's ShellBuilder wrap, then reading the acpx terminal-manager + spawn-command-options to design the fix.

When an ACP agent sends terminal/create with the entire shell command line
in the `command` field and no `args` (the request shape used by Claude
Code, codebuddy, and other agents that don't pre-split their Bash tool
input), Node's `spawn()` treats the whole string as an executable name
and fails with `spawn ... ENOENT`.

Other ACP clients — notably Zed via its ShellBuilder — handle this by
always running the request through the system shell, so those agents work
out of the box.

Match the same de facto behavior: when `args` is empty, route the
command through `/bin/sh -c <command>` on Unix or `cmd.exe /d /s /c
<command>` on Windows. When `args` is non-empty, honor the literal ACP
contract and spawn the executable with the provided argv unchanged. This
keeps well-behaved agents on the existing direct-spawn fast path while
letting raw-shell-line agents work transparently.

The Windows .bat/.cmd auto-shell path in buildSpawnCommandOptions is
unaffected: it still triggers when an agent legitimately passes a batch
file as `command` plus a non-empty argv.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant