Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/adapters/coding-agent/turn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
import { commandInvocation } from "../../lib/win-exec";
import type { IncomingMeta } from "../base";
Expand All @@ -8,6 +8,9 @@ import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProv
/** Injectable spawn for tests; production uses node:child_process. */
export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;

/** Injectable Windows process-tree terminator; production uses taskkill /T /F. */
export type KillWindowsProcessTreeFn = (pid: number) => void;

/** Per-turn injectables: spawn/which seams for tests plus wall-clock ceilings for timeout, kill grace, and bounded reap. */
export interface CodingAgentDeps {
spawn?: SpawnFn;
Expand All @@ -20,13 +23,23 @@ export interface CodingAgentDeps {
reapTimeoutMs?: number;
/** Test seam for Windows command-shim invocation. */
platform?: NodeJS.Platform;
/** Test seam for terminating a Windows CLI and all descendants. */
killWindowsProcessTree?: KillWindowsProcessTreeFn;
}

const DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_KILL_GRACE_MS = 2_000;
/** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */
const MAX_STDERR_BYTES = 8 * 1024;

function killWindowsProcessTree(pid: number): void {
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve taskkill without trusting SystemRoot

When OpenCodex is launched with a poisoned SystemRoot value, this constructs an absolute-looking path under the attacker-controlled directory and executes its taskkill.exe on the first abort or timeout with the proxy user's privileges and inherited environment. The repository already provides resolveTrustedWindowsTaskkillExe(), backed by GetSystemDirectoryW, specifically to avoid selecting system executables through caller-controlled environment variables; use that resolver or the same trusted-resolution logic here.

AGENTS.md reference: AGENTS.md:L357-L363

Useful? React with 👍 / 👎.

execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], {
stdio: "pipe",
windowsHide: true,
});
}

/** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */
const INHERITED_ENV_KEYS = [
"PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP",
Expand Down Expand Up @@ -93,6 +106,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
const reapTimeoutMs = deps.reapTimeoutMs ?? (killGraceMs * 2 + 250);
const platform = deps.platform ?? process.platform;

if (incoming.abortSignal?.aborted) {
emit({ type: "error", message: "Coding-agent turn was aborted before start." });
Expand Down Expand Up @@ -140,7 +154,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v

const args = buildArgs(profile, parsed, provider);
const env = buildEnv(profile, apiKey);
const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env });
const invocation = commandInvocation(binary, args, platform, { env });

let child: ChildProcess;
try {
Expand Down Expand Up @@ -197,6 +211,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
const kill = (): void => {
if (killed || child.killed) return;
killed = true;
if (platform === "win32" && child.pid !== undefined) {
try {
(deps.killWindowsProcessTree ?? killWindowsProcessTree)(child.pid);
Comment on lines +214 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify the child is still live before invoking taskkill

When the direct child has emitted exit but close is delayed by an inherited stdio handle, child.pid remains populated even though that PID is no longer owned by this ChildProcess. The grace timer can enter this branch after Windows has recycled the PID, causing /T /F to terminate an unrelated process and its descendants; gate the PID-based kill on child.exitCode === null or an explicit exit flag before calling taskkill. Microsoft documents /T as ending the specified process and its child processes.

AGENTS.md reference: src/AGENTS.md:L17-L20

Useful? React with 👍 / 👎.

return;
} catch { /* fall back to terminating the direct child */ }
}
try { child.kill("SIGTERM"); } catch { /* already gone */ }
killTimer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* already gone */ }
Expand Down
36 changes: 36 additions & 0 deletions tests/providers/codebuddy-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const enc = new TextEncoder();
beforeEach(() => clearCodeBuddyBinaryCache());

interface FakeChild extends EventEmitter {
pid?: number;
stdout: Readable;
stderr: Readable;
stdin: Writable;
Expand Down Expand Up @@ -346,6 +347,41 @@ describe("codebuddy runTurn streams a headless turn", () => {
expect(events.some(e => e.type === "done")).toBe(false);
});

test("a Windows abort terminates the cmd shim process tree", async () => {
const controller = new AbortController();
const stdoutStream = new Readable({
read() { setTimeout(() => controller.abort(), 5); },
});
const child = new EventEmitter() as FakeChild;
child.pid = 4242;
child.stdout = stdoutStream;
child.stderr = Readable.from([]);
child.written = [];
child.stdin = new Writable({ write(_c, _e, cb) { cb(); } });
child.killed = false;
child.exitCode = null;
const directSignals: string[] = [];
child.kill = signal => { directSignals.push(signal ?? "SIGTERM"); return true; };
const killedTrees: number[] = [];

const adapter = createCodeBuddyAdapter(provider(), {
platform: "win32",
spawn: () => child as unknown as ChildProcess,
which: () => "C:\\npm\\codebuddy.cmd",
killWindowsProcessTree: pid => {
killedTrees.push(pid);
child.exitCode = 1;
child.emit("close", 1);
},
killGraceMs: 20,
});
const events = await run(adapter, parsed(), incoming(controller.signal));

expect(killedTrees).toEqual([4242]);
expect(directSignals).toEqual([]);
expect(events).toContainEqual(expect.objectContaining({ type: "error", retryable: false }));
});

test("a timeout destroys a stalled stdout stream and returns even when close never arrives", async () => {
const stdoutStream = new Readable({ read() { /* stays open until timeout destroys it */ } });
const child = new EventEmitter() as FakeChild;
Expand Down
Loading