diff --git a/sdk/typescript/eslint.config.js b/sdk/typescript/eslint.config.js index c2fb27d2df..accb022be1 100644 --- a/sdk/typescript/eslint.config.js +++ b/sdk/typescript/eslint.config.js @@ -1,8 +1,5 @@ -import eslint from '@eslint/js'; -import { defineConfig } from 'eslint/config'; -import tseslint from 'typescript-eslint'; +import eslint from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import tseslint from "typescript-eslint"; -export default defineConfig( - eslint.configs.recommended, - tseslint.configs.recommended, -); +export default defineConfig(eslint.configs.recommended, tseslint.configs.recommended); diff --git a/sdk/typescript/src/codex.ts b/sdk/typescript/src/codex.ts index bb4b713550..331b7e6e3f 100644 --- a/sdk/typescript/src/codex.ts +++ b/sdk/typescript/src/codex.ts @@ -6,7 +6,7 @@ export class Codex { private exec: CodexExec; private options: CodexOptions; - constructor(options: CodexOptions) { + constructor(options: CodexOptions = {}) { this.exec = new CodexExec(options.codexPathOverride); this.options = options; } diff --git a/sdk/typescript/src/codexOptions.ts b/sdk/typescript/src/codexOptions.ts index 2d22bcf227..149e61717a 100644 --- a/sdk/typescript/src/codexOptions.ts +++ b/sdk/typescript/src/codexOptions.ts @@ -2,4 +2,5 @@ export type CodexOptions = { codexPathOverride?: string; baseUrl?: string; apiKey?: string; + workingDirectory?: string; }; diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 6ae5b441a7..d11c2a1646 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -12,8 +12,14 @@ export type CodexExecArgs = { baseUrl?: string; apiKey?: string; threadId?: string | null; + // --model model?: string; + // --sandbox sandboxMode?: SandboxMode; + // --cd + workingDirectory?: string; + // --skip-git-repo-check + skipGitRepoCheck?: boolean; }; export class CodexExec { @@ -33,12 +39,18 @@ export class CodexExec { commandArgs.push("--sandbox", args.sandboxMode); } - if (args.threadId) { - commandArgs.push("resume", args.threadId, args.input); - } else { - commandArgs.push(args.input); + if (args.workingDirectory) { + commandArgs.push("--cd", args.workingDirectory); + } + + if (args.skipGitRepoCheck) { + commandArgs.push("--skip-git-repo-check"); } + if (args.threadId) { + commandArgs.push("resume", args.threadId); + } + const env = { ...process.env, }; @@ -55,11 +67,25 @@ export class CodexExec { let spawnError: unknown | null = null; child.once("error", (err) => (spawnError = err)); + + if (!child.stdin) { + child.kill(); + throw new Error("Child process has no stdin"); + } + child.stdin.write(args.input); + child.stdin.end(); if (!child.stdout) { child.kill(); throw new Error("Child process has no stdout"); } + const stderrChunks: Buffer[] = []; + + if (child.stderr) { + child.stderr.on("data", (data) => { + stderrChunks.push(data); + }); + } const rl = readline.createInterface({ input: child.stdout, @@ -72,12 +98,13 @@ export class CodexExec { yield line as string; } - const exitCode = new Promise((resolve) => { - child.once("exit", (code) => { + const exitCode = new Promise((resolve, reject) => { + child.once("exit", (code) => { if (code === 0) { resolve(code); } else { - throw new Error(`Codex Exec exited with code ${code}`); + const stderrBuffer = Buffer.concat(stderrChunks); + reject(new Error(`Codex Exec exited with code ${code}: ${stderrBuffer.toString('utf8')}`)); } }); }); diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 3e380e0aea..de2c2c94dc 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -41,6 +41,8 @@ export class Thread { threadId: this.id, model: options?.model, sandboxMode: options?.sandboxMode, + workingDirectory: options?.workingDirectory, + skipGitRepoCheck: options?.skipGitRepoCheck, }); for await (const item of generator) { const parsed = JSON.parse(item) as ThreadEvent; diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts index c414334d19..ce56d7b2c6 100644 --- a/sdk/typescript/src/turnOptions.ts +++ b/sdk/typescript/src/turnOptions.ts @@ -5,4 +5,6 @@ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access" export type TurnOptions = { model?: string; sandboxMode?: SandboxMode; + workingDirectory?: string; + skipGitRepoCheck?: boolean; }; diff --git a/sdk/typescript/tests/codexExecSpy.ts b/sdk/typescript/tests/codexExecSpy.ts index 9028b6e6e5..daf8123cb7 100644 --- a/sdk/typescript/tests/codexExecSpy.ts +++ b/sdk/typescript/tests/codexExecSpy.ts @@ -9,8 +9,7 @@ const actualChildProcess = jest.requireActual("c const spawnMock = child_process.spawn as jest.MockedFunction; export function codexExecSpy(): { args: string[][]; restore: () => void } { - const previousImplementation = - spawnMock.getMockImplementation() ?? actualChildProcess.spawn; + const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn; const args: string[][] = []; spawnMock.mockImplementation(((...spawnArgs: Parameters) => { diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 8c7dff7725..7c243b8e79 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -1,3 +1,5 @@ +import fs from "fs"; +import os from "os"; import path from "path"; import { codexExecSpy } from "./codexExecSpy"; @@ -211,14 +213,79 @@ describe("Codex", () => { expectPair(commandArgs, ["--sandbox", "workspace-write"]); expectPair(commandArgs, ["--model", "gpt-test-1"]); - } finally { restore(); await close(); } }); -}); + it("runs in provided working directory", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Working directory applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); + const client = new Codex({ + codexPathOverride: codexExecPath, + baseUrl: url, + apiKey: "test", + }); + + const thread = client.startThread(); + await thread.run("use custom working directory", { + workingDirectory, + skipGitRepoCheck: true, + }); + + const commandArgs = spawnArgs[0]; + expectPair(commandArgs, ["--cd", workingDirectory]); + } finally { + restore(); + await close(); + } + }); + + it("throws if working directory is not git and no skipGitRepoCheck is provided", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Working directory applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + try { + const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); + const client = new Codex({ + codexPathOverride: codexExecPath, + baseUrl: url, + apiKey: "test", + }); + + const thread = client.startThread(); + await expect( + thread.run("use custom working directory", { + workingDirectory, + }), + ).rejects.toThrow(/Not inside a trusted directory/); + } finally { + await close(); + } + }); +}); function expectPair(args: string[] | undefined, pair: [string, string]) { if (!args) {