im_wower
·
2026-03-22
runner.ts
1import { spawn } from "node:child_process";
2import { mkdtemp, readFile, rm } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import {
6 CODEX_EXEC_COLOR_MODES,
7 CODEX_EXEC_PURPOSES,
8 CODEX_EXEC_SANDBOX_MODES,
9 DEFAULT_CODEX_EXEC_CLI_PATH,
10 DEFAULT_CODEX_EXEC_COLOR_MODE,
11 DEFAULT_CODEX_EXEC_TIMEOUT_MS,
12 type CodexExecColorMode,
13 type CodexExecError,
14 type CodexExecInvocation,
15 type CodexExecJsonParseError,
16 type CodexExecRunFailure,
17 type CodexExecRunRequest,
18 type CodexExecRunResponse,
19 type CodexExecRunResult,
20 type CodexExecSandboxMode,
21 type JsonValue
22} from "./contracts.js";
23
24const FORCE_KILL_AFTER_TIMEOUT_MS = 1_000;
25
26interface NormalizedCodexExecRunRequest {
27 additionalWritableDirectories: string[];
28 cliPath: string;
29 color: CodexExecColorMode;
30 config: string[];
31 cwd: string;
32 env: Record<string, string | undefined>;
33 ephemeral: boolean;
34 images: string[];
35 json: boolean;
36 model?: string;
37 profile?: string;
38 prompt: string;
39 purpose: CodexExecInvocation["purpose"];
40 sandbox?: CodexExecSandboxMode;
41 skipGitRepoCheck: boolean;
42 timeoutMs: number;
43}
44
45interface NormalizedCodexExecRunRequestSuccess {
46 ok: true;
47 request: NormalizedCodexExecRunRequest;
48}
49
50interface NormalizedCodexExecRunRequestFailure {
51 ok: false;
52 response: CodexExecRunFailure;
53}
54
55type NormalizedCodexExecRunRequestResult =
56 | NormalizedCodexExecRunRequestSuccess
57 | NormalizedCodexExecRunRequestFailure;
58
59interface SpawnCodexExecOutcome {
60 exitCode: number | null;
61 signal: string | null;
62 spawnError?: Error;
63 stderr: string;
64 stdout: string;
65 timedOut: boolean;
66}
67
68interface LastMessageCapture {
69 directoryPath: string;
70 filePath: string;
71}
72
73function isNonEmptyString(value: unknown): value is string {
74 return typeof value === "string" && value.trim() !== "";
75}
76
77function isRecord(value: unknown): value is Record<string, unknown> {
78 return typeof value === "object" && value !== null;
79}
80
81function isOneOf<TValue extends string>(
82 value: string,
83 allowedValues: readonly TValue[]
84): value is TValue {
85 return allowedValues.includes(value as TValue);
86}
87
88function toErrorMessage(cause: unknown, fallback: string): string {
89 if (cause instanceof Error && cause.message !== "") {
90 return cause.message;
91 }
92
93 if (typeof cause === "string" && cause !== "") {
94 return cause;
95 }
96
97 return fallback;
98}
99
100function createFailure(
101 error: CodexExecError,
102 invocation?: CodexExecInvocation,
103 result?: CodexExecRunResult
104): CodexExecRunFailure {
105 return {
106 ok: false,
107 error,
108 invocation,
109 result
110 };
111}
112
113function isFailureResponse(value: unknown): value is CodexExecRunFailure {
114 return (
115 isRecord(value) &&
116 value.ok === false &&
117 isRecord(value.error) &&
118 typeof value.error.code === "string"
119 );
120}
121
122function normalizeOptionalString(value: unknown, fieldName: string): string | CodexExecRunFailure {
123 if (value === undefined) {
124 return "";
125 }
126
127 if (isNonEmptyString(value)) {
128 return value.trim();
129 }
130
131 return createFailure({
132 code: "CODEX_EXEC_INVALID_INPUT",
133 message: `${fieldName} must be a non-empty string when provided.`,
134 retryable: false
135 });
136}
137
138function normalizeStringArray(
139 value: unknown,
140 fieldName: string
141): string[] | CodexExecRunFailure {
142 if (value === undefined) {
143 return [];
144 }
145
146 if (!Array.isArray(value)) {
147 return createFailure({
148 code: "CODEX_EXEC_INVALID_INPUT",
149 message: `${fieldName} must be an array of non-empty strings when provided.`,
150 retryable: false
151 });
152 }
153
154 const normalized: string[] = [];
155
156 for (const item of value) {
157 if (!isNonEmptyString(item)) {
158 return createFailure({
159 code: "CODEX_EXEC_INVALID_INPUT",
160 message: `${fieldName} must contain only non-empty strings.`,
161 retryable: false
162 });
163 }
164
165 normalized.push(item.trim());
166 }
167
168 return normalized;
169}
170
171function normalizeEnvMap(
172 value: unknown
173): Record<string, string | undefined> | CodexExecRunFailure {
174 if (value === undefined) {
175 return {};
176 }
177
178 if (!isRecord(value)) {
179 return createFailure({
180 code: "CODEX_EXEC_INVALID_INPUT",
181 message: "env must be an object mapping variable names to strings or undefined.",
182 retryable: false
183 });
184 }
185
186 const normalized: Record<string, string | undefined> = {};
187
188 for (const [key, envValue] of Object.entries(value)) {
189 if (!isNonEmptyString(key)) {
190 return createFailure({
191 code: "CODEX_EXEC_INVALID_INPUT",
192 message: "env variable names must be non-empty strings.",
193 retryable: false
194 });
195 }
196
197 if (envValue !== undefined && typeof envValue !== "string") {
198 return createFailure({
199 code: "CODEX_EXEC_INVALID_INPUT",
200 message: `env.${key} must be a string or undefined.`,
201 retryable: false
202 });
203 }
204
205 normalized[key] = envValue;
206 }
207
208 return normalized;
209}
210
211function normalizeRunRequest(request: CodexExecRunRequest): NormalizedCodexExecRunRequestResult {
212 const prompt = isNonEmptyString(request.prompt) ? request.prompt.trim() : "";
213
214 if (prompt === "") {
215 return {
216 ok: false,
217 response: createFailure({
218 code: "CODEX_EXEC_INVALID_INPUT",
219 message: "prompt must be a non-empty string.",
220 retryable: false
221 })
222 };
223 }
224
225 if (prompt === "-") {
226 return {
227 ok: false,
228 response: createFailure({
229 code: "CODEX_EXEC_INVALID_INPUT",
230 message: "prompt '-' is not supported by the fallback adapter because it does not stream stdin.",
231 retryable: false
232 })
233 };
234 }
235
236 const cliPathValue = normalizeOptionalString(request.cliPath, "cliPath");
237
238 if (cliPathValue && typeof cliPathValue !== "string") {
239 return {
240 ok: false,
241 response: cliPathValue
242 };
243 }
244
245 const cwdValue = normalizeOptionalString(request.cwd, "cwd");
246
247 if (cwdValue && typeof cwdValue !== "string") {
248 return {
249 ok: false,
250 response: cwdValue
251 };
252 }
253
254 const modelValue = normalizeOptionalString(request.model, "model");
255
256 if (modelValue && typeof modelValue !== "string") {
257 return {
258 ok: false,
259 response: modelValue
260 };
261 }
262
263 const profileValue = normalizeOptionalString(request.profile, "profile");
264
265 if (profileValue && typeof profileValue !== "string") {
266 return {
267 ok: false,
268 response: profileValue
269 };
270 }
271
272 const configValue = normalizeStringArray(request.config, "config");
273
274 if (!Array.isArray(configValue)) {
275 return {
276 ok: false,
277 response: configValue
278 };
279 }
280
281 const additionalWritableDirectoriesValue = normalizeStringArray(
282 request.additionalWritableDirectories,
283 "additionalWritableDirectories"
284 );
285
286 if (!Array.isArray(additionalWritableDirectoriesValue)) {
287 return {
288 ok: false,
289 response: additionalWritableDirectoriesValue
290 };
291 }
292
293 const imagesValue = normalizeStringArray(request.images, "images");
294
295 if (!Array.isArray(imagesValue)) {
296 return {
297 ok: false,
298 response: imagesValue
299 };
300 }
301
302 const envValue = normalizeEnvMap(request.env);
303
304 if (isFailureResponse(envValue)) {
305 return {
306 ok: false,
307 response: envValue
308 };
309 }
310
311 const purpose =
312 request.purpose === undefined
313 ? "simple-worker"
314 : isOneOf(request.purpose, CODEX_EXEC_PURPOSES)
315 ? request.purpose
316 : null;
317
318 if (purpose === null) {
319 return {
320 ok: false,
321 response: createFailure({
322 code: "CODEX_EXEC_INVALID_INPUT",
323 message: `purpose must be one of: ${CODEX_EXEC_PURPOSES.join(", ")}.`,
324 retryable: false
325 })
326 };
327 }
328
329 const color =
330 request.color === undefined
331 ? DEFAULT_CODEX_EXEC_COLOR_MODE
332 : isOneOf(request.color, CODEX_EXEC_COLOR_MODES)
333 ? request.color
334 : null;
335
336 if (color === null) {
337 return {
338 ok: false,
339 response: createFailure({
340 code: "CODEX_EXEC_INVALID_INPUT",
341 message: `color must be one of: ${CODEX_EXEC_COLOR_MODES.join(", ")}.`,
342 retryable: false
343 })
344 };
345 }
346
347 const sandbox =
348 request.sandbox === undefined
349 ? undefined
350 : isOneOf(request.sandbox, CODEX_EXEC_SANDBOX_MODES)
351 ? request.sandbox
352 : null;
353
354 if (sandbox === null) {
355 return {
356 ok: false,
357 response: createFailure({
358 code: "CODEX_EXEC_INVALID_INPUT",
359 message: `sandbox must be one of: ${CODEX_EXEC_SANDBOX_MODES.join(", ")}.`,
360 retryable: false
361 })
362 };
363 }
364
365 const timeoutMs =
366 request.timeoutMs === undefined
367 ? DEFAULT_CODEX_EXEC_TIMEOUT_MS
368 : Number.isSafeInteger(request.timeoutMs) && request.timeoutMs > 0
369 ? request.timeoutMs
370 : null;
371
372 if (timeoutMs === null) {
373 return {
374 ok: false,
375 response: createFailure({
376 code: "CODEX_EXEC_INVALID_INPUT",
377 message: "timeoutMs must be a positive safe integer when provided.",
378 retryable: false
379 })
380 };
381 }
382
383 if (request.skipGitRepoCheck !== undefined && typeof request.skipGitRepoCheck !== "boolean") {
384 return {
385 ok: false,
386 response: createFailure({
387 code: "CODEX_EXEC_INVALID_INPUT",
388 message: "skipGitRepoCheck must be a boolean when provided.",
389 retryable: false
390 })
391 };
392 }
393
394 if (request.json !== undefined && typeof request.json !== "boolean") {
395 return {
396 ok: false,
397 response: createFailure({
398 code: "CODEX_EXEC_INVALID_INPUT",
399 message: "json must be a boolean when provided.",
400 retryable: false
401 })
402 };
403 }
404
405 if (request.ephemeral !== undefined && typeof request.ephemeral !== "boolean") {
406 return {
407 ok: false,
408 response: createFailure({
409 code: "CODEX_EXEC_INVALID_INPUT",
410 message: "ephemeral must be a boolean when provided.",
411 retryable: false
412 })
413 };
414 }
415
416 return {
417 ok: true,
418 request: {
419 additionalWritableDirectories: additionalWritableDirectoriesValue,
420 cliPath: cliPathValue || DEFAULT_CODEX_EXEC_CLI_PATH,
421 color,
422 config: configValue,
423 cwd: cwdValue || process.cwd(),
424 env: envValue,
425 ephemeral: request.ephemeral ?? false,
426 images: imagesValue,
427 json: request.json ?? false,
428 model: modelValue || undefined,
429 profile: profileValue || undefined,
430 prompt,
431 purpose,
432 sandbox,
433 skipGitRepoCheck: request.skipGitRepoCheck ?? false,
434 timeoutMs
435 }
436 };
437}
438
439function createInvocation(request: NormalizedCodexExecRunRequest): CodexExecInvocation {
440 const args = ["exec", "--cd", request.cwd, "--color", request.color];
441
442 if (request.model !== undefined) {
443 args.push("--model", request.model);
444 }
445
446 if (request.profile !== undefined) {
447 args.push("--profile", request.profile);
448 }
449
450 if (request.sandbox !== undefined) {
451 args.push("--sandbox", request.sandbox);
452 }
453
454 if (request.skipGitRepoCheck) {
455 args.push("--skip-git-repo-check");
456 }
457
458 if (request.json) {
459 args.push("--json");
460 }
461
462 if (request.ephemeral) {
463 args.push("--ephemeral");
464 }
465
466 for (const configEntry of request.config) {
467 args.push("--config", configEntry);
468 }
469
470 for (const directoryPath of request.additionalWritableDirectories) {
471 args.push("--add-dir", directoryPath);
472 }
473
474 for (const imagePath of request.images) {
475 args.push("--image", imagePath);
476 }
477
478 args.push(request.prompt);
479
480 return {
481 command: request.cliPath,
482 args,
483 cwd: request.cwd,
484 prompt: request.prompt,
485 purpose: request.purpose,
486 timeoutMs: request.timeoutMs,
487 color: request.color,
488 json: request.json,
489 ephemeral: request.ephemeral,
490 skipGitRepoCheck: request.skipGitRepoCheck,
491 model: request.model,
492 profile: request.profile,
493 sandbox: request.sandbox,
494 config: [...request.config],
495 additionalWritableDirectories: [...request.additionalWritableDirectories],
496 images: [...request.images]
497 };
498}
499
500function createSpawnArgs(invocation: CodexExecInvocation, outputLastMessagePath: string): string[] {
501 const promptArg = invocation.args[invocation.args.length - 1];
502 const leadingArgs = invocation.args.slice(0, -1);
503
504 if (promptArg === undefined) {
505 return [...invocation.args, "--output-last-message", outputLastMessagePath];
506 }
507
508 return [...leadingArgs, "--output-last-message", outputLastMessagePath, promptArg];
509}
510
511async function createLastMessageCapture(): Promise<LastMessageCapture> {
512 const directoryPath = await mkdtemp(join(tmpdir(), "baa-codex-exec-"));
513
514 return {
515 directoryPath,
516 filePath: join(directoryPath, "last-message.txt")
517 };
518}
519
520async function readLastMessage(filePath: string): Promise<string | null> {
521 try {
522 return await readFile(filePath, "utf8");
523 } catch (error) {
524 const errorCode = isRecord(error) && typeof error.code === "string" ? error.code : "";
525
526 if (errorCode === "ENOENT") {
527 return null;
528 }
529
530 return null;
531 }
532}
533
534function buildProcessEnv(
535 envOverrides: Record<string, string | undefined>
536): NodeJS.ProcessEnv {
537 const env = { ...process.env };
538
539 for (const [key, value] of Object.entries(envOverrides)) {
540 if (value === undefined) {
541 delete env[key];
542 continue;
543 }
544
545 env[key] = value;
546 }
547
548 return env;
549}
550
551async function spawnCodexExecProcess(
552 invocation: CodexExecInvocation,
553 outputLastMessagePath: string,
554 envOverrides: Record<string, string | undefined>
555): Promise<SpawnCodexExecOutcome> {
556 return await new Promise<SpawnCodexExecOutcome>((resolve) => {
557 const spawnedArgs = createSpawnArgs(invocation, outputLastMessagePath);
558 const child = spawn(invocation.command, spawnedArgs, {
559 cwd: invocation.cwd,
560 env: buildProcessEnv(envOverrides),
561 stdio: ["ignore", "pipe", "pipe"]
562 });
563 let stdout = "";
564 let stderr = "";
565 let timedOut = false;
566 let settled = false;
567 let forceKillHandle: ReturnType<typeof setTimeout> | undefined;
568 const timeoutHandle = setTimeout(() => {
569 timedOut = true;
570 child.kill("SIGTERM");
571 forceKillHandle = setTimeout(() => {
572 child.kill("SIGKILL");
573 }, FORCE_KILL_AFTER_TIMEOUT_MS);
574 }, invocation.timeoutMs);
575
576 child.stdout?.setEncoding("utf8");
577 child.stdout?.on("data", (chunk) => {
578 stdout += typeof chunk === "string" ? chunk : String(chunk);
579 });
580
581 child.stderr?.setEncoding("utf8");
582 child.stderr?.on("data", (chunk) => {
583 stderr += typeof chunk === "string" ? chunk : String(chunk);
584 });
585
586 const finish = (outcome: SpawnCodexExecOutcome) => {
587 if (settled) {
588 return;
589 }
590
591 settled = true;
592 clearTimeout(timeoutHandle);
593
594 if (forceKillHandle !== undefined) {
595 clearTimeout(forceKillHandle);
596 }
597
598 resolve(outcome);
599 };
600
601 child.once("error", (spawnError) => {
602 finish({
603 exitCode: null,
604 signal: null,
605 spawnError,
606 stderr,
607 stdout,
608 timedOut
609 });
610 });
611
612 child.once("close", (exitCode, signal) => {
613 finish({
614 exitCode,
615 signal,
616 stderr,
617 stdout,
618 timedOut
619 });
620 });
621 });
622}
623
624function parseJsonEvents(stdout: string): {
625 jsonEvents: JsonValue[];
626 jsonParseErrors: CodexExecJsonParseError[];
627} {
628 const jsonEvents: JsonValue[] = [];
629 const jsonParseErrors: CodexExecJsonParseError[] = [];
630 const lines = stdout.split(/\r?\n/);
631
632 for (let index = 0; index < lines.length; index += 1) {
633 const line = lines[index]?.trim() ?? "";
634
635 if (line === "") {
636 continue;
637 }
638
639 try {
640 jsonEvents.push(JSON.parse(line) as JsonValue);
641 } catch (error) {
642 jsonParseErrors.push({
643 line: index + 1,
644 message: toErrorMessage(error, "Failed to parse JSONL output.")
645 });
646 }
647 }
648
649 return {
650 jsonEvents,
651 jsonParseErrors
652 };
653}
654
655export async function runCodexExec(request: CodexExecRunRequest): Promise<CodexExecRunResponse> {
656 const normalized = normalizeRunRequest(request);
657
658 if (!normalized.ok) {
659 return normalized.response;
660 }
661
662 const invocation = createInvocation(normalized.request);
663 const startedAt = new Date();
664 const capture = await createLastMessageCapture();
665
666 try {
667 const processOutcome = await spawnCodexExecProcess(
668 invocation,
669 capture.filePath,
670 normalized.request.env
671 );
672 const finishedAt = new Date();
673 const lastMessage = await readLastMessage(capture.filePath);
674 const parsedOutput = invocation.json
675 ? parseJsonEvents(processOutcome.stdout)
676 : { jsonEvents: null, jsonParseErrors: [] };
677 const result: CodexExecRunResult = {
678 durationMs: finishedAt.getTime() - startedAt.getTime(),
679 exitCode: processOutcome.exitCode,
680 finishedAt: finishedAt.toISOString(),
681 jsonEvents: parsedOutput.jsonEvents,
682 jsonParseErrors: parsedOutput.jsonParseErrors,
683 lastMessage,
684 signal: processOutcome.signal,
685 startedAt: startedAt.toISOString(),
686 stderr: processOutcome.stderr,
687 stdout: processOutcome.stdout,
688 timedOut: processOutcome.timedOut
689 };
690
691 if (processOutcome.spawnError !== undefined) {
692 return createFailure(
693 {
694 code: "CODEX_EXEC_SPAWN_FAILED",
695 message: `Failed to start codex exec: ${toErrorMessage(processOutcome.spawnError, "Unknown spawn error.")}`,
696 retryable: false
697 },
698 invocation,
699 result
700 );
701 }
702
703 if (processOutcome.timedOut) {
704 return createFailure(
705 {
706 code: "CODEX_EXEC_TIMEOUT",
707 message: `Codex exec timed out after ${invocation.timeoutMs}ms.`,
708 retryable: true,
709 details: {
710 timeoutMs: invocation.timeoutMs
711 }
712 },
713 invocation,
714 result
715 );
716 }
717
718 if (processOutcome.exitCode !== 0) {
719 return createFailure(
720 {
721 code: "CODEX_EXEC_EXIT_NON_ZERO",
722 message: `Codex exec exited with code ${String(processOutcome.exitCode)}.`,
723 retryable: false,
724 details: {
725 exitCode: processOutcome.exitCode
726 }
727 },
728 invocation,
729 result
730 );
731 }
732
733 return {
734 ok: true,
735 invocation,
736 result
737 };
738 } finally {
739 await rm(capture.directoryPath, {
740 force: true,
741 recursive: true
742 });
743 }
744}