Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* @typedef {import("./app-server-protocol").ThreadStartParams} ThreadStartParams
* @typedef {import("./app-server-protocol").Turn} Turn
* @typedef {import("./app-server-protocol").UserInput} UserInput
* @typedef {{ sandbox_workspace_write: { network_access: true } } | null} ThreadConfigOverride
* @typedef {((update: string | { message: string, phase: string | null, threadId?: string | null, turnId?: string | null, stderrMessage?: string | null, logTitle?: string | null, logBody?: string | null }) => void)} ProgressReporter
* @typedef {{
* threadId: string,
Expand Down Expand Up @@ -50,6 +51,7 @@ const DEFAULT_CONTINUE_PROMPT =
"Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
const threadConfigOverrides = new WeakMap();

function cleanCodexStderr(stderr) {
return stderr
Expand All @@ -59,27 +61,60 @@ function cleanCodexStderr(stderr) {
.join("\n");
}

/** @returns {Promise<ThreadConfigOverride>} */
function getThreadConfigOverride(client, cwd) {
let configPromise = threadConfigOverrides.get(client);
if (configPromise) {
return configPromise;
}

configPromise = Promise.resolve()
.then(() =>
client.request("config/read", {
includeLayers: false,
cwd
})
)
.then((response) => {
if (response?.config?.sandbox_workspace_write?.network_access !== true) {
return null;
}
return { sandbox_workspace_write: { network_access: true } };
})
.catch(() => null);
threadConfigOverrides.set(client, configPromise);
return configPromise;
}

/** @returns {ThreadStartParams} */
function buildThreadParams(cwd, options = {}) {
return {
const params = {
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
sandbox: options.sandbox ?? "read-only",
serviceName: SERVICE_NAME,
ephemeral: options.ephemeral ?? true
};
if (options.config) {
params.config = options.config;
}
return params;
}

/** @returns {ThreadResumeParams} */
function buildResumeParams(threadId, cwd, options = {}) {
return {
const params = {
threadId,
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
sandbox: options.sandbox ?? "read-only"
};
if (options.config) {
params.config = options.config;
}
return params;
}

/** @returns {UserInput[]} */
Expand Down Expand Up @@ -730,7 +765,8 @@ async function requestExternalAgentSessionImport(client, params) {
}

async function startThread(client, cwd, options = {}) {
const response = await client.request("thread/start", buildThreadParams(cwd, options));
const config = await getThreadConfigOverride(client, cwd);
const response = await client.request("thread/start", buildThreadParams(cwd, { ...options, config }));
const threadId = response.thread.id;
if (options.threadName) {
try {
Expand All @@ -748,7 +784,8 @@ async function startThread(client, cwd, options = {}) {
}

async function resumeThread(client, threadId, cwd, options = {}) {
return client.request("thread/resume", buildResumeParams(threadId, cwd, options));
const config = await getThreadConfigOverride(client, cwd);
return client.request("thread/resume", buildResumeParams(threadId, cwd, { ...options, config }));
}

function buildResultStatus(turnState) {
Expand Down
27 changes: 26 additions & 1 deletion tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const readline = require("node:readline");

function loadState() {
if (!fs.existsSync(STATE_PATH)) {
return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null };
return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], threadStarts: [], threadResumes: [], capabilities: null, lastInterrupt: null };
}
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
}
Expand Down Expand Up @@ -85,6 +85,26 @@ function buildAccountReadResult() {

function buildConfigReadResult() {
switch (BEHAVIOR) {
case "network-access-enabled":
return {
config: { model_provider: "openai", sandbox_workspace_write: { network_access: true } },
origins: {}
};
case "network-access-disabled":
return {
config: { model_provider: "openai", sandbox_workspace_write: { network_access: false } },
origins: {}
};
case "network-access-absent":
return {
config: { model_provider: "openai" },
origins: {}
};
case "network-access-null":
return {
config: { model_provider: "openai", sandbox_workspace_write: null },
origins: {}
};
case "provider-no-auth":
return {
config: { model_provider: "ollama" },
Expand Down Expand Up @@ -313,6 +333,9 @@ rl.on("line", (line) => {
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
}
const thread = nextThread(state, message.params.cwd, message.params.ephemeral);
state.threadStarts = state.threadStarts || [];
state.threadStarts.push(message.params);
saveState(state);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
send({ method: "thread/started", params: { thread: { id: thread.id } } });
break;
Expand Down Expand Up @@ -346,6 +369,8 @@ rl.on("line", (line) => {
}
const thread = ensureThread(state, message.params.threadId);
thread.updatedAt = now();
state.threadResumes = state.threadResumes || [];
state.threadResumes.push(message.params);
saveState(state);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
break;
Expand Down
58 changes: 58 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,64 @@ test("task runs without auth preflight so Codex can refresh an expired session",
assert.match(result.stdout, /Handled the requested task/);
});

test("task threads honor sandbox workspace network access on start and resume", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
const statePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir, "network-access-enabled");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const firstRun = run("node", [SCRIPT, "task", "--write", "initial task"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(firstRun.status, 0, firstRun.stderr);

const resumedRun = run("node", [SCRIPT, "task", "--write", "--resume", "follow up"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(resumedRun.status, 0, resumedRun.stderr);

const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
assert.deepEqual(fakeState.threadStarts[0].config, {
sandbox_workspace_write: { network_access: true }
});
assert.deepEqual(fakeState.threadResumes[0].config, {
sandbox_workspace_write: { network_access: true }
});
});

test("task threads omit sandbox network config when it is disabled, absent, null, or unreadable", () => {
for (const behavior of [
"network-access-disabled",
"network-access-absent",
"network-access-null",
"config-read-fails"
]) {
const repo = makeTempDir();
const binDir = makeTempDir();
const statePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir, behavior);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const result = run("node", [SCRIPT, "task", "--write", "initial task"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, `${behavior}: ${result.stderr}`);

const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
assert.equal("config" in fakeState.threadStarts[0], false, behavior);
}
});

test("transfer delegates the current Claude session directly to native import", () => {
const home = makeTempDir();
const repo = path.join(home, "repo");
Expand Down