- 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
+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
+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+}
+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;
+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+});
+2,
-0
1@@ -0,0 +1,2 @@
2+export * from "./contracts.js";
3+export * from "./runner.js";
+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;
+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+}
+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+});
+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+}