Skip to content

refactor(core): route external commands through CommandExecutor seam …#9

Merged
wusijian007 merged 1 commit into
mainfrom
feat/m2.1-executor-seam
May 15, 2026
Merged

refactor(core): route external commands through CommandExecutor seam …#9
wusijian007 merged 1 commit into
mainfrom
feat/m2.1-executor-seam

Conversation

@wusijian007

Copy link
Copy Markdown
Owner

…(M2.1)

Pure refactor. No user-visible behavior change. Sets up the injection point that future sandbox runtimes (Docker, Firecracker, WSL...) will plug into without touching any existing tool call site.

Surface introduced:

packages/core/src/executor.ts
type CommandExecutor = { run(input): Promise }
function createSpawnExecutor() // default: child_process.spawn

packages/core/src/types.ts
ToolContext.executor?: CommandExecutor // optional, falls back to
// createSpawnExecutor()

The result-Promise shape (rather than returning a ChildProcess-like handle) is what makes mock executors trivial in tests: { run: async () => ({ stdout, stderr, exitCode, timedOut }) }.

ExecutorRunInput.outputSink covers both call sites' needs:

  • { kind: "capture", maxBytes } for the Bash tool (collects up to MAX_BASH_OUTPUT_CHARS in memory, truncates mid-stream)
  • { kind: "fd", fd } for the task worker (pipes stdout+stderr directly to the task output file so long-running commands don't balloon the heap)

Migration scope (intentionally narrow):

IN:
- packages/tools/src/index.ts runSpawnedCommand (Bash tool)
- packages/core/src/task.ts runSpawnedReadOnlyCommand (task worker)

OUT (each for a specific reason):
- packages/core/src/hooks.ts spawn — hooks use shell:true and have exit-code-2 blocking semantics that are hook-pipeline-specific; mixing into the executor would conflate two different contracts.
- packages/cli/src/index.ts startDetachedTaskWorker — spawns myagent's own binary detached for background task workers; that's process control flow, not external command execution.

The CLI's runAgentTurn injects createSpawnExecutor() into the ToolContext so the default for every real agent run is unchanged.

Tests added in packages/core/test/security/executor-seam.test.ts (8 cases): smoke for the real spawn executor (stdout capture, abort, timeout), routing proof for the task worker via a recording mock, pid recording via the onPid callback, and a negative assertion that Bash builtins (pwd/ls/cat/find) deliberately bypass the seam while spawn commands (git/rg/grep) do route through.

Removed: the inline runSpawnedCommand promise wrangling in packages/tools/src/index.ts and the parallel block in packages/core/src/task.ts. Both are now ~10 lines of executor.run() each. Unused capString helper deleted from tools/src/index.ts (now lives inside the default executor).

Verified locally: 168 tests, 3/3 stable runs.

…(M2.1)

Pure refactor. No user-visible behavior change. Sets up the injection
point that future sandbox runtimes (Docker, Firecracker, WSL...) will
plug into without touching any existing tool call site.

Surface introduced:

  packages/core/src/executor.ts
    type CommandExecutor = { run(input): Promise<ExecutorRunResult> }
    function createSpawnExecutor()    // default: child_process.spawn

  packages/core/src/types.ts
    ToolContext.executor?: CommandExecutor   // optional, falls back to
                                             // createSpawnExecutor()

The result-Promise shape (rather than returning a ChildProcess-like
handle) is what makes mock executors trivial in tests:
`{ run: async () => ({ stdout, stderr, exitCode, timedOut }) }`.

ExecutorRunInput.outputSink covers both call sites' needs:

  - `{ kind: "capture", maxBytes }` for the Bash tool (collects up to
    MAX_BASH_OUTPUT_CHARS in memory, truncates mid-stream)
  - `{ kind: "fd", fd }` for the task worker (pipes stdout+stderr
    directly to the task output file so long-running commands don't
    balloon the heap)

Migration scope (intentionally narrow):

  IN:
    - packages/tools/src/index.ts  runSpawnedCommand        (Bash tool)
    - packages/core/src/task.ts    runSpawnedReadOnlyCommand (task worker)

  OUT (each for a specific reason):
    - packages/core/src/hooks.ts spawn — hooks use shell:true and have
      exit-code-2 blocking semantics that are hook-pipeline-specific;
      mixing into the executor would conflate two different contracts.
    - packages/cli/src/index.ts startDetachedTaskWorker — spawns
      myagent's own binary detached for background task workers; that's
      process control flow, not external command execution.

The CLI's runAgentTurn injects createSpawnExecutor() into the ToolContext
so the default for every real agent run is unchanged.

Tests added in packages/core/test/security/executor-seam.test.ts (8
cases): smoke for the real spawn executor (stdout capture, abort,
timeout), routing proof for the task worker via a recording mock,
pid recording via the onPid callback, and a negative assertion that
Bash builtins (pwd/ls/cat/find) deliberately bypass the seam while
spawn commands (git/rg/grep) do route through.

Removed: the inline runSpawnedCommand promise wrangling in
packages/tools/src/index.ts and the parallel block in
packages/core/src/task.ts. Both are now ~10 lines of executor.run()
each. Unused `capString` helper deleted from tools/src/index.ts (now
lives inside the default executor).

Verified locally: 168 tests, 3/3 stable runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@wusijian007 wusijian007 merged commit b1d9c6b into main May 15, 2026
3 checks passed
@wusijian007 wusijian007 deleted the feat/m2.1-executor-seam branch May 15, 2026 01:25
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