baa-conductor

git clone 

commit
56fe5ff
parent
d9df207
author
im_wower
date
2026-03-22 21:10:57 +0800 CST
feat(codex-exec): add fallback exec adapter
9 files changed,  +1190, -1
M docs/runtime/codexd.md
+15, -1
 1@@ -7,7 +7,10 @@
 2 当前状态:
 3 
 4 - 这是设计文档
 5-- 仓库里还没有 `codexd` 的实际实现
 6+- 仓库里还没有 `codexd` 常驻进程或对外服务实现
 7+- 已有两个底层适配包:
 8+  - `packages/codex-app-server`: 面向未来主会话 / 双工能力
 9+  - `packages/codex-exec`: 面向 smoke、简单 worker 和降级路径的一次性调用
10 
11 ## 目标
12 
13@@ -204,11 +207,21 @@
14 ### v1 兜底能力
15 
16 - 保留一个 `exec` 适配器
17+- 当前仓库里的落点是 `packages/codex-exec`
18 - 只做:
19   - 健康检查
20   - 最小 smoke
21   - 简单一次性 worker 调用
22   - app-server 不可用时的降级路径
23+- 适配层当前只覆盖:
24+  - 一次运行一个 `codex exec`
25+  - 收集 stdout / stderr
26+  - 返回 exit code、timeout、last message 和可选 JSONL 事件
27+- 它不负责:
28+  - session / thread 生命周期
29+  - 多轮对话
30+  - interrupt / steer
31+  - 持久化双工事件桥
32 
33 ### v2
34 
35@@ -266,6 +279,7 @@
36 
37 - `app-server` 是主能力面
38 - `exec` 不是主会话系统,只是简单调用和测试工具
39+- `packages/codex-exec` 只是一个兜底层,不应被扩成主双工实现
40 
41 不要把当前系统误认为已经有:
42 
A packages/codex-exec/package.json
+16, -0
 1@@ -0,0 +1,16 @@
 2+{
 3+  "name": "@baa-conductor/codex-exec",
 4+  "private": true,
 5+  "type": "module",
 6+  "main": "dist/index.js",
 7+  "exports": {
 8+    ".": "./dist/index.js"
 9+  },
10+  "types": "dist/index.d.ts",
11+  "scripts": {
12+    "build": "pnpm exec tsc -p tsconfig.json",
13+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
14+    "test": "pnpm run build && node --test src/index.test.js",
15+    "smoke": "pnpm run build && node --test src/smoke.test.js"
16+  }
17+}
A packages/codex-exec/src/contracts.ts
+103, -0
  1@@ -0,0 +1,103 @@
  2+export const CODEX_EXEC_PURPOSES = ["smoke", "simple-worker", "fallback-worker"] as const;
  3+export const CODEX_EXEC_SANDBOX_MODES = [
  4+  "read-only",
  5+  "workspace-write",
  6+  "danger-full-access"
  7+] as const;
  8+export const CODEX_EXEC_COLOR_MODES = ["always", "never", "auto"] as const;
  9+export const CODEX_EXEC_ERROR_CODES = [
 10+  "CODEX_EXEC_INVALID_INPUT",
 11+  "CODEX_EXEC_SPAWN_FAILED",
 12+  "CODEX_EXEC_TIMEOUT",
 13+  "CODEX_EXEC_EXIT_NON_ZERO"
 14+] as const;
 15+export const DEFAULT_CODEX_EXEC_CLI_PATH = "codex";
 16+export const DEFAULT_CODEX_EXEC_COLOR_MODE = "never" as const;
 17+export const DEFAULT_CODEX_EXEC_TIMEOUT_MS = 5 * 60_000;
 18+
 19+export type CodexExecPurpose = (typeof CODEX_EXEC_PURPOSES)[number];
 20+export type CodexExecSandboxMode = (typeof CODEX_EXEC_SANDBOX_MODES)[number];
 21+export type CodexExecColorMode = (typeof CODEX_EXEC_COLOR_MODES)[number];
 22+export type CodexExecErrorCode = (typeof CODEX_EXEC_ERROR_CODES)[number];
 23+export type CodexExecErrorDetailValue = boolean | number | null | string;
 24+export type JsonPrimitive = boolean | number | null | string;
 25+export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
 26+
 27+export interface CodexExecError {
 28+  code: CodexExecErrorCode;
 29+  message: string;
 30+  retryable: boolean;
 31+  details?: Record<string, CodexExecErrorDetailValue>;
 32+}
 33+
 34+export interface CodexExecRunRequest {
 35+  prompt: string;
 36+  purpose?: CodexExecPurpose;
 37+  cwd?: string;
 38+  cliPath?: string;
 39+  timeoutMs?: number;
 40+  model?: string;
 41+  profile?: string;
 42+  sandbox?: CodexExecSandboxMode;
 43+  skipGitRepoCheck?: boolean;
 44+  json?: boolean;
 45+  ephemeral?: boolean;
 46+  color?: CodexExecColorMode;
 47+  config?: string[];
 48+  additionalWritableDirectories?: string[];
 49+  images?: string[];
 50+  env?: Record<string, string | undefined>;
 51+}
 52+
 53+export interface CodexExecInvocation {
 54+  command: string;
 55+  args: string[];
 56+  cwd: string;
 57+  prompt: string;
 58+  purpose: CodexExecPurpose;
 59+  timeoutMs: number;
 60+  color: CodexExecColorMode;
 61+  json: boolean;
 62+  ephemeral: boolean;
 63+  skipGitRepoCheck: boolean;
 64+  model?: string;
 65+  profile?: string;
 66+  sandbox?: CodexExecSandboxMode;
 67+  config: string[];
 68+  additionalWritableDirectories: string[];
 69+  images: string[];
 70+}
 71+
 72+export interface CodexExecJsonParseError {
 73+  line: number;
 74+  message: string;
 75+}
 76+
 77+export interface CodexExecRunResult {
 78+  durationMs: number;
 79+  exitCode: number | null;
 80+  finishedAt: string;
 81+  jsonEvents: JsonValue[] | null;
 82+  jsonParseErrors: CodexExecJsonParseError[];
 83+  lastMessage: string | null;
 84+  signal: string | null;
 85+  startedAt: string;
 86+  stderr: string;
 87+  stdout: string;
 88+  timedOut: boolean;
 89+}
 90+
 91+export interface CodexExecRunSuccess {
 92+  ok: true;
 93+  invocation: CodexExecInvocation;
 94+  result: CodexExecRunResult;
 95+}
 96+
 97+export interface CodexExecRunFailure {
 98+  ok: false;
 99+  error: CodexExecError;
100+  invocation?: CodexExecInvocation;
101+  result?: CodexExecRunResult;
102+}
103+
104+export type CodexExecRunResponse = CodexExecRunSuccess | CodexExecRunFailure;
A packages/codex-exec/src/index.test.js
+205, -0
  1@@ -0,0 +1,205 @@
  2+import assert from "node:assert/strict";
  3+import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import test from "node:test";
  7+
  8+import { runCodexExec } from "../dist/index.js";
  9+
 10+async function createFakeCodexCli() {
 11+  const directoryPath = await mkdtemp(join(tmpdir(), "baa-codex-exec-cli-"));
 12+  const cliPath = join(directoryPath, "codex");
 13+  const source = `#!/usr/bin/env node
 14+import { writeFileSync } from "node:fs";
 15+
 16+const args = process.argv.slice(2);
 17+const delayMs = Number(process.env.FAKE_CODEX_DELAY_MS ?? "0");
 18+const exitCode = Number(process.env.FAKE_CODEX_EXIT_CODE ?? "0");
 19+const stderr = process.env.FAKE_CODEX_STDERR ?? "";
 20+const stdout = process.env.FAKE_CODEX_STDOUT ?? "";
 21+const outputLastMessageIndex = args.indexOf("--output-last-message");
 22+const outputLastMessagePath =
 23+  outputLastMessageIndex === -1 ? null : args[outputLastMessageIndex + 1];
 24+
 25+if (delayMs > 0) {
 26+  await new Promise((resolve) => setTimeout(resolve, delayMs));
 27+}
 28+
 29+if (outputLastMessagePath) {
 30+  writeFileSync(
 31+    outputLastMessagePath,
 32+    process.env.FAKE_CODEX_LAST_MESSAGE ?? "fake final message\\n"
 33+  );
 34+}
 35+
 36+if (stdout !== "") {
 37+  process.stdout.write(stdout);
 38+} else if (args.includes("--json")) {
 39+  process.stdout.write(
 40+    JSON.stringify({
 41+      type: "started",
 42+      payload: {
 43+        args,
 44+        cwd: process.cwd(),
 45+        prompt: args.at(-1) ?? null
 46+      }
 47+    }) + "\\n"
 48+  );
 49+  process.stdout.write(JSON.stringify({ type: "completed" }) + "\\n");
 50+} else {
 51+  process.stdout.write("plain stdout\\n");
 52+}
 53+
 54+if (stderr !== "") {
 55+  process.stderr.write(stderr);
 56+}
 57+
 58+process.exit(exitCode);
 59+`;
 60+
 61+  await writeFile(cliPath, source, "utf8");
 62+  await chmod(cliPath, 0o755);
 63+
 64+  return {
 65+    cliPath,
 66+    directoryPath
 67+  };
 68+}
 69+
 70+async function createWorkspace() {
 71+  return await mkdtemp(join(tmpdir(), "baa-codex-exec-workspace-"));
 72+}
 73+
 74+test("runCodexExec executes a one-shot fallback worker request and returns structured output", async (t) => {
 75+  const fakeCli = await createFakeCodexCli();
 76+  const workspacePath = await createWorkspace();
 77+
 78+  t.after(async () => {
 79+    await rm(fakeCli.directoryPath, { force: true, recursive: true });
 80+    await rm(workspacePath, { force: true, recursive: true });
 81+  });
 82+
 83+  const response = await runCodexExec({
 84+    purpose: "fallback-worker",
 85+    prompt: "Summarize the current diff.",
 86+    cwd: workspacePath,
 87+    cliPath: fakeCli.cliPath,
 88+    timeoutMs: 1_000,
 89+    model: "gpt-5.4",
 90+    profile: "worker",
 91+    sandbox: "read-only",
 92+    skipGitRepoCheck: true,
 93+    json: true,
 94+    ephemeral: true,
 95+    config: ['model_reasoning_effort="medium"'],
 96+    additionalWritableDirectories: [join(workspacePath, "artifacts")],
 97+    images: [join(workspacePath, "diagram.png")],
 98+    env: {
 99+      FAKE_CODEX_LAST_MESSAGE: "worker completed\n",
100+      FAKE_CODEX_STDERR: "simulated warning\n"
101+    }
102+  });
103+
104+  assert.equal(response.ok, true);
105+  assert.deepEqual(response.invocation.args, [
106+    "exec",
107+    "--cd",
108+    workspacePath,
109+    "--color",
110+    "never",
111+    "--model",
112+    "gpt-5.4",
113+    "--profile",
114+    "worker",
115+    "--sandbox",
116+    "read-only",
117+    "--skip-git-repo-check",
118+    "--json",
119+    "--ephemeral",
120+    "--config",
121+    'model_reasoning_effort="medium"',
122+    "--add-dir",
123+    join(workspacePath, "artifacts"),
124+    "--image",
125+    join(workspacePath, "diagram.png"),
126+    "Summarize the current diff."
127+  ]);
128+  assert.equal(response.result.exitCode, 0);
129+  assert.equal(response.result.timedOut, false);
130+  assert.equal(response.result.lastMessage, "worker completed\n");
131+  assert.equal(response.result.stderr, "simulated warning\n");
132+  assert.equal(response.result.jsonParseErrors.length, 0);
133+  assert.equal(response.result.jsonEvents?.length, 2);
134+  assert.equal(response.result.jsonEvents?.[0].type, "started");
135+  assert.equal(response.result.jsonEvents?.[0].payload.prompt, "Summarize the current diff.");
136+  assert.equal(
137+    response.result.jsonEvents?.[0].payload.args.includes("--output-last-message"),
138+    true
139+  );
140+});
141+
142+test("runCodexExec reports non-zero exits without dropping stdout or stderr", async (t) => {
143+  const fakeCli = await createFakeCodexCli();
144+  const workspacePath = await createWorkspace();
145+
146+  t.after(async () => {
147+    await rm(fakeCli.directoryPath, { force: true, recursive: true });
148+    await rm(workspacePath, { force: true, recursive: true });
149+  });
150+
151+  const response = await runCodexExec({
152+    purpose: "simple-worker",
153+    prompt: "Return an error.",
154+    cwd: workspacePath,
155+    cliPath: fakeCli.cliPath,
156+    timeoutMs: 1_000,
157+    env: {
158+      FAKE_CODEX_EXIT_CODE: "17",
159+      FAKE_CODEX_STDERR: "fatal\n"
160+    }
161+  });
162+
163+  assert.equal(response.ok, false);
164+  assert.equal(response.error.code, "CODEX_EXEC_EXIT_NON_ZERO");
165+  assert.equal(response.invocation?.purpose, "simple-worker");
166+  assert.equal(response.result?.exitCode, 17);
167+  assert.equal(response.result?.timedOut, false);
168+  assert.equal(response.result?.stderr, "fatal\n");
169+  assert.equal(response.result?.stdout, "plain stdout\n");
170+});
171+
172+test("runCodexExec reports timeouts for hung fallback processes", async (t) => {
173+  const fakeCli = await createFakeCodexCli();
174+  const workspacePath = await createWorkspace();
175+
176+  t.after(async () => {
177+    await rm(fakeCli.directoryPath, { force: true, recursive: true });
178+    await rm(workspacePath, { force: true, recursive: true });
179+  });
180+
181+  const response = await runCodexExec({
182+    purpose: "fallback-worker",
183+    prompt: "Sleep too long.",
184+    cwd: workspacePath,
185+    cliPath: fakeCli.cliPath,
186+    timeoutMs: 50,
187+    env: {
188+      FAKE_CODEX_DELAY_MS: "5000"
189+    }
190+  });
191+
192+  assert.equal(response.ok, false);
193+  assert.equal(response.error.code, "CODEX_EXEC_TIMEOUT");
194+  assert.equal(response.result?.timedOut, true);
195+  assert.equal(response.result?.exitCode, null);
196+  assert.equal(response.result?.signal, "SIGTERM");
197+});
198+
199+test("runCodexExec rejects stdin-style prompts because the fallback adapter is one-shot only", async () => {
200+  const response = await runCodexExec({
201+    prompt: "-"
202+  });
203+
204+  assert.equal(response.ok, false);
205+  assert.equal(response.error.code, "CODEX_EXEC_INVALID_INPUT");
206+});
A packages/codex-exec/src/index.ts
+2, -0
1@@ -0,0 +1,2 @@
2+export * from "./contracts.js";
3+export * from "./runner.js";
A packages/codex-exec/src/node-shims.d.ts
+61, -0
 1@@ -0,0 +1,61 @@
 2+declare namespace NodeJS {
 3+  interface ProcessEnv {
 4+    [key: string]: string | undefined;
 5+  }
 6+}
 7+
 8+declare interface BaaCodexExecReadableStream {
 9+  on(event: "data", listener: (chunk: string | Uint8Array) => void): void;
10+  setEncoding(encoding: string): void;
11+}
12+
13+declare interface BaaCodexExecChildProcess {
14+  kill(signal?: string): boolean;
15+  once(event: "error", listener: (error: Error) => void): void;
16+  once(
17+    event: "close",
18+    listener: (exitCode: number | null, signal: string | null) => void
19+  ): void;
20+  stderr: BaaCodexExecReadableStream | null;
21+  stdout: BaaCodexExecReadableStream | null;
22+}
23+
24+declare module "node:child_process" {
25+  export function spawn(
26+    command: string,
27+    args?: string[],
28+    options?: {
29+      cwd?: string;
30+      env?: NodeJS.ProcessEnv;
31+      stdio?: string[];
32+    }
33+  ): BaaCodexExecChildProcess;
34+}
35+
36+declare module "node:fs/promises" {
37+  export function mkdtemp(prefix: string): Promise<string>;
38+  export function readFile(path: string, encoding: "utf8"): Promise<string>;
39+  export function rm(
40+    path: string,
41+    options: {
42+      force?: boolean;
43+      recursive?: boolean;
44+    }
45+  ): Promise<void>;
46+}
47+
48+declare module "node:os" {
49+  export function tmpdir(): string;
50+}
51+
52+declare module "node:path" {
53+  export function join(...paths: string[]): string;
54+}
55+
56+declare const process: {
57+  cwd(): string;
58+  env: NodeJS.ProcessEnv;
59+};
60+
61+declare function clearTimeout(handle: number | undefined): void;
62+declare function setTimeout(callback: () => void, delay?: number): number;
A packages/codex-exec/src/runner.ts
+744, -0
  1@@ -0,0 +1,744 @@
  2+import { spawn } from "node:child_process";
  3+import { mkdtemp, readFile, rm } from "node:fs/promises";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import {
  7+  CODEX_EXEC_COLOR_MODES,
  8+  CODEX_EXEC_PURPOSES,
  9+  CODEX_EXEC_SANDBOX_MODES,
 10+  DEFAULT_CODEX_EXEC_CLI_PATH,
 11+  DEFAULT_CODEX_EXEC_COLOR_MODE,
 12+  DEFAULT_CODEX_EXEC_TIMEOUT_MS,
 13+  type CodexExecColorMode,
 14+  type CodexExecError,
 15+  type CodexExecInvocation,
 16+  type CodexExecJsonParseError,
 17+  type CodexExecRunFailure,
 18+  type CodexExecRunRequest,
 19+  type CodexExecRunResponse,
 20+  type CodexExecRunResult,
 21+  type CodexExecSandboxMode,
 22+  type JsonValue
 23+} from "./contracts.js";
 24+
 25+const FORCE_KILL_AFTER_TIMEOUT_MS = 1_000;
 26+
 27+interface NormalizedCodexExecRunRequest {
 28+  additionalWritableDirectories: string[];
 29+  cliPath: string;
 30+  color: CodexExecColorMode;
 31+  config: string[];
 32+  cwd: string;
 33+  env: Record<string, string | undefined>;
 34+  ephemeral: boolean;
 35+  images: string[];
 36+  json: boolean;
 37+  model?: string;
 38+  profile?: string;
 39+  prompt: string;
 40+  purpose: CodexExecInvocation["purpose"];
 41+  sandbox?: CodexExecSandboxMode;
 42+  skipGitRepoCheck: boolean;
 43+  timeoutMs: number;
 44+}
 45+
 46+interface NormalizedCodexExecRunRequestSuccess {
 47+  ok: true;
 48+  request: NormalizedCodexExecRunRequest;
 49+}
 50+
 51+interface NormalizedCodexExecRunRequestFailure {
 52+  ok: false;
 53+  response: CodexExecRunFailure;
 54+}
 55+
 56+type NormalizedCodexExecRunRequestResult =
 57+  | NormalizedCodexExecRunRequestSuccess
 58+  | NormalizedCodexExecRunRequestFailure;
 59+
 60+interface SpawnCodexExecOutcome {
 61+  exitCode: number | null;
 62+  signal: string | null;
 63+  spawnError?: Error;
 64+  stderr: string;
 65+  stdout: string;
 66+  timedOut: boolean;
 67+}
 68+
 69+interface LastMessageCapture {
 70+  directoryPath: string;
 71+  filePath: string;
 72+}
 73+
 74+function isNonEmptyString(value: unknown): value is string {
 75+  return typeof value === "string" && value.trim() !== "";
 76+}
 77+
 78+function isRecord(value: unknown): value is Record<string, unknown> {
 79+  return typeof value === "object" && value !== null;
 80+}
 81+
 82+function isOneOf<TValue extends string>(
 83+  value: string,
 84+  allowedValues: readonly TValue[]
 85+): value is TValue {
 86+  return allowedValues.includes(value as TValue);
 87+}
 88+
 89+function toErrorMessage(cause: unknown, fallback: string): string {
 90+  if (cause instanceof Error && cause.message !== "") {
 91+    return cause.message;
 92+  }
 93+
 94+  if (typeof cause === "string" && cause !== "") {
 95+    return cause;
 96+  }
 97+
 98+  return fallback;
 99+}
