diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..4d10aec00 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -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, @@ -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 @@ -59,9 +61,34 @@ function cleanCodexStderr(stderr) { .join("\n"); } +/** @returns {Promise} */ +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", @@ -69,17 +96,25 @@ function buildThreadParams(cwd, options = {}) { 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[]} */ @@ -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 { @@ -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) { diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..e56914a8b 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -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")); } @@ -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" }, @@ -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; @@ -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; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..be0508e82 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -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");