From c1e816741dbc19f3cb6a7649c5f33a8b8750bc6a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 10 Jul 2026 18:45:10 -0400 Subject: [PATCH] fix(worker): marked-command survives tail-exec; kill script excludes itself --- apps/agent-worker/src/runtime/docker.ts | 65 ++++++++++++++++--- .../tests/unit/box-process-kill.test.ts | 46 ++++++++++++- ...-07-10-spoon-phase2-runtime-unification.md | 39 +++++++++-- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index c1181ba..e390c9b 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -393,28 +393,75 @@ export const ensureUserContainer = async (args: { const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +// The marker is embedded literally into a `# comment` line and into a `pgrep` +// pattern, so it must not contain shell metacharacters or a newline (either could +// break out of the comment or the pattern). Callers generate it, but validate +// defensively so a malformed marker fails loudly instead of silently corrupting +// the script. +const MARKER_PATTERN = /^[A-Za-z0-9_-]+$/; +const assertMarker = (marker: string) => { + if (!MARKER_PATTERN.test(marker)) { + throw new Error( + `Invalid process marker ${JSON.stringify(marker)}: must match ${String(MARKER_PATTERN)}.`, + ); + } +}; + // Wraps a CLI argv so the process runs as a new session/process-group leader whose // bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT -// `exec` (bash must survive so the marker stays visible); stdout/stderr fds are -// inherited by the CLI so streaming still works. +// `exec` the CLI: bash tail-exec's a final simple command, which would replace the +// marker-carrying bash with the CLI and make `pgrep -f ` miss the running +// turn. Keeping a trailing `rc=$?; exit "$rc"` after the CLI means the CLI is no +// longer the script's final statement, so bash survives as the group leader while +// still propagating the CLI's exit code (downstream `normalizeRunResult` relies on +// it — a bare trailing `wait` would mask a non-zero CLI failure as 0). The CLI runs +// in the same process group as this bash, so the kill helper's group signal reaches +// it. stdout/stderr fds are inherited by the CLI so streaming still works. export const buildMarkedCommand = ( marker: string, command: string[], ): string[] => { - const script = `# ${marker}\nexec 0 - [ +// The negative pid (`kill -TERM -"$pgid"`) targets the whole process group, so the +// CLI and any children it spawned die together. +// +// pgrep self-match: this kill script runs via `bash -lc