100+
101+function createFailure(
102+  error: CodexExecError,
103+  invocation?: CodexExecInvocation,
104+  result?: CodexExecRunResult
105+): CodexExecRunFailure {
106+  return {
107+    ok: false,
108+    error,
109+    invocation,
110+    result
111+  };
112+}
113+
114+function isFailureResponse(value: unknown): value is CodexExecRunFailure {
115+  return (
116+    isRecord(value) &&
117+    value.ok === false &&
118+    isRecord(value.error) &&
119+    typeof value.error.code === "string"
120+  );
121+}
122+
123+function normalizeOptionalString(value: unknown, fieldName: string): string | CodexExecRunFailure {
124+  if (value === undefined) {
125+    return "";
126+  }
127+
128+  if (isNonEmptyString(value)) {
129+    return value.trim();
130+  }
131+
132+  return createFailure({
133+    code: "CODEX_EXEC_INVALID_INPUT",
134+    message: `${fieldName} must be a non-empty string when provided.`,
135+    retryable: false
136+  });
137+}
138+
139+function normalizeStringArray(
140+  value: unknown,
141+  fieldName: string
142+): string[] | CodexExecRunFailure {
143+  if (value === undefined) {
144+    return [];
145+  }
146+
147+  if (!Array.isArray(value)) {
148+    return createFailure({
149+      code: "CODEX_EXEC_INVALID_INPUT",
150+      message: `${fieldName} must be an array of non-empty strings when provided.`,
151+      retryable: false
152+    });
153+  }
154+
155+  const normalized: string[] = [];
156+
157+  for (const item of value) {
158+    if (!isNonEmptyString(item)) {
159+      return createFailure({
160+        code: "CODEX_EXEC_INVALID_INPUT",
161+        message: `${fieldName} must contain only non-empty strings.`,
162+        retryable: false
163+      });
164+    }
165+
166+    normalized.push(item.trim());
167+  }
168+
169+  return normalized;
170+}
171+
172+function normalizeEnvMap(
173+  value: unknown
174+): Record<string, string | undefined> | CodexExecRunFailure {
175+  if (value === undefined) {
176+    return {};
177+  }
178+
179+  if (!isRecord(value)) {
180+    return createFailure({
181+      code: "CODEX_EXEC_INVALID_INPUT",
182+      message: "env must be an object mapping variable names to strings or undefined.",
183+      retryable: false
184+    });
185+  }
186+
187+  const normalized: Record<string, string | undefined> = {};
188+
189+  for (const [key, envValue] of Object.entries(value)) {
190+    if (!isNonEmptyString(key)) {
191+      return createFailure({
192+        code: "CODEX_EXEC_INVALID_INPUT",
193+        message: "env variable names must be non-empty strings.",
194+        retryable: false
195+      });
196+    }
197+
198+    if (envValue !== undefined && typeof envValue !== "string") {
199+      return createFailure({
200+        code: "CODEX_EXEC_INVALID_INPUT",
201+        message: `env.${key} must be a string or undefined.`,
202+        retryable: false
203+      });
204+    }
205+
206+    normalized[key] = envValue;
207+  }
208+
209+  return normalized;
210+}
211+
212+function normalizeRunRequest(request: CodexExecRunRequest): NormalizedCodexExecRunRequestResult {
213+  const prompt = isNonEmptyString(request.prompt) ? request.prompt.trim() : "";
214+
215+  if (prompt === "") {
216+    return {
217+      ok: false,
218+      response: createFailure({
219+        code: "CODEX_EXEC_INVALID_INPUT",
220+        message: "prompt must be a non-empty string.",
221+        retryable: false
222+      })
223+    };
224+  }
225+
226+  if (prompt === "-") {
227+    return {
228+      ok: false,
229+      response: createFailure({
230+        code: "CODEX_EXEC_INVALID_INPUT",
231+        message: "prompt '-' is not supported by the fallback adapter because it does not stream stdin.",
232+        retryable: false
233+      })
234+    };
235+  }
236+
237+  const cliPathValue = normalizeOptionalString(request.cliPath, "cliPath");
238+
239+  if (cliPathValue && typeof cliPathValue !== "string") {
240+    return {
241+      ok: false,
242+      response: cliPathValue
243+    };
244+  }
245+
246+  const cwdValue = normalizeOptionalString(request.cwd, "cwd");
247+
248+  if (cwdValue && typeof cwdValue !== "string") {
249+    return {
250+      ok: false,
251+      response: cwdValue
252+    };
253+  }
254+
255+  const modelValue = normalizeOptionalString(request.model, "model");
256+
257+  if (modelValue && typeof modelValue !== "string") {
258+    return {
259+      ok: false,
260+      response: modelValue
261+    };
262+  }
263+
264+  const profileValue = normalizeOptionalString(request.profile, "profile");
265+
266+  if (profileValue && typeof profileValue !== "string") {
267+    return {
268+      ok: false,
269+      response: profileValue
270+    };
271+  }
272+
273+  const configValue = normalizeStringArray(request.config, "config");
274+
275+  if (!Array.isArray(configValue)) {
276+    return {
277+      ok: false,
278+      response: configValue
279+    };
280+  }
281+
282+  const additionalWritableDirectoriesValue = normalizeStringArray(
283+    request.additionalWritableDirectories,
284+    "additionalWritableDirectories"
285+  );
286+
287+  if (!Array.isArray(additionalWritableDirectoriesValue)) {
288+    return {
289+      ok: false,
290+      response: additionalWritableDirectoriesValue
291+    };
292+  }
293+
294+  const imagesValue = normalizeStringArray(request.images, "images");
295+
296+  if (!Array.isArray(imagesValue)) {
297+    return {
298+      ok: false,
299+      response: imagesValue
300+    };
301+  }
302+
303+  const envValue = normalizeEnvMap(request.env);
304+
305+  if (isFailureResponse(envValue)) {
306+    return {
307+      ok: false,
308+      response: envValue
309+    };
310+  }
311+
312+  const purpose =
313+    request.purpose === undefined
314+      ? "simple-worker"
315+      : isOneOf(request.purpose, CODEX_EXEC_PURPOSES)
316+        ? request.purpose
317+        : null;
318+
319+  if (purpose === null) {
320+    return {
321+      ok: false,
322+      response: createFailure({
323+        code: "CODEX_EXEC_INVALID_INPUT",
324+        message: `purpose must be one of: ${CODEX_EXEC_PURPOSES.join(", ")}.`,
325+        retryable: false
326+      })
327+    };
328+  }
329+
330+  const color =
331+    request.color === undefined
332+      ? DEFAULT_CODEX_EXEC_COLOR_MODE
333+      : isOneOf(request.color, CODEX_EXEC_COLOR_MODES)
334+        ? request.color
335+        : null;
336+
337+  if (color === null) {
338+    return {
339+      ok: false,
340+      response: createFailure({
341+        code: "CODEX_EXEC_INVALID_INPUT",
342+        message: `color must be one of: ${CODEX_EXEC_COLOR_MODES.join(", ")}.`,
343+        retryable: false
344+      })
345+    };
346+  }
347+
348+  const sandbox =
349+    request.sandbox === undefined
350+      ? undefined
351+      : isOneOf(request.sandbox, CODEX_EXEC_SANDBOX_MODES)
352+        ? request.sandbox
353+        : null;
354+
355+  if (sandbox === null) {
356+    return {
357+      ok: false,
358+      response: createFailure({
359+        code: "CODEX_EXEC_INVALID_INPUT",
360+        message: `sandbox must be one of: ${CODEX_EXEC_SANDBOX_MODES.join(", ")}.`,
361+        retryable: false
362+      })
363+    };
364+  }
365+
366+  const timeoutMs =
367+    request.timeoutMs === undefined
368+      ? DEFAULT_CODEX_EXEC_TIMEOUT_MS
369+      : Number.isSafeInteger(request.timeoutMs) && request.timeoutMs > 0
370+        ? request.timeoutMs
371+        : null;
372+
373+  if (timeoutMs === null) {
374+    return {
375+      ok: false,
376+      response: createFailure({
377+        code: "CODEX_EXEC_INVALID_INPUT",
378+        message: "timeoutMs must be a positive safe integer when provided.",
379+        retryable: false
380+      })
381+    };
382+  }
383+
384+  if (request.skipGitRepoCheck !== undefined && typeof request.skipGitRepoCheck !== "boolean") {
385+    return {
386+      ok: false,
387+      response: createFailure({
388+        code: "CODEX_EXEC_INVALID_INPUT",
389+        message: "skipGitRepoCheck must be a boolean when provided.",
390+        retryable: false
391+      })
392+    };
393+  }
394+
395+  if (request.json !== undefined && typeof request.json !== "boolean") {
396+    return {
397+      ok: false,
398+      response: createFailure({
399+        code: "CODEX_EXEC_INVALID_INPUT",
400+        message: "json must be a boolean when provided.",
401+        retryable: false
402+      })
403+    };
404+  }
405+
406+  if (request.ephemeral !== undefined && typeof request.ephemeral !== "boolean") {
407+    return {
408+      ok: false,
409+      response: createFailure({
410+        code: "CODEX_EXEC_INVALID_INPUT",
411+        message: "ephemeral must be a boolean when provided.",
412+        retryable: false
413+      })
414+    };
415+  }
416+
417+  return {
418+    ok: true,
419+    request: {
420+      additionalWritableDirectories: additionalWritableDirectoriesValue,
421+      cliPath: cliPathValue || DEFAULT_CODEX_EXEC_CLI_PATH,
422+      color,
423+      config: configValue,
424+      cwd: cwdValue || process.cwd(),
425+      env: envValue,
426+      ephemeral: request.ephemeral ?? false,
427+      images: imagesValue,
428+      json: request.json ?? false,
429+      model: modelValue || undefined,
430+      profile: profileValue || undefined,
431+      prompt,
432+      purpose,
433+      sandbox,
434+      skipGitRepoCheck: request.skipGitRepoCheck ?? false,
435+      timeoutMs
436+    }
437+  };
438+}
439+
440+function createInvocation(request: NormalizedCodexExecRunRequest): CodexExecInvocation {
441+  const args = ["exec", "--cd", request.cwd, "--color", request.color];
442+
443+  if (request.model !== undefined) {
444+    args.push("--model", request.model);
445+  }
446+
447+  if (request.profile !== undefined) {
448+    args.push("--profile", request.profile);
449+  }
450+
451+  if (request.sandbox !== undefined) {
452+    args.push("--sandbox", request.sandbox);
453+  }
454+
455+  if (request.skipGitRepoCheck) {
456+    args.push("--skip-git-repo-check");
457+  }
458+
459+  if (request.json) {
460+    args.push("--json");
461+  }
462+
463+  if (request.ephemeral) {
464+    args.push("--ephemeral");
465+  }
466+
467+  for (const configEntry of request.config) {
468+    args.push("--config", configEntry);
469+  }
470+
471+  for (const directoryPath of request.additionalWritableDirectories) {
472+    args.push("--add-dir", directoryPath);
473+  }
474+
475+  for (const imagePath of request.images) {
476+    args.push("--image", imagePath);
477+  }
478+
479+  args.push(request.prompt);
480+
481+  return {
482+    command: request.cliPath,
483+    args,
484+    cwd: request.cwd,
485+    prompt: request.prompt,
486+    purpose: request.purpose,
487+    timeoutMs: request.timeoutMs,
488+    color: request.color,
489+    json: request.json,
490+    ephemeral: request.ephemeral,
491+    skipGitRepoCheck: request.skipGitRepoCheck,
492+    model: request.model,
493+    profile: request.profile,
494+    sandbox: request.sandbox,
495+    config: [...request.config],
496+    additionalWritableDirectories: [...request.additionalWritableDirectories],
497+    images: [...request.images]
498+  };
499+}
500+
501+function createSpawnArgs(invocation: CodexExecInvocation, outputLastMessagePath: string): string[] {
502+  const promptArg = invocation.args[invocation.args.length - 1];
503+  const leadingArgs = invocation.args.slice(0, -1);
504+
505+  if (promptArg === undefined) {
506+    return [...invocation.args, "--output-last-message", outputLastMessagePath];
507+  }
508+
509+  return [...leadingArgs, "--output-last-message", outputLastMessagePath, promptArg];
510+}
511+
512+async function createLastMessageCapture(): Promise<LastMessageCapture> {
513+  const directoryPath = await mkdtemp(join(tmpdir(), "baa-codex-exec-"));
514+
515+  return {
516+    directoryPath,
517+    filePath: join(directoryPath, "last-message.txt")
518+  };
519+}
520+
521+async function readLastMessage(filePath: string): Promise<string | null> {
522+  try {
523+    return await readFile(filePath, "utf8");
524+  } catch (error) {
525+    const errorCode = isRecord(error) && typeof error.code === "string" ? error.code : "";
526+
527+    if (errorCode === "ENOENT") {
528+      return null;
529+    }
530+
531+    return null;
532+  }
533+}
534+
535+function buildProcessEnv(
536+  envOverrides: Record<string, string | undefined>
537+): NodeJS.ProcessEnv {
538+  const env = { ...process.env };
539+
540+  for (const [key, value] of Object.entries(envOverrides)) {
541+    if (value === undefined) {
542+      delete env[key];
543+      continue;
544+    }
545+
546+    env[key] = value;
547+  }
548+
549+  return env;
550+}
551+
552+async function spawnCodexExecProcess(
553+  invocation: CodexExecInvocation,
554+  outputLastMessagePath: string,
555+  envOverrides: Record<string, string | undefined>
556+): Promise<SpawnCodexExecOutcome> {
557+  return await new Promise<SpawnCodexExecOutcome>((resolve) => {
558+    const spawnedArgs = createSpawnArgs(invocation, outputLastMessagePath);
559+    const child = spawn(invocation.command, spawnedArgs, {
560+      cwd: invocation.cwd,
561+      env: buildProcessEnv(envOverrides),
562+      stdio: ["ignore", "pipe", "pipe"]
563+    });
564+    let stdout = "";
565+    let stderr = "";
566+    let timedOut = false;
567+    let settled = false;
568+    let forceKillHandle: ReturnType<typeof setTimeout> | undefined;
569+    const timeoutHandle = setTimeout(() => {
570+      timedOut = true;
571+      child.kill("SIGTERM");
572+      forceKillHandle = setTimeout(() => {
573+        child.kill("SIGKILL");
574+      }, FORCE_KILL_AFTER_TIMEOUT_MS);
575+    }, invocation.timeoutMs);
576+
577+    child.stdout?.setEncoding("utf8");
578+    child.stdout?.on("data", (chunk) => {
579+      stdout += typeof chunk === "string" ? chunk : String(chunk);
580+    });
581+
582+    child.stderr?.setEncoding("utf8");
583+    child.stderr?.on("data", (chunk) => {
584+      stderr += typeof chunk === "string" ? chunk : String(chunk);
585+    });
586+
587+    const finish = (outcome: SpawnCodexExecOutcome) => {
588+      if (settled) {
589+        return;
590+      }
591+
592+      settled = true;
593+      clearTimeout(timeoutHandle);
594+
595+      if (forceKillHandle !== undefined) {
596+        clearTimeout(forceKillHandle);
597+      }
598+
599+      resolve(outcome);
600+    };
601+
602+    child.once("error", (spawnError) => {
603+      finish({
604+        exitCode: null,
605+        signal: null,
606+        spawnError,
607+        stderr,
608+        stdout,
609+        timedOut
610+      });
611+    });
612+
613+    child.once("close", (exitCode, signal) => {
614+      finish({
615+        exitCode,
616+        signal,
617+        stderr,
618+        stdout,
619+        timedOut
620+      });
621+    });
622+  });
623+}
624+
625+function parseJsonEvents(stdout: string): {
626+  jsonEvents: JsonValue[];
627+  jsonParseErrors: CodexExecJsonParseError[];
628+} {
629+  const jsonEvents: JsonValue[] = [];
630+  const jsonParseErrors: CodexExecJsonParseError[] = [];
631+  const lines = stdout.split(/\r?\n/);
632+
633+  for (let index = 0; index < lines.length; index += 1) {
634+    const line = lines[index]?.trim() ?? "";
635+
636+    if (line === "") {
637+      continue;
638+    }
639+
640+    try {
641+      jsonEvents.push(JSON.parse(line) as JsonValue);
642+    } catch (error) {
643+      jsonParseErrors.push({
644+        line: index + 1,
645+        message: toErrorMessage(error, "Failed to parse JSONL output.")
646+      });
647+    }
648+  }
649+
650+  return {
651+    jsonEvents,
652+    jsonParseErrors
653+  };
654+}
655+
656+export async function runCodexExec(request: CodexExecRunRequest): Promise<CodexExecRunResponse> {
657+  const normalized = normalizeRunRequest(request);
658+
659+  if (!normalized.ok) {
660+    return normalized.response;
661+  }
662+
663+  const invocation = createInvocation(normalized.request);
664+  const startedAt = new Date();
665+  const capture = await createLastMessageCapture();
666+
667+  try {
668+    const processOutcome = await spawnCodexExecProcess(
669+      invocation,
670+      capture.filePath,
671+      normalized.request.env
672+    );
673+    const finishedAt = new Date();
674+    const lastMessage = await readLastMessage(capture.filePath);
675+    const parsedOutput = invocation.json
676+      ? parseJsonEvents(processOutcome.stdout)
677+      : { jsonEvents: null, jsonParseErrors: [] };
678+    const result: CodexExecRunResult = {
679+      durationMs: finishedAt.getTime() - startedAt.getTime(),
680+      exitCode: processOutcome.exitCode,
681+      finishedAt: finishedAt.toISOString(),
682+      jsonEvents: parsedOutput.jsonEvents,
683+      jsonParseErrors: parsedOutput.jsonParseErrors,
684+      lastMessage,
685+      signal: processOutcome.signal,
686+      startedAt: startedAt.toISOString(),
687+      stderr: processOutcome.stderr,
688+      stdout: processOutcome.stdout,
689+      timedOut: processOutcome.timedOut
690+    };
691+
692+    if (processOutcome.spawnError !== undefined) {
693+      return createFailure(
694+        {
695+          code: "CODEX_EXEC_SPAWN_FAILED",
696+          message: `Failed to start codex exec: ${toErrorMessage(processOutcome.spawnError, "Unknown spawn error.")}`,
697+          retryable: false
698+        },
699+        invocation,
700+        result
701+      );
702+    }
703+
704+    if (processOutcome.timedOut) {
705+      return createFailure(
706+        {
707+          code: "CODEX_EXEC_TIMEOUT",
708+          message: `Codex exec timed out after ${invocation.timeoutMs}ms.`,
709+          retryable: true,
710+          details: {
711+            timeoutMs: invocation.timeoutMs
712+          }
713+        },
714+        invocation,
715+        result
716+      );
717+    }
718+
719+    if (processOutcome.exitCode !== 0) {
720+      return createFailure(
721+        {
722+          code: "CODEX_EXEC_EXIT_NON_ZERO",
723+          message: `Codex exec exited with code ${String(processOutcome.exitCode)}.`,
724+          retryable: false,
725+          details: {
726+            exitCode: processOutcome.exitCode
727+          }
728+        },
729+        invocation,
730+        result
731+      );
732+    }
733+
734+    return {
735+      ok: true,
736+      invocation,
737+      result
738+    };
739+  } finally {
740+    await rm(capture.directoryPath, {
741+      force: true,
742+      recursive: true
743+    });
744+  }
745+}
A packages/codex-exec/src/smoke.test.js
+35, -0
 1@@ -0,0 +1,35 @@
 2+import assert from "node:assert/strict";
 3+import { mkdtemp, rm } from "node:fs/promises";
 4+import { tmpdir } from "node:os";
 5+import { join } from "node:path";
 6+import test from "node:test";
 7+
 8+import { runCodexExec } from "../dist/index.js";
 9+
