diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6e5fd3516e0b..9a8522a4428a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -719,7 +719,7 @@ function codexReplayPreamble(input: { ]; } -/** Every turn/start follows a config/read; inserts that exchange and shifts later client ids. */ +/** Every turn/start follows a config/read; inserts that exchange where a test has not. */ function withConfigReadBeforeTurnStart( entries: ReadonlyArray, ): Array { @@ -737,7 +737,11 @@ function withConfigReadBeforeTurnStart( offset = 0; clientIds = new Map(); } - if (frame.method === "turn/start") { + const previous = result.at(-1); + if ( + frame.method === "turn/start" && + !(previous?.type === "emit_inbound" && previous.label === "config/read") + ) { const id = frame.id + offset; const params = Predicate.isObject(frame.params) ? frame.params : {}; result.push( @@ -1795,6 +1799,125 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + // regression: #13987 sandboxed turns keep the configured network access and writable roots + it.effect.each([ + { runtimeMode: "auto", approvalsReviewer: "auto_review" }, + { runtimeMode: "auto-accept-edits", approvalsReviewer: "user" }, + ] as const)( + "keeps the thread's configured workspace-write sandbox on $runtimeMode turns", + ({ runtimeMode, approvalsReviewer }) => + Effect.gen(function* () { + const scenario = `configured-sandbox-${runtimeMode}`; + const configuredSandbox = { + type: "workspaceWrite", + writableRoots: ["/shared/worktrees"], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }; + const entries = codexReplayPreamble({ + nativeThreadId: `${scenario}-thread`, + nativeTurnId: `${scenario}-turn`, + prompt: "fetch the API", + }).map((entry): CodexReplay.CodexAppServerReplayEntry => { + if (entry.type === "runtime_exit" || !Predicate.isObject(entry.frame)) return entry; + const frame = entry.frame; + // Without a sandbox mode, Codex starts untrusted projects read-only. + if (entry.label === "thread/start" && Predicate.isObject(frame.result)) { + return { + ...entry, + frame: { + ...frame, + result: { ...frame.result, sandbox: { type: "readOnly", networkAccess: false } }, + }, + }; + } + if (entry.type === "expect_outbound" && entry.label === "turn/start") { + const params = Predicate.isObject(frame.params) ? frame.params : {}; + return { + ...entry, + frame: { + ...frame, + params: { + ...params, + approvalPolicy: "on-request", + approvalsReviewer, + sandboxPolicy: configuredSandbox, + }, + }, + }; + } + return entry; + }); + const transcript = makeCodexReplayTranscript({ + scenario, + entries: withConfigReadBeforeTurnStart(entries), + }); + const configured = { + ...transcript, + entries: transcript.entries.map((entry): CodexReplay.CodexAppServerReplayEntry => + entry.type === "emit_inbound" && + entry.label === "config/read" && + Predicate.isObject(entry.frame) + ? { + ...entry, + frame: { + ...entry.frame, + result: { + config: { + sandbox_workspace_write: { + writable_roots: ["/shared/worktrees"], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }, + origins: {}, + }, + }, + } + : entry, + ), + }; + const turnStarts: Array = []; + const harness = yield* makeCodexReplayHarness( + configured, + () => Effect.void, + (method, params) => + Effect.sync(() => { + if (method === "turn/start") turnStarts.push(params); + }), + ); + yield* harness.runtime + .startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-${scenario}`), + text: "fetch the API", + }), + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...CODEX_TEST_RUNTIME_POLICY, + runtimeMode, + }), + }) + .pipe(Effect.exit); + assert.lengthOf(turnStarts, 1); + assert.deepPropertyVal(turnStarts[0], "sandboxPolicy", configuredSandbox); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15839 launch-arg MCP servers survive the thread's t3-code server it.effect("replays historical and dotted MCP config while enforcing other thread config", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4e8fd1ac5f30..aeaccb30400d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -729,7 +729,16 @@ const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, ); -function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { +type CodexWorkspaceWriteSandbox = Omit< + Extract, + "type" +>; + +function codexRuntimeModeTurnDefaults( + runtimeMode: RuntimeMode, + // A bare workspaceWrite turn override resets network access and writable roots to off. + configuredWorkspaceWriteSandbox: CodexWorkspaceWriteSandbox | undefined, +): { readonly approvalPolicy: CodexSchema.V2TurnStartParams__AskForApproval; readonly approvalsReviewer: CodexSchema.V2TurnStartParams__ApprovalsReviewer; readonly sandboxPolicy: CodexSchema.V2TurnStartParams__SandboxPolicy; @@ -747,17 +756,13 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { return { approvalPolicy: "on-request", approvalsReviewer: "user", - sandboxPolicy: { - type: "workspaceWrite", - }, + sandboxPolicy: { ...configuredWorkspaceWriteSandbox, type: "workspaceWrite" }, }; case "auto": return { approvalPolicy: "on-request", approvalsReviewer: "auto_review", - sandboxPolicy: { - type: "workspaceWrite", - }, + sandboxPolicy: { ...configuredWorkspaceWriteSandbox, type: "workspaceWrite" }, }; case "full-access": return { @@ -784,8 +789,13 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; /** The user's `model_reasoning_summary` from the Codex config, when set. */ readonly configuredReasoningSummary?: CodexSchema.V2TurnStartParams__ReasoningSummary; + /** The user's `sandbox_workspace_write` from the Codex config, when set. */ + readonly configuredWorkspaceWriteSandbox?: CodexWorkspaceWriteSandbox; }) { - const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); + const runtimeModeDefaults = codexRuntimeModeTurnDefaults( + input.runtimePolicy.runtimeMode, + input.configuredWorkspaceWriteSandbox, + ); const approvalPolicy = input.runtimePolicy.approvalPolicy === undefined ? runtimeModeDefaults.approvalPolicy @@ -7240,14 +7250,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); - const configuredReasoningSummary = yield* client + const config = yield* client .request("config/read", { cwd: turnInput.runtimePolicy.cwd, includeLayers: false }) .pipe( - Effect.map((response) => response.config.model_reasoning_summary ?? undefined), + Effect.map((response) => response.config), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined)), ), ); + const configuredReasoningSummary = config?.model_reasoning_summary ?? undefined; + const workspaceWrite = config?.sandbox_workspace_write ?? undefined; const turnStartParams = yield* buildCodexTurnStartParams({ nativeThreadId: threadId, codexInput, @@ -7259,6 +7271,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( omitServiceTier: adapterOptions.resolveRuntime !== undefined, ...(turnInput.appContext === undefined ? {} : { appContext: turnInput.appContext }), ...(configuredReasoningSummary === undefined ? {} : { configuredReasoningSummary }), + ...(workspaceWrite === undefined + ? {} + : { + configuredWorkspaceWriteSandbox: { + ...(workspaceWrite.writable_roots === undefined + ? {} + : { writableRoots: workspaceWrite.writable_roots }), + ...(workspaceWrite.network_access === undefined + ? {} + : { networkAccess: workspaceWrite.network_access }), + ...(workspaceWrite.exclude_tmpdir_env_var === undefined + ? {} + : { excludeTmpdirEnvVar: workspaceWrite.exclude_tmpdir_env_var }), + ...(workspaceWrite.exclude_slash_tmp === undefined + ? {} + : { excludeSlashTmp: workspaceWrite.exclude_slash_tmp }), + }, + }), }); yield* Ref.update(pendingRootTurns, (current) => { const updated = new Map(current);