SDK: support working directory and skipGitRepoCheck options (#4563)

Make options not required, add support for working directory and
skipGitRepoCheck options on the turn
This commit is contained in:
pakrym-oai
2025-10-01 11:26:49 -07:00
committed by GitHub
parent 400a5a90bf
commit 8a367ef6bf
8 changed files with 114 additions and 19 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -2,4 +2,5 @@ export type CodexOptions = {
codexPathOverride?: string;
baseUrl?: string;
apiKey?: string;
workingDirectory?: string;
};

View File

@@ -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,10 +39,16 @@ export class CodexExec {
commandArgs.push("--sandbox", args.sandboxMode);
}
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, args.input);
} else {
commandArgs.push(args.input);
commandArgs.push("resume", args.threadId);
}
const env = {
@@ -56,10 +68,24 @@ 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) => {
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')}`));
}
});
});

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -9,8 +9,7 @@ const actualChildProcess = jest.requireActual<typeof import("child_process")>("c
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
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<typeof child_process.spawn>) => {

View File

@@ -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) {