diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index bba467deec417..78622ef75b743 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -748,7 +748,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 any missing exchange and shifts later client ids. */ function withConfigReadBeforeTurnStart( entries: ReadonlyArray, ): Array { @@ -766,7 +766,9 @@ function withConfigReadBeforeTurnStart( offset = 0; clientIds = new Map(); } - if (frame.method === "turn/start") { + const previous = result.at(-1); + const hasConfigRead = previous?.type === "emit_inbound" && previous.label === "config/read"; + if (frame.method === "turn/start" && !hasConfigRead) { const id = frame.id + offset; const params = Predicate.isObject(frame.params) ? frame.params : {}; result.push( @@ -1332,6 +1334,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { promptSuggestions: Effect.Effect = Effect.succeed(false), onNotification: (method: string) => Effect.Effect = () => Effect.void, models?: CodexAdapterV2.CodexAdapterV2Options["models"], + runtimePolicy = CODEX_TEST_RUNTIME_POLICY, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1411,13 +1414,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId, providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + runtimePolicy, promptSuggestions, }); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + runtimePolicy, }); const events: Array = []; const firstTerminal = yield* Deferred.make(); @@ -1760,6 +1763,165 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each([ + { platform: "darwin", runtimeMode: "auto", resolves: true }, + { platform: "darwin", runtimeMode: "full-access", resolves: false }, + { platform: "win32", runtimeMode: "auto", resolves: false }, + ] as const)( + "runs $platform $runtimeMode Codex turns in the physical workspace: $resolves", + ({ platform, runtimeMode, resolves }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-symlink-" }); + const target = `${root}/physical`; + const alias = `${root}/alias`; + yield* fs.makeDirectory(target); + yield* fs.symlink(target, alias); + const turnCwd = resolves ? yield* fs.realPath(target) : alias; + const policy = { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode, cwd: alias }; + const turnDefaults = + runtimeMode === "auto" + ? { + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxPolicy: { type: "workspaceWrite" }, + } + : { + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + }; + const requests: Array = []; + const preamble = codexReplayPreamble({ + nativeThreadId: "physical-workspace", + nativeTurnId: "physical-turn", + prompt: "Inspect the workspace.", + }); + const entries: Array = [ + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "resume alias", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: "physical-workspace", + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "resume alias", + frame: { + id: 3, + result: { thread: { id: "physical-workspace", updatedAt: 1782622440 } }, + }, + }, + // Config reads keep the registered path, as the thread requests do. + { + type: "expect_outbound", + label: "config/read", + frame: { + id: 4, + method: "config/read", + params: { cwd: "/workspace", includeLayers: false }, + }, + }, + { + type: "emit_inbound", + label: "config/read", + frame: { id: 4, result: { config: {}, origins: {} } }, + }, + { + type: "expect_outbound", + label: "turn/start", + frame: { + id: 5, + method: "turn/start", + params: { + threadId: "physical-workspace", + input: [{ type: "text", text: "Inspect the workspace." }], + cwd: "/turn-workspace", + model: "gpt-5.4", + ...turnDefaults, + summary: "auto", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + }, + }, + }, + ...preamble + .slice(6) + .map((entry) => + entry.type !== "runtime_exit" && Predicate.hasProperty(entry.frame, "id") + ? withReplayRequestId(entry, 5) + : entry, + ), + ]; + const transcript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson( + makeCodexReplayTranscript({ + scenario: `physical-workspace-${platform}-${runtimeMode}`, + entries, + }), + )) + .replaceAll(yield* encodeStringJson("/workspace"), yield* encodeStringJson(alias)) + .replaceAll( + yield* encodeStringJson("/turn-workspace"), + yield* encodeStringJson(turnCwd), + ), + ); + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method, params) => + Effect.sync(() => { + if (method === "thread/start" || method === "thread/resume") requests.push(params); + }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + policy, + ); + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: policy, + modelSelection: CODEX_TEST_MODEL_SELECTION, + }); + assert.lengthOf(requests, 2); + // Codex keys project trust and config on the thread cwd, so thread requests keep the alias. + for (const request of requests) assert.propertyVal(request, "cwd", alias); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("physical-attempt"), + text: "Inspect the workspace.", + }), + runtimePolicy: policy, + }); + assert.equal(policy.cwd, alias); + assert.equal(harness.runtime.providerSession.cwd, alias); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.Platform, platform), + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("adds the session MCP server without replacing launch-argument servers", () => Effect.gen(function* () { const scenario = "launch-mcp-servers"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index e9b41ac8a5b05..6c1327314e667 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -95,6 +95,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -1777,6 +1778,27 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const { clientFactory, digest, fileSystem, idAllocator, serverConfig } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests; + // macOS Seatbelt refuses a symlinked writable root, so workspace-write turns run in the physical + // directory. Thread requests keep the registered path for Codex's project trust and config. + const resolveCodexTurnCwd = Effect.fn("CodexAdapterV2.resolveCodexTurnCwd")(function* ( + policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) { + const cwd = policy.cwd; + const sandboxPolicy = + policy.sandboxPolicy ?? codexRuntimeModeTurnDefaults(policy.runtimeMode).sandboxPolicy; + if ( + cwd === null || + (yield* HostProcess.Platform) !== "darwin" || + !Predicate.hasProperty(sandboxPolicy, "type") || + sandboxPolicy.type !== "workspaceWrite" + ) + return policy; + return { + ...policy, + cwd: yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)), + }; + }); + return ProviderAdapter.ProviderAdapterV2.of({ instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, @@ -7269,7 +7291,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( cyberAccess: codexCyberAccess(models, turnInput.modelSelection.model), nativeThreadId: threadId, codexInput, - runtimePolicy: turnInput.runtimePolicy, + runtimePolicy: yield* resolveCodexTurnCwd(turnInput.runtimePolicy), modelSelection: turnInput.modelSelection, hasT3Mcp: mcpSession !== undefined, browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,