Skip to content

Commit 8a367ef

Browse files
authored
SDK: support working directory and skipGitRepoCheck options (#4563)
Make options not required, add support for working directory and skipGitRepoCheck options on the turn
1 parent 400a5a9 commit 8a367ef

File tree

8 files changed

+114
-19
lines changed

8 files changed

+114
-19
lines changed

sdk/typescript/eslint.config.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import eslint from '@eslint/js';
2-
import { defineConfig } from 'eslint/config';
3-
import tseslint from 'typescript-eslint';
1+
import eslint from "@eslint/js";
2+
import { defineConfig } from "eslint/config";
3+
import tseslint from "typescript-eslint";
44

5-
export default defineConfig(
6-
eslint.configs.recommended,
7-
tseslint.configs.recommended,
8-
);
5+
export default defineConfig(eslint.configs.recommended, tseslint.configs.recommended);

sdk/typescript/src/codex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export class Codex {
66
private exec: CodexExec;
77
private options: CodexOptions;
88

9-
constructor(options: CodexOptions) {
9+
constructor(options: CodexOptions = {}) {
1010
this.exec = new CodexExec(options.codexPathOverride);
1111
this.options = options;
1212
}

sdk/typescript/src/codexOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export type CodexOptions = {
22
codexPathOverride?: string;
33
baseUrl?: string;
44
apiKey?: string;
5+
workingDirectory?: string;
56
};

sdk/typescript/src/exec.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ export type CodexExecArgs = {
1212
baseUrl?: string;
1313
apiKey?: string;
1414
threadId?: string | null;
15+
// --model
1516
model?: string;
17+
// --sandbox
1618
sandboxMode?: SandboxMode;
19+
// --cd
20+
workingDirectory?: string;
21+
// --skip-git-repo-check
22+
skipGitRepoCheck?: boolean;
1723
};
1824

1925
export class CodexExec {
@@ -33,12 +39,18 @@ export class CodexExec {
3339
commandArgs.push("--sandbox", args.sandboxMode);
3440
}
3541

36-
if (args.threadId) {
37-
commandArgs.push("resume", args.threadId, args.input);
38-
} else {
39-
commandArgs.push(args.input);
42+
if (args.workingDirectory) {
43+
commandArgs.push("--cd", args.workingDirectory);
44+
}
45+
46+
if (args.skipGitRepoCheck) {
47+
commandArgs.push("--skip-git-repo-check");
4048
}
4149

50+
if (args.threadId) {
51+
commandArgs.push("resume", args.threadId);
52+
}
53+
4254
const env = {
4355
...process.env,
4456
};
@@ -55,11 +67,25 @@ export class CodexExec {
5567

5668
let spawnError: unknown | null = null;
5769
child.once("error", (err) => (spawnError = err));
70+
71+
if (!child.stdin) {
72+
child.kill();
73+
throw new Error("Child process has no stdin");
74+
}
75+
child.stdin.write(args.input);
76+
child.stdin.end();
5877

5978
if (!child.stdout) {
6079
child.kill();
6180
throw new Error("Child process has no stdout");
6281
}
82+
const stderrChunks: Buffer[] = [];
83+
84+
if (child.stderr) {
85+
child.stderr.on("data", (data) => {
86+
stderrChunks.push(data);
87+
});
88+
}
6389

6490
const rl = readline.createInterface({
6591
input: child.stdout,
@@ -72,12 +98,13 @@ export class CodexExec {
7298
yield line as string;
7399
}
74100

75-
const exitCode = new Promise((resolve) => {
76-
child.once("exit", (code) => {
101+
const exitCode = new Promise((resolve, reject) => {
102+
child.once("exit", (code) => {
77103
if (code === 0) {
78104
resolve(code);
79105
} else {
80-
throw new Error(`Codex Exec exited with code ${code}`);
106+
const stderrBuffer = Buffer.concat(stderrChunks);
107+
reject(new Error(`Codex Exec exited with code ${code}: ${stderrBuffer.toString('utf8')}`));
81108
}
82109
});
83110
});

sdk/typescript/src/thread.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export class Thread {
4141
threadId: this.id,
4242
model: options?.model,
4343
sandboxMode: options?.sandboxMode,
44+
workingDirectory: options?.workingDirectory,
45+
skipGitRepoCheck: options?.skipGitRepoCheck,
4446
});
4547
for await (const item of generator) {
4648
const parsed = JSON.parse(item) as ThreadEvent;

sdk/typescript/src/turnOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"
55
export type TurnOptions = {
66
model?: string;
77
sandboxMode?: SandboxMode;
8+
workingDirectory?: string;
9+
skipGitRepoCheck?: boolean;
810
};

sdk/typescript/tests/codexExecSpy.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ const actualChildProcess = jest.requireActual<typeof import("child_process")>("c
99
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
1010

1111
export function codexExecSpy(): { args: string[][]; restore: () => void } {
12-
const previousImplementation =
13-
spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
12+
const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
1413
const args: string[][] = [];
1514

1615
spawnMock.mockImplementation(((...spawnArgs: Parameters<typeof child_process.spawn>) => {

sdk/typescript/tests/run.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "fs";
2+
import os from "os";
13
import path from "path";
24

35
import { codexExecSpy } from "./codexExecSpy";
@@ -211,14 +213,79 @@ describe("Codex", () => {
211213

212214
expectPair(commandArgs, ["--sandbox", "workspace-write"]);
213215
expectPair(commandArgs, ["--model", "gpt-test-1"]);
214-
215216
} finally {
216217
restore();
217218
await close();
218219
}
219220
});
220-
});
221221

222+
it("runs in provided working directory", async () => {
223+
const { url, close } = await startResponsesTestProxy({
224+
statusCode: 200,
225+
responseBodies: [
226+
sse(
227+
responseStarted("response_1"),
228+
assistantMessage("Working directory applied", "item_1"),
229+
responseCompleted("response_1"),
230+
),
231+
],
232+
});
233+
234+
const { args: spawnArgs, restore } = codexExecSpy();
235+
236+
try {
237+
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
238+
const client = new Codex({
239+
codexPathOverride: codexExecPath,
240+
baseUrl: url,
241+
apiKey: "test",
242+
});
243+
244+
const thread = client.startThread();
245+
await thread.run("use custom working directory", {
246+
workingDirectory,
247+
skipGitRepoCheck: true,
248+
});
249+
250+
const commandArgs = spawnArgs[0];
251+
expectPair(commandArgs, ["--cd", workingDirectory]);
252+
} finally {
253+
restore();
254+
await close();
255+
}
256+
});
257+
258+
it("throws if working directory is not git and no skipGitRepoCheck is provided", async () => {
259+
const { url, close } = await startResponsesTestProxy({
260+
statusCode: 200,
261+
responseBodies: [
262+
sse(
263+
responseStarted("response_1"),
264+
assistantMessage("Working directory applied", "item_1"),
265+
responseCompleted("response_1"),
266+
),
267+
],
268+
});
269+
270+
try {
271+
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
272+
const client = new Codex({
273+
codexPathOverride: codexExecPath,
274+
baseUrl: url,
275+
apiKey: "test",
276+
});
277+
278+
const thread = client.startThread();
279+
await expect(
280+
thread.run("use custom working directory", {
281+
workingDirectory,
282+
}),
283+
).rejects.toThrow(/Not inside a trusted directory/);
284+
} finally {
285+
await close();
286+
}
287+
});
288+
});
222289

223290
function expectPair(args: string[] | undefined, pair: [string, string]) {
224291
if (!args) {

0 commit comments

Comments
 (0)