10+test("runCodexExec can drive the real codex exec CLI for smoke coverage", async (t) => {
11+  if (process.env.BAA_RUN_LIVE_CODEX_EXEC_SMOKE !== "1") {
12+    t.skip("Set BAA_RUN_LIVE_CODEX_EXEC_SMOKE=1 to run the live codex exec smoke test.");
13+  }
14+
15+  const workspacePath = await mkdtemp(join(tmpdir(), "baa-codex-exec-smoke-"));
16+
17+  t.after(async () => {
18+    await rm(workspacePath, { force: true, recursive: true });
19+  });
20+
21+  const response = await runCodexExec({
22+    purpose: "smoke",
23+    prompt: "Reply with a short confirmation sentence.",
24+    cwd: workspacePath,
25+    timeoutMs: 120_000,
26+    sandbox: "read-only",
27+    skipGitRepoCheck: true,
28+    ephemeral: true
29+  });
30+
31+  assert.equal(response.ok, true, response.ok ? undefined : response.error.message);
32+  assert.equal(response.result.exitCode, 0);
33+  assert.equal(response.result.timedOut, false);
34+  assert.notEqual(response.result.lastMessage, null);
35+  assert.notEqual(response.result.lastMessage?.trim(), "");
36+});
A packages/codex-exec/tsconfig.json
+9, -0
 1@@ -0,0 +1,9 @@
 2+{
 3+  "extends": "../../tsconfig.base.json",
 4+  "compilerOptions": {
 5+    "declaration": true,
 6+    "rootDir": "src",
 7+    "outDir": "dist"
 8+  },
 9+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
10+}