baa-conductor


baa-conductor / packages / codex-exec / src
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}