diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 294fab806f45..596f89c2eabd 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -328,20 +328,6 @@ const bootstrap = Effect.gen(function* () { baseUrl: remoteHttpBaseUrl.href, }); yield* desktopWindow.handleBackendReady(remoteHttpBaseUrl); - - // Keep the native Windows backend available as a secondary environment - // while the remote server remains the renderer's primary backend. - const localBackend = yield* pool.get(DesktopBackendPool.WINDOWS_SECONDARY_INSTANCE_ID); - if (Option.isSome(localBackend)) { - const backendPortSelection = yield* resolveDesktopBackendPort( - environment.configuredBackendPort, - ); - yield* serverExposure.configureFromSettings({ port: backendPortSelection.port }); - yield* localBackend.value.start; - yield* logBootstrapInfo("bootstrap Windows secondary start requested", { - port: backendPortSelection.port, - }); - } } return; } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 8708e693195d..3caf1713eeac 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -151,7 +151,7 @@ describe("DesktopBackendPool", () => { ), ); - it.effect("keeps Windows registered as a secondary for a remote primary", () => + it.effect("does not register a local backend for a remote primary", () => Effect.scoped( Effect.gen(function* () { const labelRef = yield* Ref.make("Windows"); @@ -166,10 +166,7 @@ describe("DesktopBackendPool", () => { ); assert.isTrue(Option.isNone(yield* pool.primary)); - assert.deepEqual( - (yield* pool.list).map((instance) => instance.id), - [DesktopBackendPool.WINDOWS_SECONDARY_INSTANCE_ID], - ); + assert.deepEqual(yield* pool.list, []); }), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index b81c54ddf7c5..bff77a7ab977 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -108,7 +108,6 @@ const { logWarning: logBackendPoolWarning } = export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; -export const WINDOWS_SECONDARY_INSTANCE_ID = BackendInstanceId("windows:local"); export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; @@ -307,43 +306,19 @@ export const layer = Layer.effect( }), ); - // A normal desktop using a remote primary still keeps its native backend - // available as a secondary execution environment. The remote-only build is - // intentionally excluded: that distribution must never launch local work. - const windowsSecondary = - !isRemoteOnlyDesktopBuild && startupPlan.remoteOnly - ? Option.some( - yield* DesktopBackendManager.makeBackendInstance({ - id: WINDOWS_SECONDARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - configResolve: configuration.resolvePrimary, - }), - ) - : Option.none(); - const instancesRef = yield* SynchronizedRef.make< ReadonlyMap >( - new Map([ - ...Option.match(primary, { - onNone: () => [], - onSome: (instance) => [ + Option.match(primary, { + onNone: () => new Map(), + onSome: (instance) => + new Map([ [ DesktopBackendManager.PRIMARY_INSTANCE_ID, { _tag: "Active" as const, instance, scope: Option.none() }, - ] as const, - ], - }), - ...Option.match(windowsSecondary, { - onNone: () => [], - onSome: (instance) => [ - [ - WINDOWS_SECONDARY_INSTANCE_ID, - { _tag: "Active" as const, instance, scope: Option.none() }, - ] as const, - ], - }), - ]), + ], + ]), + }), ); const register: DesktopBackendPool["Service"]["register"] = (spec) => diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 7fc6e233db5a..6f3cbb97f69a 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -50,27 +50,6 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { waitForReady: () => Effect.succeed(true), }; -const { runningDistro: _runningDistro, ...readyConfigWithoutDistro } = readyWslConfig; -const windowsSecondaryInstance: DesktopBackendManager.DesktopBackendInstance = { - ...defaultWslInstance, - id: DesktopBackendPool.WINDOWS_SECONDARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - currentConfig: Effect.succeed( - Option.some({ - ...readyConfigWithoutDistro, - executablePath: "electron.exe", - args: ["server.mjs"], - entryPath: "server.mjs", - bootstrap: { - ...readyWslConfig.bootstrap, - port: 3773, - host: "127.0.0.1", - }, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - }), - ), -}; - const desktopSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), } as unknown as DesktopAppSettings.DesktopAppSettings["Service"]); @@ -100,39 +79,6 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(Layer.merge(DesktopBackendPool.layerTest([]), remoteSettingsLayer))); }); - it.effect("publishes Windows as a secondary while the remote server remains primary", () => { - const remoteSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.succeed({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - primaryBackendMode: "remote", - remoteBackendUrl: "https://remote.example.test/", - }), - } as unknown as DesktopAppSettings.DesktopAppSettings["Service"]); - return Effect.gen(function* () { - assert.deepEqual(yield* getLocalEnvironmentBootstraps.handler(), [ - { - id: "primary", - label: "remote.example.test", - runningDistro: null, - httpBaseUrl: "https://remote.example.test/", - wsBaseUrl: "wss://remote.example.test/", - }, - { - id: "windows:local", - label: "Windows", - runningDistro: null, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - bootstrapToken: "bootstrap-token", - }, - ]); - }).pipe( - Effect.provide( - Layer.merge(DesktopBackendPool.layerTest([windowsSecondaryInstance]), remoteSettingsLayer), - ), - ); - }); - it.effect("publishes the concrete running distro without replacing the stable instance id", () => Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 6684df149efa..a3e577f17a62 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -198,11 +198,7 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); const settings = yield* appSettings.get; - if ( - settings.primaryBackendMode === "remote" && - !isLocalExecutionOverride() && - targetId !== DesktopBackendPool.WINDOWS_SECONDARY_INSTANCE_ID - ) { + if (settings.primaryBackendMode === "remote" && !isLocalExecutionOverride()) { // A native Windows folder path is meaningless to the remote Linux // environment. Remote project selection stays server-side. return null; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 05370781c0d0..4881362315a1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -30,4 +30,30 @@ describe("runtimeEventToActivities approval details", () => { expect(activity?.kind).toBe("approval.requested"); expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + + it("gives a sandbox permission request an answerable request kind", () => { + const event = { + type: "request.opened", + eventId: EventId.make("evt-permissions-opened"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-07-18T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-perm-1"), + payload: { + requestType: "permissions_approval", + detail: "read: /workspace/src", + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + const payload = activity?.payload as Record | undefined; + + expect(activity?.kind).toBe("approval.requested"); + // Without a requestKind the web and mobile approval folds never render a + // prompt, while the server still opens a pending row — the thread parks + // with no way to answer it. + expect(payload?.requestKind).toBe("file-change"); + expect(activity?.summary).toBe("Sandbox permission approval requested"); + expect(payload?.detail).toBe("read: /workspace/src"); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2f078bea7b2b..5fc5d7e544dd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -718,6 +718,10 @@ function requestKindFromCanonicalRequestType( return "file-read"; case "file_change_approval": case "apply_patch_approval": + // A Codex sandbox escalation can grant writes, so it rides the file-change + // kind. Must match CODEX_PERMISSION_REQUEST_KIND, which the session runtime + // stamps on the request itself. + case "permissions_approval": return "file-change"; default: return undefined; @@ -793,13 +797,15 @@ export function runtimeEventToActivities( tone: "approval", kind: "approval.requested", summary: - requestKind === "command" - ? "Command approval requested" - : requestKind === "file-read" - ? "File-read approval requested" - : requestKind === "file-change" - ? "File-change approval requested" - : "Approval requested", + event.payload.requestType === "permissions_approval" + ? "Sandbox permission approval requested" + : requestKind === "command" + ? "Command approval requested" + : requestKind === "file-read" + ? "File-read approval requested" + : requestKind === "file-change" + ? "File-change approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index d54d8abb52d4..53fc6ebc5746 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -286,6 +286,15 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory }); const validationRuntimeFactory = makeRuntimeFactory(); +// Isolation requires a source auth.json before any session starts, so these +// validation cases need a real home even though none of them assert on auth. +const validationSourceHomePath = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "cc-codex-validation-home-"), +); +NodeFS.writeFileSync( + NodePath.join(validationSourceHomePath, "auth.json"), + '{"token":"test-only"}\n', +); const validationLayer = it.layer( Layer.effect( CodexAdapter, @@ -293,7 +302,7 @@ const validationLayer = it.layer( const codexConfig = decodeCodexSettings({}); return yield* makeCodexAdapter(codexConfig, { makeRuntime: validationRuntimeFactory.factory, - commandCenterSourceHomePath: NodePath.join(process.cwd(), ".missing-command-center-auth"), + commandCenterSourceHomePath: validationSourceHomePath, commandCenterRuntimeExecutablePath: process.execPath, }); }), @@ -444,6 +453,7 @@ it.effect("fails closed when elevated Windows Command Center isolation cannot be const runtimePath = NodePath.join(tempDir, "codex.exe"); const sourceHomePath = NodePath.join(tempDir, "codex-home"); NodeFS.mkdirSync(sourceHomePath, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(sourceHomePath, "auth.json"), '{"token":"test-only"}\n'); NodeFS.writeFileSync(runtimePath, Uint8Array.from([0x4d, 0x5a, 0x00, 0x00])); const runtimeFactory = makeRuntimeFactory({ @@ -2161,6 +2171,54 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("surfaces a sandbox permission request with the paths it asks for", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: asEventId("evt-permissions-request"), + kind: "request", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/permissions/requestApproval", + requestKind: "file-change", + requestId: ApprovalRequestId.make("req-perm-1"), + payload: { + cwd: "/workspace", + itemId: "item-1", + permissions: { + fileSystem: { + entries: [{ access: "write", path: { type: "path", path: "/workspace/dist" } }], + }, + }, + reason: "write build output", + startedAtMs: 0, + threadId: "thread-1", + turnId: "turn-1", + }, + }; + + yield* runtime.emit(event); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "request.opened"); + if (firstEvent.value.type !== "request.opened") { + return; + } + // Before this method was mapped it fell through to "unknown", which the + // web and mobile approval folds silently drop. + NodeAssert.equal(firstEvent.value.payload.requestType, "permissions_approval"); + NodeAssert.match(firstEvent.value.payload.detail ?? "", /write: \/workspace\/dist/u); + NodeAssert.match(firstEvent.value.payload.detail ?? "", /write build output/u); + }), + ); + it.effect("preserves file-read request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 49d16d2d25b5..dc19b8bc6a72 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -82,6 +82,7 @@ import { resolveCommandCenterCodexRuntimeExecutable, resolveCommandCenterManagedGitMetadata, } from "../security/CommandCenterProviderIsolation.ts"; +import { describeCodexPermissionRequest } from "../security/CodexPermissionEscalation.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); @@ -453,6 +454,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "exec_command_approval"; case "item/tool/requestUserInput": return "tool_user_input"; + case "item/permissions/requestApproval": + return "permissions_approval"; case "item/tool/call": return "dynamic_tool_call"; case "account/chatgptAuthTokens/refresh": @@ -1120,6 +1123,15 @@ function mapToRuntimeEvents( ); return payload?.reason ?? payload?.command.join(" "); } + case "item/permissions/requestApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__PermissionsRequestApprovalParams, + event.payload, + ); + // The persisted approval activity keeps only `detail`, so the + // requested paths are folded in here or the prompt is unreviewable. + return payload ? describeCodexPermissionRequest(payload) : undefined; + } case "item/tool/call": { const payload = readPayload( EffectCodexSchema.ServerRequest__DynamicToolCallParams, @@ -2009,7 +2021,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? yield* ( options?.commandCenterRuntimeExecutablePath ? Effect.succeed(options.commandCenterRuntimeExecutablePath) - : resolveCommandPath(codexConfig.binaryPath, { + : resolveCommandPath("codex", { env: sourceEnvironment, extendEnv: false, }).pipe( @@ -2160,6 +2172,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(commandCenterIsolation ? { permissionProfile: commandCenterIsolation.permissionProfile } : {}), + ...(codexConfig.autoApproveReadOnlyPermissions + ? { autoApproveReadOnlyPermissions: true } + : {}), ...(commandCenterIsolation?.windowsSandboxMode ? { commandCenterPlatform: hostPlatform, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 7bf274292a7e..bfa4a58a1e2d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -16,10 +16,12 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildCommandCenterDarwinIsolationProbeScript, + buildCommandCenterIsolationProbeScript, buildCommandCenterWindowsIsolationProbeScript, buildTurnStartParams, ensureCommandCenterWindowsSandbox, hasConfiguredMcpServer, + isCommandCenterIsolationProbeAccepted, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; @@ -73,15 +75,44 @@ describe("Command Center native sandbox admission", () => { ); it("builds native probes without Linux-only process assumptions", () => { + const linux = buildCommandCenterIsolationProbeScript(true); const darwin = buildCommandCenterDarwinIsolationProbeScript(true); const windows = buildCommandCenterWindowsIsolationProbeScript(false); + NodeAssert.doesNotMatch(linux, /\/proc\//u); + NodeAssert.match(linux, /CC_PROVIDER_ISOLATION_SENTINEL/u); NodeAssert.doesNotMatch(darwin, /\/proc\//u); NodeAssert.match(darwin, /HOME\/auth\.json/u); NodeAssert.match(windows, /USERPROFILE/u); NodeAssert.match(windows, /WriteAllText/u); NodeAssert.match(windows, /exit 73/u); }); + + it("admits both shell-reported and sandbox-enforced read denials after the marker", () => { + const linux = buildCommandCenterIsolationProbeScript(false); + NodeAssert.ok( + linux.indexOf("command-center-isolation-read-denial-ready") < + linux.indexOf(': > "$probe_path"'), + ); + NodeAssert.equal( + isCommandCenterIsolationProbeAccepted(false, { + exitCode: 1, + stdout: "command-center-isolation-read-denial-ready\n", + }), + true, + ); + NodeAssert.equal( + isCommandCenterIsolationProbeAccepted(false, { + exitCode: 74, + stdout: "command-center-isolation-read-denial-ready\n", + }), + false, + ); + NodeAssert.equal( + isCommandCenterIsolationProbeAccepted(false, { exitCode: 1, stdout: "" }), + false, + ); + }); }); describe("CodexSessionRuntimeIdentifierGenerationError", () => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 8ca217ff28e6..905971173d3d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -49,6 +49,10 @@ import { COMMAND_CENTER_CODEX_READ_PERMISSION_PROFILE, COMMAND_CENTER_CODEX_WRITE_PERMISSION_PROFILE, } from "../security/CommandCenterProviderIsolation.ts"; +import { + CODEX_PERMISSION_REQUEST_KIND, + classifyCodexPermissionRequest, +} from "../security/CodexPermissionEscalation.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const decodeV2CommandExecResponse = Schema.decodeUnknownEffect( EffectCodexSchema.V2CommandExecResponse, @@ -166,6 +170,12 @@ export interface CodexSessionRuntimeOptions { readonly permissionProfile?: string; readonly commandCenterPlatform?: NodeJS.Platform; readonly windowsSandboxMode?: "elevated"; + /** + * Grant read-only sandbox escalations without prompting. Only ever applies to + * requests classified read-only by `classifyCodexPermissionRequest`; writes, + * sandbox denials, and network enables always prompt. + */ + readonly autoApproveReadOnlyPermissions?: boolean; } export interface CodexSessionRuntimeSendTurnInput { @@ -596,6 +606,16 @@ interface CodexIsolationProbeClient { const COMMAND_CENTER_ISOLATION_PROBE_SUCCESS = "command-center-isolation-ok"; const COMMAND_CENTER_ISOLATION_READ_DENIAL_READY = "command-center-isolation-read-denial-ready"; +export function isCommandCenterIsolationProbeAccepted( + writable: boolean, + result: { readonly exitCode: number; readonly stdout: string }, +): boolean { + return writable + ? result.exitCode === 0 && result.stdout.trim() === COMMAND_CENTER_ISOLATION_PROBE_SUCCESS + : [1, 73].includes(result.exitCode) && + result.stdout.trim() === COMMAND_CENTER_ISOLATION_READ_DENIAL_READY; +} + function isCommandCenterPermissionProfile( permissionProfile: string | undefined, ): permissionProfile is @@ -622,23 +642,20 @@ export function buildCommandCenterIsolationProbeScript( : [ 'probe_path=".cc-provider-isolation-probe.$$"', "trap 'rm -f \"$probe_path\"' EXIT", + `printf '${COMMAND_CENTER_ISOLATION_READ_DENIAL_READY}\\n'`, 'if : > "$probe_path" 2>/dev/null; then', ' rm -f "$probe_path"', " trap - EXIT", " exit 74", "fi", - `printf '${COMMAND_CENTER_ISOLATION_READ_DENIAL_READY}\\n'`, "exit 73", ]; return [ "set -eu", - "for environment_file in /proc/[0-9]*/environ; do", - ' if /usr/bin/tr "\\0" "\\n" < "$environment_file" 2>/dev/null | /usr/bin/grep -Eq "^(CC_PROVIDER_ISOLATION_SENTINEL|T3_MCP_BEARER_TOKEN|OPENAI_API_KEY)="; then', - " exit 70", - " fi", - "done", + 'test -z "${CC_PROVIDER_ISOLATION_SENTINEL:-}" || exit 70', + 'test -z "${T3_MCP_BEARER_TOKEN:-}" || exit 70', + 'test -z "${OPENAI_API_KEY:-}" || exit 70', 'test ! -r "$HOME/auth.json" || exit 71', - 'test ! -r "/proc/1/root$HOME/auth.json" || exit 72', 'test ! -r "$HOME/.cc-provider-isolation-canary" || exit 80', ...(blockedNetworkPort === undefined ? [] @@ -854,9 +871,7 @@ export const verifyCommandCenterCodexIsolation = Effect.fn( ); }), ); - const accepted = writable - ? result.exitCode === 0 && result.stdout.trim() === COMMAND_CENTER_ISOLATION_PROBE_SUCCESS - : result.exitCode === 73 && result.stdout.trim() === COMMAND_CENTER_ISOLATION_READ_DENIAL_READY; + const accepted = isCommandCenterIsolationProbeAccepted(writable, result); if (!accepted) { return yield* new CodexSessionRuntimeIsolationProbeError({ issue: @@ -2118,6 +2133,97 @@ export const makeCodexSessionRuntime = ( }), ); + // Sandbox escalation. Codex asks to widen filesystem access and/or re-enable + // network mid-turn. Without this handler the request falls through to + // `handleUnknownServerRequest` below and is answered with a JSON-RPC + // "method not found", which Codex reports as a user rejection — a silent + // denial the user is never shown and cannot answer. + yield* client.handleServerRequest("item/permissions/requestApproval", (payload) => + Effect.gen(function* () { + const classification = classifyCodexPermissionRequest(payload.permissions); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + + // Auto-approve is keyed on the requested permissions, never on the tool + // that triggered them: a read-only tool can still ask for write. The + // grant is echoed back scoped to this turn only, so it never silently + // widens the rest of the session. + if (classification.readOnly && options.autoApproveReadOnlyPermissions === true) { + yield* Effect.logDebug("Auto-approved a read-only Codex sandbox escalation.").pipe( + Effect.annotateLogs({ threadId: options.threadId, itemId: payload.itemId }), + ); + return { + permissions: payload.permissions, + scope: "turn", + } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; + } + + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("permissions-approval-request"), + ); + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId: payload.itemId, + requestKind: CODEX_PERMISSION_REQUEST_KIND, + turnId, + itemId, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.itemId, { + requestId, + requestKind: CODEX_PERMISSION_REQUEST_KIND, + turnId, + itemId, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/permissions/requestApproval", + requestId, + requestKind: CODEX_PERMISSION_REQUEST_KIND, + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + + // The approval channel carries only accept/decline, so the granted + // profile is the requested one echoed back on accept and an empty + // profile on refusal. `acceptForSession` is the one path that persists + // the grant beyond this turn. + if (resolved === "accept" || resolved === "acceptForSession") { + return { + permissions: payload.permissions, + scope: resolved === "acceptForSession" ? "session" : "turn", + } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; + } + return { + permissions: {}, + scope: "turn", + } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; + }), + ); + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dcc3ac0b5db7..4505bfcc1773 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -96,6 +96,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ homePath: "", shadowHomePath: "", launchArgs: "", + autoApproveReadOnlyPermissions: false, customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/security/CodexPermissionEscalation.test.ts b/apps/server/src/provider/security/CodexPermissionEscalation.test.ts new file mode 100644 index 000000000000..2f5c82b42710 --- /dev/null +++ b/apps/server/src/provider/security/CodexPermissionEscalation.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from "@effect/vitest"; +import * as NodeAssert from "node:assert/strict"; + +import { + classifyCodexPermissionRequest, + describeCodexPermissionRequest, +} from "./CodexPermissionEscalation.ts"; + +const params = ( + permissions: Parameters[0], + reason?: string, +) => + ({ + cwd: "/workspace", + itemId: "item-1", + permissions, + startedAtMs: 0, + threadId: "thread-1", + turnId: "turn-1", + ...(reason === undefined ? {} : { reason }), + }) as Parameters[0]; + +describe("classifyCodexPermissionRequest", () => { + it("treats an all-read profile as read-only", () => { + const classification = classifyCodexPermissionRequest({ + fileSystem: { + entries: [ + { access: "read", path: { type: "path", path: "/workspace/src" } }, + { access: "read", path: { type: "glob_pattern", pattern: "/workspace/**/*.ts" } }, + ], + }, + }); + + NodeAssert.equal(classification.readOnly, true); + NodeAssert.equal(classification.network, false); + NodeAssert.deepStrictEqual(classification.escalations, []); + }); + + it("treats a profile requesting nothing as read-only", () => { + NodeAssert.equal(classifyCodexPermissionRequest({}).readOnly, true); + }); + + it("refuses a write entry", () => { + const classification = classifyCodexPermissionRequest({ + fileSystem: { + entries: [ + { access: "read", path: { type: "path", path: "/workspace/src" } }, + { access: "write", path: { type: "path", path: "/workspace/dist" } }, + ], + }, + }); + + NodeAssert.equal(classification.readOnly, false); + NodeAssert.equal(classification.escalations.length, 1); + NodeAssert.match(classification.escalations[0]!, /write access to \/workspace\/dist/u); + }); + + it("refuses the legacy write list even when entries are read-only", () => { + const classification = classifyCodexPermissionRequest({ + fileSystem: { + entries: [{ access: "read", path: { type: "path", path: "/workspace/src" } }], + write: ["/workspace/out"], + }, + }); + + NodeAssert.equal(classification.readOnly, false); + NodeAssert.match(classification.escalations.join(" "), /\/workspace\/out/u); + }); + + it("refuses a network enable", () => { + const classification = classifyCodexPermissionRequest({ network: { enabled: true } }); + + NodeAssert.equal(classification.readOnly, false); + NodeAssert.equal(classification.network, true); + NodeAssert.deepStrictEqual(classification.escalations, ["network access"]); + }); + + it("does not treat an explicitly disabled network as an escalation", () => { + const classification = classifyCodexPermissionRequest({ network: { enabled: false } }); + + NodeAssert.equal(classification.readOnly, true); + NodeAssert.equal(classification.network, false); + }); + + it("refuses sandbox denials, which rewrite the profile rather than widen it", () => { + const classification = classifyCodexPermissionRequest({ + fileSystem: { + entries: [{ access: "deny", path: { type: "path", path: "/workspace/.env" } }], + }, + }); + + NodeAssert.equal(classification.readOnly, false); + NodeAssert.match(classification.escalations.join(" "), /denials/u); + }); +}); + +describe("describeCodexPermissionRequest", () => { + it("names read and write paths and the network request", () => { + const detail = describeCodexPermissionRequest( + params({ + fileSystem: { + entries: [ + { access: "read", path: { type: "path", path: "/workspace/src" } }, + { access: "write", path: { type: "path", path: "/workspace/dist" } }, + ], + }, + network: { enabled: true }, + }), + ); + + NodeAssert.match(detail, /write: \/workspace\/dist/u); + NodeAssert.match(detail, /read: \/workspace\/src/u); + NodeAssert.match(detail, /network access/u); + }); + + it("appends the reason Codex supplied", () => { + const detail = describeCodexPermissionRequest( + params( + { fileSystem: { entries: [{ access: "read", path: { type: "path", path: "/a" } }] } }, + "inspect the workspace", + ), + ); + + NodeAssert.equal(detail, "read: /a — inspect the workspace"); + }); + + it("collapses long path lists instead of rendering an unreadable prompt", () => { + const detail = describeCodexPermissionRequest( + params({ + fileSystem: { + read: ["/a", "/b", "/c", "/d", "/e", "/f"], + }, + }), + ); + + NodeAssert.match(detail, /\(\+2 more\)/u); + }); + + it("still describes a profile that requests nothing", () => { + NodeAssert.equal(describeCodexPermissionRequest(params({})), "no additional permissions"); + }); +}); diff --git a/apps/server/src/provider/security/CodexPermissionEscalation.ts b/apps/server/src/provider/security/CodexPermissionEscalation.ts new file mode 100644 index 000000000000..9892b5212e66 --- /dev/null +++ b/apps/server/src/provider/security/CodexPermissionEscalation.ts @@ -0,0 +1,150 @@ +/** + * Classification for Codex `item/permissions/requestApproval` — the sandbox + * escalation request. + * + * This request is not "may I run this tool". It is "may I widen my sandbox", + * carrying an arbitrary list of filesystem paths (each read/write/deny) plus a + * flag that can re-enable network access. The requesting side is the untrusted + * one, so the decision has to be made on the *requested permissions*, never on + * which tool happened to trigger them. + * + * Two things are derived here: + * - `classifyCodexPermissionRequest` — is this escalation read-only, and + * which `ProviderRequestKind` should carry it to the client. + * - `describeCodexPermissionRequest` — a human-readable summary, because the + * persisted approval activity keeps only a `detail` string (the raw params + * in `args` are dropped at ingestion), and an approval prompt that cannot + * say which paths are being requested is not reviewable. + */ + +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import type { ProviderRequestKind } from "@t3tools/contracts"; + +/** + * Kind carried to the client for a prompted sandbox escalation. + * + * Fixed rather than derived per request: `ProviderRuntimeIngestion` maps the + * kind from the canonical request type alone, so a kind that varied per request + * would disagree between the opened and resolved activities. `file-change` is + * the conservative choice — any escalation that reaches a prompt can grant + * writes — and both web and mobile already render it, so the prompt stays + * answerable without widening `ProviderRequestKind` across every client fold. + */ +export const CODEX_PERMISSION_REQUEST_KIND: ProviderRequestKind = "file-change"; + +type PermissionParams = EffectCodexSchema.ServerRequest__PermissionsRequestApprovalParams; +type PermissionProfile = EffectCodexSchema.ServerRequest__RequestPermissionProfile; +type FileSystemPath = EffectCodexSchema.ServerRequest__FileSystemPath; + +/** Maximum paths named in the summary before it collapses to a count. */ +const MAX_SUMMARIZED_PATHS = 4; + +export interface CodexPermissionClassification { + /** + * True only when every requested grant is a read. Any write, any deny-flip, + * any legacy `write[]` entry, or any network enable makes this false. + */ + readonly readOnly: boolean; + /** True when the request would re-enable network access. */ + readonly network: boolean; + /** Reasons the request was not classified read-only, for the prompt detail. */ + readonly escalations: ReadonlyArray; +} + +function renderFileSystemPath(path: FileSystemPath): string { + switch (path.type) { + case "path": + return path.path; + case "glob_pattern": + return path.pattern; + case "special": + return path.value.kind === "unknown" ? path.value.path : `<${path.value.kind}>`; + } +} + +/** + * Classify a requested profile. Absent/null fields are treated as "not + * requested" rather than as a grant, but an entirely empty profile is still + * read-only=true — it asks for nothing. + */ +export function classifyCodexPermissionRequest( + permissions: PermissionProfile, +): CodexPermissionClassification { + const escalations: string[] = []; + const fileSystem = permissions.fileSystem ?? undefined; + + const writeEntries = (fileSystem?.entries ?? []).filter((entry) => entry.access === "write"); + if (writeEntries.length > 0) { + escalations.push( + `write access to ${writeEntries.map((entry) => renderFileSystemPath(entry.path)).join(", ")}`, + ); + } + + // `deny` narrows rather than widens, but it still rewrites the sandbox + // profile, so it is never auto-granted. + const denyEntries = (fileSystem?.entries ?? []).filter((entry) => entry.access === "deny"); + if (denyEntries.length > 0) { + escalations.push( + `sandbox denials for ${denyEntries.map((entry) => renderFileSystemPath(entry.path)).join(", ")}`, + ); + } + + const legacyWrite = fileSystem?.write ?? []; + if (legacyWrite.length > 0) { + escalations.push(`write access to ${legacyWrite.join(", ")}`); + } + + const network = permissions.network?.enabled === true; + if (network) { + escalations.push("network access"); + } + + return { + readOnly: escalations.length === 0, + network, + escalations, + }; +} + +function summarizePaths(paths: ReadonlyArray): string | undefined { + if (paths.length === 0) return undefined; + if (paths.length <= MAX_SUMMARIZED_PATHS) return paths.join(", "); + return `${paths.slice(0, MAX_SUMMARIZED_PATHS).join(", ")} (+${paths.length - MAX_SUMMARIZED_PATHS} more)`; +} + +/** + * Build the `detail` string shown on the approval prompt. Always names what is + * being requested; the reason Codex supplied is appended when present. + */ +export function describeCodexPermissionRequest(params: PermissionParams): string { + const fileSystem = params.permissions.fileSystem ?? undefined; + const parts: string[] = []; + + const readPaths = [ + ...(fileSystem?.entries ?? []) + .filter((entry) => entry.access === "read") + .map((entry) => renderFileSystemPath(entry.path)), + ...(fileSystem?.read ?? []), + ]; + const writePaths = [ + ...(fileSystem?.entries ?? []) + .filter((entry) => entry.access === "write") + .map((entry) => renderFileSystemPath(entry.path)), + ...(fileSystem?.write ?? []), + ]; + const denyPaths = (fileSystem?.entries ?? []) + .filter((entry) => entry.access === "deny") + .map((entry) => renderFileSystemPath(entry.path)); + + const writeSummary = summarizePaths(writePaths); + if (writeSummary) parts.push(`write: ${writeSummary}`); + const readSummary = summarizePaths(readPaths); + if (readSummary) parts.push(`read: ${readSummary}`); + const denySummary = summarizePaths(denyPaths); + if (denySummary) parts.push(`deny: ${denySummary}`); + if (params.permissions.network?.enabled === true) parts.push("network access"); + + const requested = parts.length > 0 ? parts.join("; ") : "no additional permissions"; + const reason = params.reason?.trim(); + return reason ? `${requested} — ${reason}` : requested; +} diff --git a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts index a37d649bcf31..bb7cf261eb8a 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts @@ -336,6 +336,37 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => }), ); + it.effect("names the missing source auth.json instead of starting unauthenticated", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cc-codex-home-noauth-" }); + const stateDir = path.join(root, "state"); + const sourceHomePath = path.join(root, "source-home"); + yield* fileSystem.makeDirectory(stateDir, { recursive: true }); + yield* fileSystem.makeDirectory(sourceHomePath, { recursive: true }); + + // No auth.json in the source home. Previously this copied nothing and let + // the session start, so the failure only surfaced as a provider 401 on the + // first model call. + const error = yield* prepareCommandCenterCodexHome({ + stateDir, + sourceHomePath, + threadId: "cc:thread-missing-auth", + fileSystem, + path, + crypto, + runtimeExecutablePath: NodeProcess.execPath, + platform: NodeProcess.platform, + writableRoots: [root], + }).pipe(Effect.flip); + + NodeAssert.match(error.issue, /found no Codex credentials to isolate/u); + NodeAssert.ok(error.issue.includes(path.join(sourceHomePath, "auth.json"))); + }), + ); + it.effect("fails closed if an isolated Codex home gains ambient config", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -346,6 +377,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => const sourceHomePath = path.join(root, "source-home"); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); yield* fileSystem.makeDirectory(sourceHomePath, { recursive: true }); + yield* writeFile(path.join(sourceHomePath, "auth.json"), '{"token":"test-only"}\n'); const input = { stateDir, sourceHomePath, @@ -375,6 +407,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => const sourceHomePath = path.join(root, "source-home"); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); yield* fileSystem.makeDirectory(sourceHomePath, { recursive: true }); + yield* writeFile(path.join(sourceHomePath, "auth.json"), '{"token":"test-only"}\n'); const input = { stateDir, sourceHomePath, @@ -463,6 +496,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => const runtimePath = path.join(root, "codex.exe"); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); yield* fileSystem.makeDirectory(sourceHomePath, { recursive: true }); + yield* writeFile(path.join(sourceHomePath, "auth.json"), '{"token":"test-only"}\n'); yield* fileSystem.writeFile(runtimePath, Uint8Array.from([0x4d, 0x5a, 0x00, 0x00])); const makeHome = (threadId: string) => @@ -541,6 +575,47 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => }), ); + it.effect("resolves the native executable behind a canonical Linux npm launcher", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cc-linux-runtime-" }); + const packageRoot = path.join(root, "node_modules", "@openai", "codex"); + const commandPath = path.join(packageRoot, "bin", "codex.js"); + const platformPackageRoot = path.join( + packageRoot, + "node_modules", + "@openai", + "codex-linux-x64", + ); + const nativePath = path.join( + platformPackageRoot, + "vendor", + "x86_64-unknown-linux-musl", + "bin", + "codex", + ); + yield* writeFile(commandPath, "#!/usr/bin/env node\n"); + yield* writeFile( + path.join(platformPackageRoot, "package.json"), + '{"name":"@openai/codex-linux-x64","version":"0.0.0"}\n', + ); + yield* fileSystem.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fileSystem.writeFile(nativePath, Uint8Array.from([0x7f, 0x45, 0x4c, 0x46])); + + NodeAssert.equal( + yield* resolveCommandCenterCodexRuntimeExecutable({ + commandPath, + platform: "linux", + architecture: "x64", + fileSystem, + path, + }), + yield* fileSystem.realPath(nativePath), + ); + }), + ); + it.effect( "grants read-only access to the exact pointer and common metadata for a valid worktree", () => diff --git a/apps/server/src/provider/security/CommandCenterProviderIsolation.ts b/apps/server/src/provider/security/CommandCenterProviderIsolation.ts index 34355c7c1395..489fc6a3cc0d 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.ts @@ -387,7 +387,7 @@ function codexHomeError(issue: string, cause?: unknown): CommandCenterCodexHomeI }); } -/** Resolve the native executable behind the official Windows npm launcher without executing it. */ +/** Resolve the native executable behind the official Codex npm launcher without executing it. */ export const resolveCommandCenterCodexRuntimeExecutable = Effect.fn( "CommandCenterProviderIsolation.resolveCodexRuntimeExecutable", )(function* (input: ResolveCommandCenterCodexRuntimeInput) { @@ -399,51 +399,57 @@ export const resolveCommandCenterCodexRuntimeExecutable = Effect.fn( codexHomeError("Command Center could not canonicalize the Codex runtime launcher.", cause), ), ); - if (input.platform !== "win32" || path.extname(canonicalCommandPath).toLowerCase() === ".exe") { + if ( + (input.platform === "win32" && path.extname(canonicalCommandPath).toLowerCase() === ".exe") || + (input.platform !== "win32" && path.basename(canonicalCommandPath) !== "codex.js") + ) { return canonicalCommandPath; } - const target = - input.architecture === "arm64" - ? { - packageName: "@openai/codex-win32-arm64", - triple: "aarch64-pc-windows-msvc", - } - : input.architecture === "x64" - ? { - packageName: "@openai/codex-win32-x64", - triple: "x86_64-pc-windows-msvc", - } - : undefined; + const targets: Readonly< + Record + > = { + "linux:arm64": ["@openai/codex-linux-arm64", "aarch64-unknown-linux-musl", "codex"], + "linux:x64": ["@openai/codex-linux-x64", "x86_64-unknown-linux-musl", "codex"], + "darwin:arm64": ["@openai/codex-darwin-arm64", "aarch64-apple-darwin", "codex"], + "darwin:x64": ["@openai/codex-darwin-x64", "x86_64-apple-darwin", "codex"], + "win32:arm64": ["@openai/codex-win32-arm64", "aarch64-pc-windows-msvc", "codex.exe"], + "win32:x64": ["@openai/codex-win32-x64", "x86_64-pc-windows-msvc", "codex.exe"], + }; + const target = targets[`${input.platform}:${input.architecture}`]; if (target === undefined) { return yield* codexHomeError( - `Command Center does not support the '${input.architecture}' Windows Codex runtime architecture.`, + `Command Center does not support the '${input.architecture}' ${input.platform} Codex runtime architecture.`, ); } - const codexPackageRoot = path.join( - path.dirname(canonicalCommandPath), - "node_modules", - "@openai", - "codex", - ); - const codexLauncherPath = path.join(codexPackageRoot, "bin", "codex.js"); + const codexLauncherPath = + path.basename(canonicalCommandPath) === "codex.js" + ? canonicalCommandPath + : path.join( + path.dirname(canonicalCommandPath), + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); if (!(yield* fileSystem.exists(codexLauncherPath))) { return yield* codexHomeError( - "Command Center requires the official native Codex package; the configured Windows launcher does not have a verifiable @openai/codex installation.", + "Command Center requires the official native Codex package; the configured launcher does not have a verifiable @openai/codex installation.", ); } + const [packageName, triple, executableName] = target; const packageJsonPath = yield* Effect.try({ - try: () => - NodeModule.createRequire(codexLauncherPath).resolve(`${target.packageName}/package.json`), + try: () => NodeModule.createRequire(codexLauncherPath).resolve(`${packageName}/package.json`), catch: (cause) => codexHomeError( - `Command Center could not resolve the native ${target.packageName} package. Reinstall or update @openai/codex.`, + `Command Center could not resolve the native ${packageName} package. Reinstall or update @openai/codex.`, cause, ), }); - const platformVendorRoot = path.join(path.dirname(packageJsonPath), "vendor", target.triple); + const platformVendorRoot = path.join(path.dirname(packageJsonPath), "vendor", triple); const nativeExecutableCandidates = [ - path.join(platformVendorRoot, "bin", "codex.exe"), - path.join(platformVendorRoot, "codex", "codex.exe"), + path.join(platformVendorRoot, "bin", executableName), + path.join(platformVendorRoot, "codex", executableName), ]; for (const candidate of nativeExecutableCandidates) { if (yield* fileSystem.exists(candidate)) { @@ -457,7 +463,7 @@ export const resolveCommandCenterCodexRuntimeExecutable = Effect.fn( } } return yield* codexHomeError( - "Command Center found the Windows Codex package but not its native codex.exe. Reinstall @openai/codex with optional dependencies enabled.", + `Command Center found the ${input.platform} Codex package but not its native ${executableName}. Reinstall @openai/codex with optional dependencies enabled.`, ); }); @@ -805,39 +811,45 @@ export const prepareCommandCenterCodexHome = Effect.fn( } const targetAuthStat = yield* optionalStat(targetAuthPath); - if (Option.isSome(targetAuthStat)) { - if (targetAuthStat.value.type !== "File") { - return yield* codexHomeError( - "Command Center's isolated Codex authentication entry is not a private file.", - ); - } - const linkState = yield* fileSystem.readLink(targetAuthPath).pipe( - Effect.map(Option.some), - Effect.catchTags({ - PlatformError: (cause) => - isNotSymlink(cause) - ? Effect.succeed(Option.none()) - : Effect.fail( - codexHomeError( - "Command Center could not verify isolated Codex authentication.", - cause, - ), - ), - }), + if (Option.isNone(targetAuthStat)) { + // Fail closed. Without `auth.json` the session still starts, and the first + // model call fails far downstream as an opaque provider 401 that says + // nothing about which home Command Center actually read. + return yield* codexHomeError( + `Command Center found no Codex credentials to isolate: ${sourceAuthPath} does not exist. ` + + "Sign in with Codex for this identity, or point the provider instance's CODEX_HOME " + + "setting at the home that holds its auth.json.", + ); + } + if (targetAuthStat.value.type !== "File") { + return yield* codexHomeError( + "Command Center's isolated Codex authentication entry is not a private file.", ); - if (Option.isSome(linkState)) { - return yield* codexHomeError( - "Command Center refuses symlinked isolated Codex authentication.", - ); - } - yield* fileSystem - .chmod(targetAuthPath, 0o600) - .pipe( - Effect.mapError((cause) => - codexHomeError("Command Center could not protect isolated Codex authentication.", cause), - ), - ); } + const linkState = yield* fileSystem.readLink(targetAuthPath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + isNotSymlink(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + codexHomeError( + "Command Center could not verify isolated Codex authentication.", + cause, + ), + ), + }), + ); + if (Option.isSome(linkState)) { + return yield* codexHomeError("Command Center refuses symlinked isolated Codex authentication."); + } + yield* fileSystem + .chmod(targetAuthPath, 0o600) + .pipe( + Effect.mapError((cause) => + codexHomeError("Command Center could not protect isolated Codex authentication.", cause), + ), + ); return layout; }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index fa16667ec15e..98af2c654c64 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -183,6 +183,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "/Users/julius/.codex", shadowHomePath: "", launchArgs: "", + autoApproveReadOnlyPermissions: false, customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -515,6 +516,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", shadowHomePath: "", launchArgs: "", + autoApproveReadOnlyPermissions: false, customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 2ba0df428906..485ffbf8d37f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -8,7 +8,7 @@ export { export interface EnvironmentOption { environmentId: EnvironmentId; - projectId: ProjectId | null; + projectId: ProjectId; label: string; isPrimary: boolean; } diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index df7f019b7e4f..b5d5751a280b 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -114,10 +114,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {env.label} - {env.projectId === null ? ( - Choose folder… - ) : null} + {env.label} ))} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 27c0029950ab..bdc6f1b3ce57 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -120,7 +120,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; @@ -260,7 +260,6 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, - resolveEnvironmentOptionLabel, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -1873,24 +1872,14 @@ function ChatViewContent(props: ChatViewProps) { ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ - projects: allProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }); - const logicalKey = - logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? - deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); - const memberProjects = allProjects.filter((project) => { - return ( - (logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)) === logicalKey - ); - }); + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; - projectId: ProjectId | null; + projectId: ProjectId; label: string; isPrimary: boolean; }> = []; @@ -1906,37 +1895,13 @@ function ChatViewContent(props: ChatViewProps) { isPrimary, }); } - for (const environment of environments) { - if (seen.has(environment.environmentId) || environment.connection.phase !== "connected") { - continue; - } - seen.add(environment.environmentId); - const isPrimary = environment.environmentId === primaryEnvironmentId; - envs.push({ - environmentId: environment.environmentId, - projectId: null, - label: resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: environment.environmentId, - runtimeLabel: environment.label, - }), - isPrimary, - }); - } // Sort: primary first, then alphabetical envs.sort((a, b) => { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return a.label.localeCompare(b.label); }); return envs; - }, [ - activeProject, - allProjects, - environments, - projectGroupingSettings, - primaryEnvironmentId, - environmentById, - ]); + }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const activeEnvironmentOption = logicalProjectEnvironments.find( @@ -2797,10 +2762,6 @@ function ChatViewContent(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - if (target.projectId === null) { - openCommandPalette({ open: "add-project" }); - return; - } setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..d53d36e1f869 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -19,6 +19,7 @@ describe("ProviderSettingsForm helpers", () => { "homePath", "shadowHomePath", "launchArgs", + "autoApproveReadOnlyPermissions", ]); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f01d2619246a..46759b7d41f2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -36,8 +36,8 @@ const WINDOW_OPTIONS = [ export function UsagePage() { const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), + days: 1, + window: makeWindow(1, undefined, "hour"), })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d78c0c7acb6e..c2dbea286acd 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -14,27 +14,24 @@ That gives you: ## Use a Remote Server as the Desktop Primary -The Electron desktop can use a paired remote environment as its primary backend while keeping its -native Windows backend available as a secondary execution environment. The dedicated remote-only -desktop build remains remote-only and never starts local execution. +The Electron desktop can use a paired remote environment as its primary backend. In this mode the +remote environment owns execution; Desktop does not start a second local backend. 1. Pair the remote environment from **Settings** → **Connections** → **Add environment**. 2. In **Desktop execution**, choose **Remote server** and enter its HTTP or HTTPS base URL. 3. Test the connection, then choose **Make remote primary and restart**. Remote-primary mode persists across ordinary launches. The desktop loads its web bundle from the -remote server and sends primary HTTP and WebSocket traffic directly to it using the saved pairing -session. A normal desktop build also starts Windows as a secondary environment so each new thread -can explicitly run remotely or on this computer. +remote server and sends HTTP and WebSocket traffic directly to it using the saved pairing session. +Projects, files, git state, terminals, and provider sessions therefore remain on the remote machine. If the remote server is unavailable, Command Center shows a local recovery window. It never falls back to Windows automatically. You can retry, edit the endpoint, quit, or select **Start local for this launch**. The last option relaunches with a conspicuous local-override indicator and leaves the persisted Remote preference unchanged, so the next ordinary launch returns to remote-only mode. -Switching the primary back to **Windows local** or **WSL** is always an explicit Connections setting -change followed by a restart. When the remote server remains primary, choosing Windows for a new -thread enables the native folder picker for that Windows project. +To run work on the desktop machine, switch the primary back to **Windows local** or **WSL** in +Connections and restart. Local execution is not available while the remote server remains primary. ## Enabling Network Access diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index ada64a7664d8..29d8cb70475a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -143,6 +143,7 @@ export const CanonicalRequestType = Schema.Literals([ "tool_user_input", "dynamic_tool_call", "auth_tokens_refresh", + "permissions_approval", "unknown", ]); export type CanonicalRequestType = typeof CanonicalRequestType.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 56937f893292..4c91277a566a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -310,13 +310,28 @@ export const CodexSettings = makeProviderSettingsSchema( description: "Additional CLI arguments passed to codex app-server on session start.", }), ), + autoApproveReadOnlyPermissions: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Auto-approve read-only sandbox requests", + description: + "Grant Codex sandbox escalations that only request read access, without prompting. Requests for write access, sandbox denials, or network access always prompt.", + providerSettingsForm: { control: "switch" }, + }), + ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { - order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"], + order: [ + "binaryPath", + "homePath", + "shadowHomePath", + "launchArgs", + "autoApproveReadOnlyPermissions", + ], }, ); export type CodexSettings = typeof CodexSettings.Type; @@ -729,6 +744,7 @@ const CodexSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), launchArgs: Schema.optionalKey(TrimmedString), + autoApproveReadOnlyPermissions: Schema.optionalKey(Schema.Boolean), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index a287c52e2acf..71824210037c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([ "provider-event", "command-approval-request", "file-change-approval-request", + "permissions-approval-request", "user-input-request", "session-generation", ]);