diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89b00d3521d..93fcd9fce72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,36 @@ jobs: - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + command_center_native_isolation: + name: Command Center Native Isolation (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - label: macOS + os: macos-26 + - label: Windows + os: windows-2025 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Test native Command Center isolation + run: >- + vp test run + apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts + apps/server/src/provider/Layers/CodexSessionRuntime.test.ts + apps/server/src/command-center/RunDispatcher.test.ts + mobile_native_static_analysis: name: Mobile Native Static Analysis runs-on: macos-26 diff --git a/apps/server/src/command-center/CommandCenterAcceptance.test.ts b/apps/server/src/command-center/CommandCenterAcceptance.test.ts index 4c68120bc6d..4bec2631742 100644 --- a/apps/server/src/command-center/CommandCenterAcceptance.test.ts +++ b/apps/server/src/command-center/CommandCenterAcceptance.test.ts @@ -363,6 +363,7 @@ const makeDispatcherHarness = ( projectId: row.projectId, threadId: row.threadId, executionAuthorizedAt: row.executionAuthorizedAt, + parentRunId: null, state: row.state, route: submitted.route, command, diff --git a/apps/server/src/command-center/RunDispatcher.test.ts b/apps/server/src/command-center/RunDispatcher.test.ts index eb6709c6f58..92a73bf96e1 100644 --- a/apps/server/src/command-center/RunDispatcher.test.ts +++ b/apps/server/src/command-center/RunDispatcher.test.ts @@ -104,6 +104,7 @@ function makeFixture( readonly resolveTargetProject?: DispatcherDependencies["resolveTargetProject"]; readonly space?: StoredSpace; readonly executionAuthorized?: boolean; + readonly parentRunId?: string; readonly priorContext?: ReadonlyArray<{ readonly commandText: string; readonly responseText?: string; @@ -117,6 +118,7 @@ function makeFixture( projectId: null, threadId: null, executionAuthorizedAt: options.executionAuthorized === false ? null : fixtureTime, + parentRunId: options.parentRunId ?? null, state: "queued", route, command, @@ -279,7 +281,7 @@ it.effect("links one fresh thread and registers the exact Space and repository s expect(first).toMatchObject({ projectId: targetProject.id, - threadId: ThreadId.make("cc:thread-example"), + threadId: ThreadId.make("cc:interactive:thread-example"), state: "running", sequence: 42, duplicate: false, @@ -287,7 +289,7 @@ it.effect("links one fresh thread and registers the exact Space and repository s expect(duplicate.duplicate).toBe(true); expect(state.dispatchCount).toBe(1); expect(state.recordedSequence).toBe(42); - expect(state.registeredThread).toBe(ThreadId.make("cc:thread-example")); + expect(state.registeredThread).toBe(ThreadId.make("cc:interactive:thread-example")); expect(state.registeredScope?.spaceId).toBe(spaceId); expect(state.registeredScope?.repositoryId).toBe(repositoryId); expect(state.registeredScope?.memoryWriteMode).toBe("propose"); @@ -309,6 +311,20 @@ it.effect("links one fresh thread and registers the exact Space and repository s }), ); +it.effect("marks automation child threads as unattended before provider dispatch", () => + Effect.gen(function* () { + const fixture = makeFixture(readyRoute, { parentRunId: "automation-execution-example" }); + + const result = yield* fixture.dispatcher.dispatch({ + runId, + dispatchCommand: fixture.dispatch(), + }); + + expect(result.threadId).toBe(ThreadId.make("cc:automation:thread-example")); + expect(fixture.read().registeredThread).toBe(ThreadId.make("cc:automation:thread-example")); + }), +); + it.effect("refuses pre-ack dispatch and recovery without changing the admitted Run", () => Effect.gen(function* () { const fixture = makeFixture(readyRoute, { executionAuthorized: false }); @@ -371,7 +387,7 @@ it.effect("persists dispatch failure and revokes the linked thread scope", () => expect(failure.reason).toBe("dispatch-failed"); expect(state.storedRun.state).toBe("failed"); expect(state.failedError?.reason).toBe("dispatch-failed"); - expect(state.unregisteredThread).toBe(ThreadId.make("cc:thread-example")); + expect(state.unregisteredThread).toBe(ThreadId.make("cc:interactive:thread-example")); }), ); diff --git a/apps/server/src/command-center/RunDispatcher.ts b/apps/server/src/command-center/RunDispatcher.ts index fe2276e5e8c..a8a3094d6cc 100644 --- a/apps/server/src/command-center/RunDispatcher.ts +++ b/apps/server/src/command-center/RunDispatcher.ts @@ -48,6 +48,10 @@ import { isProvisionableRepositoryRemote, } from "./RepositoryProvisioningPolicy.ts"; import { makeRunLifecyclePersistence } from "./RunLifecycle.ts"; +import { + COMMAND_CENTER_AUTOMATION_THREAD_ID_PREFIX, + COMMAND_CENTER_INTERACTIVE_THREAD_ID_PREFIX, +} from "../provider/security/CommandCenterProviderIsolation.ts"; export { isManagedRepositoryWorkspacePath, @@ -130,6 +134,7 @@ export interface StoredRun { readonly projectId: string | null; readonly threadId: string | null; readonly executionAuthorizedAt: string | null; + readonly parentRunId: string | null; readonly state: | "queued" | "running" @@ -840,7 +845,11 @@ export const makeWithDependencies = (deps: DispatcherDependencies): RunDispatche cause, ), }); - const threadId = ThreadId.make(`cc:${yield* deps.randomUUID}`); + const threadPrefix = + run.parentRunId === null + ? COMMAND_CENTER_INTERACTIVE_THREAD_ID_PREFIX + : COMMAND_CENTER_AUTOMATION_THREAD_ID_PREFIX; + const threadId = ThreadId.make(`${threadPrefix}${yield* deps.randomUUID}`); const claimed = yield* deps.claim({ runId: run.id, projectId: project.id, threadId }); if (!claimed) { const current = yield* deps.loadRun(run.id); @@ -976,6 +985,7 @@ interface RunRow { readonly projectId: string | null; readonly threadId: string | null; readonly executionAuthorizedAt: string | null; + readonly parentRunId: string | null; readonly state: StoredRun["state"]; readonly routeJson: string; readonly inputJson: string; @@ -1027,6 +1037,7 @@ const make = Effect.gen(function* () { SELECT id, command_id AS "commandId", space_id AS "spaceId", project_id AS "projectId", thread_id AS "threadId", state, execution_authorized_at AS "executionAuthorizedAt", + parent_run_id AS "parentRunId", route_json AS "routeJson", input_json AS "inputJson" FROM command_center_runs WHERE id = ${runId} @@ -1058,6 +1069,7 @@ const make = Effect.gen(function* () { projectId: row.projectId, threadId: row.threadId, executionAuthorizedAt: row.executionAuthorizedAt, + parentRunId: row.parentRunId, state: row.state, route, command, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7da15f7e043..8d5a60c3fa3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -34,7 +34,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterValidationError, +} from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -150,7 +153,10 @@ describe("ProviderCommandReactor", () => { readonly titleRegenerationBeforeStart?: "one" | "two"; readonly startSessionEffect?: ( session: ProviderSession, - ) => Effect.Effect; + ) => Effect.Effect< + ProviderSession, + ProviderAdapterRequestError | ProviderAdapterValidationError + >; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -623,8 +629,10 @@ describe("ProviderCommandReactor", () => { waitFor(async () => { const readModel = await harness.readModel(); return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status === "error" + readModel.threads + .find((entry) => entry.id === ThreadId.make("thread-1")) + ?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false ); }), ); @@ -657,6 +665,59 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("shows validation issues without exposing provider stack traces", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "Native Windows sandbox setup is required.", + }), + ), + }), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-validation-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-validation-failure"), + role: "user", + text: "start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads + .find((entry) => entry.id === ThreadId.make("thread-1")) + ?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }), + ); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.turn.start.failed", + ); + expect(failureActivity).toMatchObject({ + payload: { detail: "Native Windows sandbox setup is required." }, + }); + expect(thread?.session?.lastError).toBe("Native Windows sandbox setup is required."); + expect(thread?.session?.lastError).not.toContain("ProviderAdapterValidationError"); + expect(thread?.session?.lastError).not.toContain(" at "); + }), + ); it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9215e8d0782..a5e54d4e12c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,7 +31,11 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterValidationError, +} from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { COMMAND_PRODUCED_NO_EVENTS_DETAIL } from "../Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; @@ -60,6 +64,8 @@ import { import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); +const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderDriverKind = Schema.is(ProviderDriverKind); // Structural (by value) comparison of two model selections. `ModelSelection` @@ -416,13 +422,13 @@ const make = Effect.gen(function* () { const formatFailureDetail = (cause: Cause.Cause): string => { const failReason = cause.reasons.find(Cause.isFailReason); - const providerError = isProviderAdapterRequestError(failReason?.error) - ? failReason.error - : undefined; - if (providerError) { - return providerError.detail; + const failure = failReason?.error; + if (isProviderAdapterValidationError(failure)) return failure.issue; + if (isProviderAdapterRequestError(failure) || isProviderAdapterProcessError(failure)) { + return failure.detail; } - return Cause.pretty(cause); + if (failure instanceof Error && failure.message.trim().length > 0) return failure.message; + return "The provider operation failed. Check the server logs for technical details."; }; const setThreadSession = (input: { @@ -2000,11 +2006,14 @@ const make = Effect.gen(function* () { return Effect.void; } const detail = formatFailureDetail(cause); - return setThreadSessionErrorOnTurnStartFailure({ - threadId: event.payload.threadId, - detail, - createdAt: event.payload.createdAt, - }).pipe( + return Effect.logError("Provider turn start failed.", { cause }).pipe( + Effect.andThen( + setThreadSessionErrorOnTurnStartFailure({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }), + ), Effect.flatMap(() => appendProviderFailureActivity({ threadId: event.payload.threadId, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c2a9491b3c5..61e0306a07d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -45,6 +45,7 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { + CodexSessionRuntimeIsolationProbeError, CodexSessionRuntimeThreadIdMissingError, type CodexSessionRuntimeOptions, type CodexSessionRuntimeError, @@ -76,6 +77,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { public currentSession: ProviderSession | undefined; public interruptTurnFailure: CodexSessionRuntimeError | undefined; public sendTurnFailure: CodexSessionRuntimeError | undefined; + public startFailure: CodexSessionRuntimeError | undefined; public readonly interruptTurnCalls: Array< readonly [TurnId | undefined, ProviderTurnTargetIdentity | undefined] > = []; @@ -144,6 +146,9 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { } start() { + if (this.startFailure !== undefined) { + return Effect.fail(this.startFailure); + } return Effect.promise(() => this.startImpl()).pipe( Effect.tap((session) => Effect.sync(() => { @@ -214,16 +219,22 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { } } -function makeRuntimeFactory() { +function makeRuntimeFactory(input?: { + readonly startFailures?: ReadonlyArray; +}) { const runtimes: Array = []; - const factory = vi.fn((options: CodexSessionRuntimeOptions) => { - const runtime = new FakeCodexRuntime(options); + const factory = vi.fn((runtimeOptions: CodexSessionRuntimeOptions) => { + const runtime = new FakeCodexRuntime(runtimeOptions); + runtime.startFailure = input?.startFailures?.[runtimes.length]; runtimes.push(runtime); return Effect.succeed(runtime); }); return { factory, + get runtimes(): ReadonlyArray { + return runtimes; + }, get lastRuntime(): FakeCodexRuntime | undefined { return runtimes.at(-1); }, @@ -428,6 +439,66 @@ validationLayer("CodexAdapterLive validation", (it) => { ); }); +it.effect( + "retries Windows Command Center isolation unelevated after the elevated probe fails", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "cc-windows-fallback-")); + const runtimePath = NodePath.join(tempDir, "codex.exe"); + const sourceHomePath = NodePath.join(tempDir, "codex-home"); + NodeFS.mkdirSync(sourceHomePath, { recursive: true }); + NodeFS.writeFileSync(runtimePath, Uint8Array.from([0x4d, 0x5a, 0x00, 0x00])); + + const runtimeFactory = makeRuntimeFactory({ + startFailures: [ + new CodexSessionRuntimeIsolationProbeError({ + issue: "The elevated live isolation probe failed.", + exitCode: 79, + }), + ], + }); + const layer = Layer.effect( + CodexAdapter, + makeCodexAdapter(decodeCodexSettings({}), { + makeRuntime: runtimeFactory.factory, + commandCenterSourceHomePath: sourceHomePath, + commandCenterRuntimeExecutablePath: runtimePath, + commandCenterPlatform: "win32", + commandCenterArchitecture: "x64", + }), + ).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "codex-adapter-windows-fallback-" }), + ), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("cc:interactive:windows-fallback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "approval-required", + }); + + NodeAssert.equal(runtimeFactory.runtimes.length, 2); + NodeAssert.equal(runtimeFactory.runtimes[0]?.options.windowsSandboxMode, "elevated"); + NodeAssert.equal(runtimeFactory.runtimes[0]?.closeImpl.mock.calls.length, 1); + NodeAssert.equal(runtimeFactory.runtimes[1]?.options.windowsSandboxMode, "unelevated"); + NodeAssert.match( + runtimeFactory.runtimes[1]?.options.appServerArgs?.join(" ") ?? "", + /windows\.sandbox="unelevated"/u, + ); + yield* adapter.stopSession(threadId); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + const sessionRuntimeFactory = makeRuntimeFactory(); const sessionErrorLayer = it.layer( Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2eab60ba19a..38897d52e02 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -42,7 +42,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveCommandPath } from "@t3tools/shared/shell"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -61,6 +61,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, + CodexSessionRuntimeIsolationProbeError, + CodexSessionRuntimeWindowsSandboxSetupError, CodexSessionRuntimeThreadIdMissingError, makeCodexSessionRuntime, matchesCodexInterruptTarget, @@ -70,12 +72,14 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { + CommandCenterCodexHomeIsolationError, commandCenterCodexIsolation, commandCenterProviderEnvironment, commandCenterProviderIsolationIssue, commandCenterProviderPlatformIssue, isCommandCenterThreadId, prepareCommandCenterCodexHome, + resolveCommandCenterCodexRuntimeExecutable, resolveCommandCenterManagedGitMetadata, } from "../security/CommandCenterProviderIsolation.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; @@ -86,6 +90,11 @@ const isCodexAppServerProtocolParseError = Schema.is(CodexErrors.CodexAppServerP const isCodexSessionRuntimeThreadIdMissingError = Schema.is( CodexSessionRuntimeThreadIdMissingError, ); +const isCodexSessionRuntimeWindowsSandboxSetupError = Schema.is( + CodexSessionRuntimeWindowsSandboxSetupError, +); +const isCodexSessionRuntimeIsolationProbeError = Schema.is(CodexSessionRuntimeIsolationProbeError); +const isCommandCenterCodexHomeIsolationError = Schema.is(CommandCenterCodexHomeIsolationError); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const PROVIDER = ProviderDriverKind.make("codex"); @@ -106,8 +115,10 @@ export interface CodexAdapterLiveOptions { readonly commandCenterSourceHomePath?: string; /** Test/embedding override for the exact executable admitted to the isolated runtime. */ readonly commandCenterRuntimeExecutablePath?: string; - /** Trusted test override; production Command Center isolation is Linux-only. */ + /** Trusted test override for native Command Center platform admission. */ readonly commandCenterPlatform?: NodeJS.Platform; + /** Trusted test override for native package selection. */ + readonly commandCenterArchitecture?: NodeJS.Architecture; } /** @@ -1594,6 +1605,9 @@ function mapToRuntimeEvents( } if (event.method === "windowsSandbox/setupCompleted") { + if (isCommandCenterThreadId(canonicalThreadId)) { + return []; + } const payload = readPayload( EffectCodexSchema.V2WindowsSandboxSetupCompletedNotification, event.payload, @@ -1651,6 +1665,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const hostPlatform = options?.commandCenterPlatform ?? (yield* HostProcessPlatform); + const hostArchitecture = options?.commandCenterArchitecture ?? (yield* HostProcessArchitecture); const serverConfig = yield* Effect.service(ServerConfig); const nativeEventLogger = options?.nativeEventLogger ?? @@ -1699,7 +1714,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); } const platformIssue = commandCenterThread - ? commandCenterProviderPlatformIssue(hostPlatform) + ? commandCenterProviderPlatformIssue(hostPlatform, input.threadId) : undefined; if (platformIssue !== undefined) { return yield* new ProviderAdapterValidationError({ @@ -1740,14 +1755,23 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( Effect.provideService(Path.Path, path), ) ).pipe( - Effect.flatMap((executablePath) => fileSystem.realPath(executablePath)), + Effect.flatMap((executablePath) => + resolveCommandCenterCodexRuntimeExecutable({ + commandPath: executablePath, + platform: hostPlatform, + architecture: hostArchitecture, + fileSystem, + path, + }), + ), Effect.mapError( (cause) => new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", - issue: - "Command Center could not resolve the Codex runtime executable for its isolated permission profile.", + issue: isCommandCenterCodexHomeIsolationError(cause) + ? cause.issue + : "Command Center could not resolve the Codex runtime executable for its isolated permission profile.", cause, }), ), @@ -1783,6 +1807,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( path, crypto, runtimeExecutablePath: commandCenterRuntimeExecutable ?? codexConfig.binaryPath, + platform: hostPlatform, writableRoots: [cwd, serverConfig.worktreesDir], }).pipe( Effect.mapError( @@ -1803,6 +1828,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( managedGitMetadata, commandCenterRuntimeExecutable, commandCenterHome, + hostPlatform, ) : undefined; if (commandCenterThread && commandCenterIsolation === undefined) { @@ -1816,6 +1842,14 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? commandCenterProviderEnvironment({ source: sourceEnvironment, ...commandCenterHome, + ...(hostPlatform === "win32" && commandCenterRuntimeExecutable !== undefined + ? { + runtimeSupportPath: path.join( + path.dirname(path.dirname(commandCenterRuntimeExecutable)), + "path", + ), + } + : {}), writableRoots: [cwd, serverConfig.worktreesDir], ...(mcpSession ? { @@ -1864,6 +1898,14 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(commandCenterIsolation ? { permissionProfile: commandCenterIsolation.permissionProfile } : {}), + ...(commandCenterIsolation?.windowsSandboxMode + ? { + commandCenterPlatform: hostPlatform, + windowsSandboxMode: commandCenterIsolation.windowsSandboxMode, + } + : commandCenterThread + ? { commandCenterPlatform: hostPlatform } + : {}), ...(appServerArgs.length > 0 ? { appServerArgs } : {}), }; const turnRequestCorrelation: CodexTurnRequestCorrelation = { @@ -1874,45 +1916,89 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( unresolved: undefined, }; const sendLock = yield* Semaphore.make(1); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; - const runtime = yield* createRuntime(runtimeInput).pipe( - Effect.provideService(Scope.Scope, sessionScope), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.provideService(Crypto.Crypto, crypto), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); + const startRuntimeAttempt = Effect.fn("CodexAdapter.startRuntimeAttempt")(function* ( + attemptInput: CodexSessionRuntimeOptions, + ) { + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + const runtime = yield* createRuntime(attemptInput).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(Crypto.Crypto, crypto), + ); + const eventFiber = yield* Stream.runForEach(runtime.events, (event) => + Effect.gen(function* () { + yield* writeNativeEvent(event); + const runtimeEvents = mapToRuntimeEvents( + event, + event.threadId, + turnRequestCorrelation, + ); + if (runtimeEvents.length === 0) { + yield* Effect.logDebug("ignoring unhandled Codex provider event", { + method: event.method, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.itemId, + }); + return; + } + yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + }), + ).pipe(Effect.forkChild); + const started = yield* runtime + .start() + .pipe( + Effect.onError(() => + runtime.close.pipe( + Effect.andThen(drainOutwardEventFiber(eventFiber)), + Effect.andThen(Effect.ignore(Scope.close(sessionScope, Exit.void))), + Effect.andThen(Fiber.interrupt(eventFiber)), + Effect.ignore, + ), + ), + ); + sessionScopeTransferred = true; + return { eventFiber, runtime, sessionScope, started } as const; + }); - const eventFiber = yield* Stream.runForEach(runtime.events, (event) => - Effect.gen(function* () { - yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event, event.threadId, turnRequestCorrelation); - if (runtimeEvents.length === 0) { - yield* Effect.logDebug("ignoring unhandled Codex provider event", { - method: event.method, - threadId: event.threadId, - turnId: event.turnId, - itemId: event.itemId, - }); - return; + const attempt = yield* startRuntimeAttempt(runtimeInput).pipe( + Effect.catch((cause) => { + const canTryUnelevated = + isCodexSessionRuntimeIsolationProbeError(cause) || + (isCodexSessionRuntimeWindowsSandboxSetupError(cause) && + cause.allowUnelevatedFallback); + if ( + hostPlatform !== "win32" || + !commandCenterThread || + !canTryUnelevated || + commandCenterRuntimeExecutable === undefined || + commandCenterHome === undefined + ) { + return Effect.fail(cause); } - yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + const fallbackIsolation = commandCenterCodexIsolation( + input.runtimeMode, + managedGitMetadata, + commandCenterRuntimeExecutable, + commandCenterHome, + hostPlatform, + "unelevated", + ); + if (fallbackIsolation === undefined) return Effect.fail(cause); + const fallbackInput: CodexSessionRuntimeOptions = { + ...runtimeInput, + permissionProfile: fallbackIsolation.permissionProfile, + commandCenterPlatform: "win32", + windowsSandboxMode: "unelevated", + appServerArgs: [...fallbackIsolation.appServerArgs, ...mcpAppServerArgs], + }; + return startRuntimeAttempt(fallbackInput); }), - ).pipe(Effect.forkChild); - - const started = yield* runtime.start().pipe( Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -1922,15 +2008,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( cause, }), ), - Effect.onError(() => - runtime.close.pipe( - Effect.andThen(drainOutwardEventFiber(eventFiber)), - Effect.andThen(Effect.ignore(Scope.close(sessionScope, Exit.void))), - Effect.andThen(Fiber.interrupt(eventFiber)), - Effect.ignore, - ), - ), ); + const { eventFiber, runtime, sessionScope, started } = attempt; sessions.set(input.threadId, { threadId: input.threadId, @@ -1941,7 +2020,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sendLock, stopped: false, }); - sessionScopeTransferred = true; return started; }), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index e12c8d85acd..c8d69c38d11 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -15,13 +15,75 @@ import { } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { + buildCommandCenterDarwinIsolationProbeScript, + buildCommandCenterWindowsIsolationProbeScript, buildTurnStartParams, + ensureCommandCenterWindowsSandbox, hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +describe("Command Center native sandbox admission", () => { + it.effect("skips Windows setup when Codex reports the sandbox ready", () => + Effect.gen(function* () { + const calls: string[] = []; + yield* ensureCommandCenterWindowsSandbox({ + client: { + request: (method) => + Effect.sync(() => { + calls.push(method); + return { status: "ready" }; + }), + }, + cwd: "C:\\workspace", + mode: "elevated", + setupCompleted: Effect.never, + }); + + NodeAssert.deepStrictEqual(calls, ["windowsSandbox/readiness"]); + }), + ); + + it.effect("marks failed elevated setup as eligible for verified unelevated fallback", () => + Effect.gen(function* () { + const error = yield* ensureCommandCenterWindowsSandbox({ + client: { + request: (method) => + Effect.succeed( + method === "windowsSandbox/readiness" + ? { status: "notConfigured" } + : { started: true }, + ), + }, + cwd: "C:\\workspace", + mode: "elevated", + setupCompleted: Effect.succeed({ + mode: "elevated", + success: false, + error: "administrator approval was unavailable", + }), + }).pipe(Effect.flip); + + NodeAssert.equal(error.mode, "elevated"); + NodeAssert.equal(error.allowUnelevatedFallback, true); + NodeAssert.equal(error.issue, "administrator approval was unavailable"); + }), + ); + + it("builds native probes without Linux-only process assumptions", () => { + const darwin = buildCommandCenterDarwinIsolationProbeScript(true); + const windows = buildCommandCenterWindowsIsolationProbeScript(false); + + 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); + }); +}); + describe("CodexSessionRuntimeIdentifierGenerationError", () => { it("retains identifier purpose and the random source failure", () => { const cause = new Error("random source unavailable"); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index e24ef494053..533d9abe56e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeNet from "node:net"; + import { ApprovalRequestId, DEFAULT_MODEL, @@ -50,6 +53,12 @@ const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V const decodeV2CommandExecResponse = Schema.decodeUnknownEffect( EffectCodexSchema.V2CommandExecResponse, ); +const decodeV2WindowsSandboxReadinessResponse = Schema.decodeUnknownEffect( + EffectCodexSchema.V2WindowsSandboxReadinessResponse, +); +const decodeV2WindowsSandboxSetupStartResponse = Schema.decodeUnknownEffect( + EffectCodexSchema.V2WindowsSandboxSetupStartResponse, +); // The generated response schemas omit `activePermissionProfile`, so it is // reassigned here. The Codex App Server sends `null` when no permission profile // is active (older CLIs, or a thread without an isolation profile), so the @@ -155,6 +164,8 @@ export interface CodexSessionRuntimeOptions { readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; readonly permissionProfile?: string; + readonly commandCenterPlatform?: NodeJS.Platform; + readonly windowsSandboxMode?: "elevated" | "unelevated"; } export interface CodexSessionRuntimeSendTurnInput { @@ -208,6 +219,7 @@ export interface CodexSessionRuntimeShape { export type CodexSessionRuntimeError = | CodexErrors.CodexAppServerError | CodexSessionRuntimeIsolationProbeError + | CodexSessionRuntimeWindowsSandboxSetupError | CodexSessionRuntimePermissionProfileMismatchError | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError @@ -226,6 +238,19 @@ export class CodexSessionRuntimeIsolationProbeError extends Schema.TaggedErrorCl } } +export class CodexSessionRuntimeWindowsSandboxSetupError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeWindowsSandboxSetupError", + { + issue: Schema.String, + mode: Schema.Literals(["elevated", "unelevated"]), + allowUnelevatedFallback: Schema.Boolean, + }, +) { + override get message(): string { + return this.issue; + } +} + export class CodexSessionRuntimePermissionProfileMismatchError extends Schema.TaggedErrorClass()( "CodexSessionRuntimePermissionProfileMismatchError", { @@ -583,7 +608,10 @@ function isCommandCenterPermissionProfile( ); } -export function buildCommandCenterIsolationProbeScript(writable: boolean): string { +export function buildCommandCenterIsolationProbeScript( + writable: boolean, + blockedNetworkPort?: number, +): string { const workspaceCheck = writable ? [ 'probe_path=".cc-provider-isolation-probe.$$"', @@ -612,6 +640,10 @@ export function buildCommandCenterIsolationProbeScript(writable: boolean): strin "done", '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 + ? [] + : [`if (: > /dev/tcp/127.0.0.1/${blockedNetworkPort}) 2>/dev/null; then`, " exit 79", "fi"]), "if test -e .git; then", " test ! -w .git || exit 75", " /usr/bin/git status --porcelain=v1 >/dev/null", @@ -625,6 +657,139 @@ export function buildCommandCenterIsolationProbeScript(writable: boolean): strin ].join("\n"); } +export function buildCommandCenterDarwinIsolationProbeScript( + writable: boolean, + blockedNetworkPort?: number, +): string { + const workspaceCheck = writable + ? [ + 'probe_path=".cc-provider-isolation-probe.$$"', + "trap 'rm -f \"$probe_path\"' EXIT", + ': > "$probe_path" || exit 73', + 'rm -f "$probe_path"', + "trap - EXIT", + ] + : [ + 'probe_path=".cc-provider-isolation-probe.$$"', + "trap 'rm -f \"$probe_path\"' EXIT", + '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", + '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 "$HOME/.cc-provider-isolation-canary" || exit 80', + ...(blockedNetworkPort === undefined + ? [] + : [ + `if /usr/bin/nc -z -w 1 127.0.0.1 ${blockedNetworkPort} 2>/dev/null; then`, + " exit 79", + "fi", + ]), + "if test -e .git; then", + " test ! -w .git || exit 75", + " /usr/bin/git status --porcelain=v1 >/dev/null", + " git_dir=$(/usr/bin/git rev-parse --git-dir 2>/dev/null || true)", + ' test -z "$git_dir" || test ! -w "$git_dir" || exit 76', + " common_git_dir=$(/usr/bin/git rev-parse --git-common-dir 2>/dev/null || true)", + ' test -z "$common_git_dir" || test ! -w "$common_git_dir" || exit 77', + "fi", + ...workspaceCheck, + `printf '${COMMAND_CENTER_ISOLATION_PROBE_SUCCESS}\\n'`, + ].join("\n"); +} + +export function buildCommandCenterWindowsIsolationProbeScript( + writable: boolean, + blockedNetworkPort?: number, +): string { + const workspaceCheck = writable + ? [ + "$probePath = Join-Path (Get-Location) '.cc-provider-isolation-probe'", + "try { [IO.File]::WriteAllText($probePath, '') } catch { exit 73 }", + "Remove-Item -LiteralPath $probePath -Force", + ] + : [ + "$probePath = Join-Path (Get-Location) '.cc-provider-isolation-probe'", + "try { [IO.File]::WriteAllText($probePath, ''); Remove-Item -LiteralPath $probePath -Force; exit 74 } catch {}", + `Write-Output '${COMMAND_CENTER_ISOLATION_READ_DENIAL_READY}'`, + "exit 73", + ]; + return [ + "$ErrorActionPreference = 'Stop'", + "if ($env:CC_PROVIDER_ISOLATION_SENTINEL -or $env:T3_MCP_BEARER_TOKEN -or $env:OPENAI_API_KEY) { exit 70 }", + "$authPath = Join-Path $env:USERPROFILE 'auth.json'", + "try { $stream = [IO.File]::OpenRead($authPath); $stream.Dispose(); exit 71 } catch {}", + "$canaryPath = Join-Path $env:USERPROFILE '.cc-provider-isolation-canary'", + "try { $stream = [IO.File]::OpenRead($canaryPath); $stream.Dispose(); exit 80 } catch {}", + ...(blockedNetworkPort === undefined + ? [] + : [ + `try { $client = [Net.Sockets.TcpClient]::new(); $client.Connect('127.0.0.1', ${blockedNetworkPort}); $client.Dispose(); exit 79 } catch {}`, + ]), + "if (Test-Path -LiteralPath '.git') {", + " $gitEntry = Get-Item -LiteralPath '.git' -Force", + " if ($gitEntry.PSIsContainer) {", + " $gitProbe = Join-Path $gitEntry.FullName '.cc-provider-isolation-probe'", + " try { [IO.File]::WriteAllText($gitProbe, ''); Remove-Item -LiteralPath $gitProbe -Force; exit 75 } catch {}", + " } else {", + " try { $stream = [IO.File]::Open($gitEntry.FullName, 'Open', 'Write'); $stream.Dispose(); exit 75 } catch {}", + " }", + " & git status --porcelain=v1 *> $null", + " if ($LASTEXITCODE -ne 0) { exit 78 }", + "}", + ...workspaceCheck, + `Write-Output '${COMMAND_CENTER_ISOLATION_PROBE_SUCCESS}'`, + ].join("\n"); +} + +const acquireCommandCenterNetworkProbe = Effect.callback< + { readonly server: NodeNet.Server; readonly port: number }, + CodexSessionRuntimeIsolationProbeError +>((resume) => { + const server = NodeNet.createServer((socket) => socket.destroy()); + server.unref(); + server.once("error", (cause) => + resume( + Effect.fail( + new CodexSessionRuntimeIsolationProbeError({ + issue: `Command Center could not open its local network-isolation probe: ${cause.message}`, + }), + ), + ), + ); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + resume( + port > 0 + ? Effect.succeed({ server, port }) + : Effect.fail( + new CodexSessionRuntimeIsolationProbeError({ + issue: "Command Center received an invalid local network-isolation probe port.", + }), + ), + ); + }); + return Effect.sync(() => server.close()); +}); + +const releaseCommandCenterNetworkProbe = (probe: { + readonly server: NodeNet.Server; + readonly port: number; +}) => + Effect.callback((resume) => { + probe.server.close(() => resume(Effect.void)); + }); + /** * Exercise the admitted profile through the same app-server command path used * by Codex tools before any untrusted model turn can run. @@ -635,6 +800,7 @@ export const verifyCommandCenterCodexIsolation = Effect.fn( readonly client: CodexIsolationProbeClient; readonly cwd: string; readonly permissionProfile: string; + readonly platform: NodeJS.Platform; }) { const writable = input.permissionProfile === COMMAND_CENTER_CODEX_WRITE_PERMISSION_PROFILE; if (!writable && input.permissionProfile !== COMMAND_CENTER_CODEX_READ_PERMISSION_PROFILE) { @@ -642,24 +808,53 @@ export const verifyCommandCenterCodexIsolation = Effect.fn( issue: "Command Center received an unknown Codex isolation profile.", }); } - const result = yield* input.client - .request("command/exec", { - command: ["/usr/bin/bash", "-c", buildCommandCenterIsolationProbeScript(writable)], - cwd: input.cwd, - timeoutMs: 10_000, - }) - .pipe( - Effect.flatMap(decodeV2CommandExecResponse), - Effect.mapError((cause) => - Schema.isSchemaError(cause) - ? CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - cause, - { method: "command/exec" }, - ) - : cause, - ), - ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const networkProbe = yield* Effect.acquireRelease( + acquireCommandCenterNetworkProbe, + releaseCommandCenterNetworkProbe, + ); + const command = + input.platform === "win32" + ? [ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + buildCommandCenterWindowsIsolationProbeScript(writable, networkProbe.port), + ] + : input.platform === "darwin" + ? [ + "/bin/bash", + "-c", + buildCommandCenterDarwinIsolationProbeScript(writable, networkProbe.port), + ] + : [ + "/usr/bin/bash", + "-c", + buildCommandCenterIsolationProbeScript(writable, networkProbe.port), + ]; + return yield* input.client + .request("command/exec", { + command, + cwd: input.cwd, + timeoutMs: 10_000, + }) + .pipe( + Effect.flatMap(decodeV2CommandExecResponse), + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + cause, + { method: "command/exec" }, + ) + : cause, + ), + ); + }), + ); 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; @@ -672,6 +867,73 @@ export const verifyCommandCenterCodexIsolation = Effect.fn( } }); +interface CodexWindowsSandboxSetupClient { + readonly request: ( + method: "windowsSandbox/readiness" | "windowsSandbox/setupStart", + payload: unknown, + ) => Effect.Effect; +} + +export const ensureCommandCenterWindowsSandbox = Effect.fn( + "CodexSessionRuntime.ensureCommandCenterWindowsSandbox", +)(function* (input: { + readonly client: CodexWindowsSandboxSetupClient; + readonly cwd: string; + readonly mode: "elevated" | "unelevated"; + readonly setupCompleted: Effect.Effect< + EffectCodexSchema.V2WindowsSandboxSetupCompletedNotification, + never + >; +}) { + const setupError = (issue: string, allowUnelevatedFallback = input.mode === "elevated") => + new CodexSessionRuntimeWindowsSandboxSetupError({ + issue, + mode: input.mode, + allowUnelevatedFallback, + }); + const readiness = yield* input.client.request("windowsSandbox/readiness", undefined).pipe( + Effect.flatMap(decodeV2WindowsSandboxReadinessResponse), + Effect.mapError((cause) => + setupError( + `Command Center requires a Codex installation with native Windows sandbox setup support: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ), + ), + ); + if (readiness.status === "ready") return; + + const started = yield* input.client + .request("windowsSandbox/setupStart", { mode: input.mode, cwd: input.cwd }) + .pipe( + Effect.flatMap(decodeV2WindowsSandboxSetupStartResponse), + Effect.mapError((cause) => + setupError( + `Codex could not start ${input.mode} Windows sandbox setup: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ), + ); + if (!started.started) { + return yield* setupError(`Codex declined to start ${input.mode} Windows sandbox setup.`); + } + + const completed = yield* input.setupCompleted.pipe( + Effect.timeout("2 minutes"), + Effect.mapError(() => + setupError(`Timed out waiting for ${input.mode} Windows sandbox setup to complete.`), + ), + ); + if (completed.mode !== input.mode) { + return yield* setupError( + `Codex completed '${completed.mode}' Windows sandbox setup while '${input.mode}' was required.`, + false, + ); + } + if (!completed.success) { + const safeError = completed.error?.split(/\r?\n/u)[0]?.trim(); + return yield* setupError(safeError || `Codex ${input.mode} Windows sandbox setup failed.`); + } +}); + export const openCodexThread = (input: { readonly client: CodexThreadOpenClient; readonly threadId: ThreadId; @@ -1052,6 +1314,8 @@ export const makeCodexSessionRuntime = ( Effect.provide(clientContext), ); const serverNotifications = yield* Queue.unbounded(); + const windowsSandboxSetupCompleted = + yield* Deferred.make(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => crypto.randomUUIDv4.pipe( @@ -1136,6 +1400,9 @@ export const makeCodexSessionRuntime = ( const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { + if (notification.method === "windowsSandbox/setupCompleted") { + yield* Deferred.succeed(windowsSandboxSetupCompleted, notification.params); + } const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); @@ -1556,10 +1823,26 @@ export const makeCodexSessionRuntime = ( yield* client.notify("initialized", undefined); if (isCommandCenterPermissionProfile(options.permissionProfile)) { + if (options.commandCenterPlatform === "win32") { + if (options.windowsSandboxMode === undefined) { + return yield* new CodexSessionRuntimeWindowsSandboxSetupError({ + issue: "Command Center did not select a native Windows sandbox mode.", + mode: "elevated", + allowUnelevatedFallback: false, + }); + } + yield* ensureCommandCenterWindowsSandbox({ + client: client.raw, + cwd: options.cwd, + mode: options.windowsSandboxMode, + setupCompleted: Deferred.await(windowsSandboxSetupCompleted), + }); + } yield* verifyCommandCenterCodexIsolation({ client: client.raw, cwd: options.cwd, permissionProfile: options.permissionProfile, + platform: options.commandCenterPlatform ?? "linux", }); } diff --git a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts index 331f7612c86..a3c774aaf3e 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts @@ -12,12 +12,14 @@ import * as Path from "effect/Path"; import { COMMAND_CENTER_CODEX_READ_PERMISSION_PROFILE, COMMAND_CENTER_CODEX_WRITE_PERMISSION_PROFILE, + commandCenterExecutionClass, commandCenterCodexIsolation, commandCenterProviderEnvironment, commandCenterProviderIsolationIssue, commandCenterProviderPlatformIssue, isCommandCenterThreadId, prepareCommandCenterCodexHome, + resolveCommandCenterCodexRuntimeExecutable, resolveCommandCenterManagedGitMetadata, } from "./CommandCenterProviderIsolation.ts"; @@ -27,10 +29,29 @@ describe("CommandCenterProviderIsolation", () => { NodeAssert.equal(isCommandCenterThreadId("thread-1"), false); }); - it("allows only the live-verified Linux process boundary", () => { - NodeAssert.equal(commandCenterProviderPlatformIssue("linux"), undefined); - NodeAssert.match(commandCenterProviderPlatformIssue("darwin") ?? "", /dispatch is blocked/u); - NodeAssert.match(commandCenterProviderPlatformIssue("win32") ?? "", /dispatch is blocked/u); + it("admits native interactive chats while keeping unattended runs Linux-only", () => { + NodeAssert.equal(commandCenterExecutionClass("cc:interactive:run-1"), "interactive"); + NodeAssert.equal(commandCenterExecutionClass("cc:automation:run-1"), "automation"); + NodeAssert.equal(commandCenterExecutionClass("cc:run-1"), "legacy"); + NodeAssert.equal(commandCenterExecutionClass("thread-1"), undefined); + + NodeAssert.equal(commandCenterProviderPlatformIssue("linux", "cc:run-1"), undefined); + NodeAssert.equal( + commandCenterProviderPlatformIssue("darwin", "cc:interactive:run-1"), + undefined, + ); + NodeAssert.equal( + commandCenterProviderPlatformIssue("win32", "cc:interactive:run-1"), + undefined, + ); + NodeAssert.match( + commandCenterProviderPlatformIssue("darwin", "cc:automation:run-1") ?? "", + /requires a verified Linux host/u, + ); + NodeAssert.match( + commandCenterProviderPlatformIssue("win32", "cc:run-1") ?? "", + /requires a verified Linux host/u, + ); }); it("fails closed for unverified providers and full-access sessions", () => { @@ -96,7 +117,25 @@ describe("CommandCenterProviderIsolation", () => { NodeAssert.ok(read); NodeAssert.ok(write); const auto = commandCenterCodexIsolation("auto", undefined, "/runtime/codex", codexHome); + const windowsElevated = commandCenterCodexIsolation( + "approval-required", + undefined, + "C:\\runtime\\codex.exe", + codexHome, + "win32", + "elevated", + ); + const windowsUnelevated = commandCenterCodexIsolation( + "approval-required", + undefined, + "C:\\runtime\\codex.exe", + codexHome, + "win32", + "unelevated", + ); NodeAssert.ok(auto); + NodeAssert.ok(windowsElevated); + NodeAssert.ok(windowsUnelevated); NodeAssert.equal(read.permissionProfile, COMMAND_CENTER_CODEX_READ_PERMISSION_PROFILE); NodeAssert.equal(write.permissionProfile, COMMAND_CENTER_CODEX_WRITE_PERMISSION_PROFILE); @@ -128,6 +167,8 @@ describe("CommandCenterProviderIsolation", () => { NodeAssert.match(writeConfig, /default_permissions="command-center-isolated-write-v1"/u); NodeAssert.match(writeConfig, /":workspace_roots"=\{"\."="write"\}/u); NodeAssert.doesNotMatch(writeConfig, /network=\{enabled=true\}/u); + NodeAssert.match(windowsElevated.appServerArgs.join(" "), /windows\.sandbox="elevated"/u); + NodeAssert.match(windowsUnelevated.appServerArgs.join(" "), /windows\.sandbox="unelevated"/u); }); it.runIf(NodeProcess.platform === "linux")( @@ -254,21 +295,27 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => path, crypto, runtimeExecutablePath: NodeProcess.execPath, + platform: NodeProcess.platform, writableRoots: [root], }); const targetAuthPath = path.join(isolated.homePath, "auth.json"); const arg0BlockerPath = path.join(isolated.tempPath, "arg0"); NodeAssert.equal(yield* fileSystem.readFileString(targetAuthPath), '{"token":"test-only"}\n'); - for (const alias of [ - "codex-linux-sandbox", - "apply_patch", - "applypatch", - "codex-execve-wrapper", - ]) { + const aliases = + NodeProcess.platform === "win32" + ? ["apply_patch.bat", "applypatch.bat"] + : NodeProcess.platform === "darwin" + ? ["apply_patch", "applypatch", "codex-execve-wrapper"] + : ["codex-linux-sandbox", "apply_patch", "applypatch", "codex-execve-wrapper"]; + for (const alias of aliases) { const helperPath = path.join(isolated.helperBinPath, alias); const helper = yield* fileSystem.readFileString(helperPath); - NodeAssert.match(helper, /exec \/usr\/bin\/env -i/u); - NodeAssert.equal(helper.includes(`exec -a ${alias}`), true); + if (NodeProcess.platform === "win32") { + NodeAssert.match(helper, /--codex-run-as-apply-patch/u); + } else { + NodeAssert.match(helper, /exec \/usr\/bin\/env -i/u); + NodeAssert.equal(helper.includes(`exec -a ${alias}`), true); + } NodeAssert.doesNotMatch(helper, /T3_MCP_BEARER_TOKEN|OPENAI_API_KEY/u); if (NodeProcess.platform !== "win32") { NodeAssert.equal((yield* fileSystem.stat(helperPath)).mode & 0o777, 0o500); @@ -314,6 +361,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => path, crypto, runtimeExecutablePath: NodeProcess.execPath, + platform: NodeProcess.platform, writableRoots: [root], } as const; const isolated = yield* prepareCommandCenterCodexHome(input); @@ -342,6 +390,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => path, crypto, runtimeExecutablePath: NodeProcess.execPath, + platform: NodeProcess.platform, writableRoots: [root], } as const; const isolated = yield* prepareCommandCenterCodexHome(input); @@ -387,9 +436,10 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => path, crypto, runtimeExecutablePath: scriptPath, + platform: NodeProcess.platform, writableRoots: [], }).pipe(Effect.flip); - NodeAssert.match(scriptError.issue, /requires a native ELF Codex runtime/u); + NodeAssert.match(scriptError.issue, /requires a native Codex runtime/u); const writableRuntimeError = yield* prepareCommandCenterCodexHome({ stateDir, @@ -399,6 +449,7 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => path, crypto, runtimeExecutablePath: NodeProcess.execPath, + platform: NodeProcess.platform, writableRoots: [path.dirname(NodeProcess.execPath)], }).pipe(Effect.flip); NodeAssert.match( @@ -408,6 +459,85 @@ it.layer(NodeServices.layer)("CommandCenter provider runtime isolation", (it) => }), ); + it.effect("shares only Windows sandbox control state while keeping thread state separate", () => + 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-windows-home-" }); + const stateDir = path.join(root, "state"); + const sourceHomePath = path.join(root, "source-home"); + const runtimePath = path.join(root, "codex.exe"); + yield* fileSystem.makeDirectory(stateDir, { recursive: true }); + yield* fileSystem.makeDirectory(sourceHomePath, { recursive: true }); + yield* fileSystem.writeFile(runtimePath, Uint8Array.from([0x4d, 0x5a, 0x00, 0x00])); + + const makeHome = (threadId: string) => + prepareCommandCenterCodexHome({ + stateDir, + sourceHomePath, + threadId, + fileSystem, + path, + crypto, + runtimeExecutablePath: runtimePath, + platform: "win32", + writableRoots: [], + }); + const first = yield* makeHome("cc:interactive:first"); + const second = yield* makeHome("cc:interactive:second"); + + NodeAssert.equal(first.homePath, second.homePath); + NodeAssert.notEqual(first.tempPath, second.tempPath); + NodeAssert.match( + yield* fileSystem.readFileString(path.join(first.helperBinPath, "apply_patch.bat")), + /--codex-run-as-apply-patch/u, + ); + }), + ); + + it.effect("resolves the native executable behind an official Windows npm launcher", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cc-windows-runtime-" }); + const commandPath = path.join(root, "codex.cmd"); + const packageRoot = path.join(root, "node_modules", "@openai", "codex"); + const platformPackageRoot = path.join( + packageRoot, + "node_modules", + "@openai", + "codex-win32-x64", + ); + const nativePath = path.join( + platformPackageRoot, + "vendor", + "x86_64-pc-windows-msvc", + "codex", + "codex.exe", + ); + yield* writeFile(commandPath, "@echo off\r\n"); + yield* writeFile(path.join(packageRoot, "bin", "codex.js"), "// launcher\n"); + yield* writeFile( + path.join(platformPackageRoot, "package.json"), + '{"name":"@openai/codex-win32-x64","version":"0.0.0"}\n', + ); + yield* fileSystem.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fileSystem.writeFile(nativePath, Uint8Array.from([0x4d, 0x5a, 0x00, 0x00])); + + NodeAssert.equal( + yield* resolveCommandCenterCodexRuntimeExecutable({ + commandPath, + platform: "win32", + 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 9bd59613593..0b0d84e5c13 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; import type { ProviderDriverKind, RuntimeMode, ThreadId } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -14,16 +15,25 @@ import * as Schema from "effect/Schema"; import { trustedHostExecutablePath, unsafeHostGitConfigKey } from "../../vcs/HostGitSecurity.ts"; export const COMMAND_CENTER_THREAD_ID_PREFIX = "cc:"; +export const COMMAND_CENTER_INTERACTIVE_THREAD_ID_PREFIX = "cc:interactive:"; +export const COMMAND_CENTER_AUTOMATION_THREAD_ID_PREFIX = "cc:automation:"; + +export type CommandCenterExecutionClass = "interactive" | "automation" | "legacy"; export const COMMAND_CENTER_CODEX_READ_PERMISSION_PROFILE = "command-center-isolated-read-v1"; export const COMMAND_CENTER_CODEX_WRITE_PERMISSION_PROFILE = "command-center-isolated-write-v1"; -const COMMAND_CENTER_CODEX_RUNTIME_ALIASES = [ +const COMMAND_CENTER_CODEX_LINUX_RUNTIME_ALIASES = [ "codex-linux-sandbox", "apply_patch", "applypatch", "codex-execve-wrapper", ] as const; +const COMMAND_CENTER_CODEX_DARWIN_RUNTIME_ALIASES = [ + "apply_patch", + "applypatch", + "codex-execve-wrapper", +] as const; const MAX_LOCAL_GIT_CONFIG_BYTES = FileSystem.Size(1024 * 1024); @@ -70,6 +80,7 @@ function sameControlFileIdentity( export interface CommandCenterCodexIsolation { readonly permissionProfile: string; readonly appServerArgs: ReadonlyArray; + readonly windowsSandboxMode?: "elevated" | "unelevated"; } export interface CommandCenterManagedGitMetadata { @@ -116,6 +127,8 @@ export interface CommandCenterProviderEnvironmentInput { readonly source: NodeJS.ProcessEnv; readonly homePath: string; readonly helperBinPath: string; + /** Optional packaged-tool directory next to the admitted native Codex runtime. */ + readonly runtimeSupportPath?: string; readonly tempPath: string; readonly xdgConfigPath: string; readonly xdgCachePath: string; @@ -146,7 +159,15 @@ export function commandCenterProviderEnvironment( if (value !== undefined) environment[name] = value; } const trustedPath = trustedHostExecutablePath({ - sourceEnvironment: input.source, + sourceEnvironment: + input.runtimeSupportPath === undefined + ? input.source + : { + ...input.source, + PATH: [input.runtimeSupportPath, readEnvironmentValue(input.source, "PATH")] + .filter((entry): entry is string => entry !== undefined) + .join(NodePath.delimiter), + }, writableRoots: input.writableRoots, }); const providerPath = [input.helperBinPath, ...trustedPath.split(NodePath.delimiter)] @@ -225,18 +246,47 @@ export interface PrepareCommandCenterCodexHomeInput { readonly path: Path.Path; readonly crypto: Crypto.Crypto; readonly runtimeExecutablePath: string; + readonly platform: NodeJS.Platform; /** Canonicalized below; the native runtime must not be replaceable by a provider turn. */ readonly writableRoots: ReadonlyArray; } +export interface ResolveCommandCenterCodexRuntimeInput { + readonly commandPath: string; + readonly platform: NodeJS.Platform; + readonly architecture: NodeJS.Architecture; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; +} + export function isCommandCenterThreadId(threadId: ThreadId | string): boolean { return String(threadId).startsWith(COMMAND_CENTER_THREAD_ID_PREFIX); } -export function commandCenterProviderPlatformIssue(platform: NodeJS.Platform): string | undefined { - return platform === "linux" - ? undefined - : "Command Center provider isolation is supported only on verified Linux hosts; macOS and Windows dispatch is blocked."; +export function commandCenterExecutionClass( + threadId: ThreadId | string, +): CommandCenterExecutionClass | undefined { + const value = String(threadId); + if (value.startsWith(COMMAND_CENTER_INTERACTIVE_THREAD_ID_PREFIX)) return "interactive"; + if (value.startsWith(COMMAND_CENTER_AUTOMATION_THREAD_ID_PREFIX)) return "automation"; + return value.startsWith(COMMAND_CENTER_THREAD_ID_PREFIX) ? "legacy" : undefined; +} + +export function commandCenterProviderPlatformIssue( + platform: NodeJS.Platform, + threadId: ThreadId | string, +): string | undefined { + if (platform === "linux") return undefined; + if ( + (platform === "darwin" || platform === "win32") && + commandCenterExecutionClass(threadId) === "interactive" + ) { + return undefined; + } + if (platform === "darwin" || platform === "win32") { + return "Unattended Command Center automation currently requires a verified Linux host; native macOS and Windows support is limited to user-started chats."; + } + return `Command Center provider isolation is not supported on '${platform}'.`; } export function commandCenterProviderIsolationIssue(input: { @@ -294,6 +344,10 @@ function isWithinRoot(path: Path.Path, candidate: string, root: string): boolean ); } +function isSameResolvedPath(path: Path.Path, left: string, right: string): boolean { + return path.relative(left, right) === "" && path.relative(right, left) === ""; +} + function safeManagedComponent(value: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value); } @@ -333,6 +387,78 @@ function codexHomeError(issue: string, cause?: unknown): CommandCenterCodexHomeI }); } +/** Resolve the native executable behind the official Windows npm launcher without executing it. */ +export const resolveCommandCenterCodexRuntimeExecutable = Effect.fn( + "CommandCenterProviderIsolation.resolveCodexRuntimeExecutable", +)(function* (input: ResolveCommandCenterCodexRuntimeInput) { + const { fileSystem, path } = input; + const canonicalCommandPath = yield* fileSystem + .realPath(input.commandPath) + .pipe( + Effect.mapError((cause) => + codexHomeError("Command Center could not canonicalize the Codex runtime launcher.", cause), + ), + ); + if (input.platform !== "win32" || path.extname(canonicalCommandPath).toLowerCase() === ".exe") { + 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; + if (target === undefined) { + return yield* codexHomeError( + `Command Center does not support the '${input.architecture}' Windows Codex runtime architecture.`, + ); + } + const codexPackageRoot = path.join( + path.dirname(canonicalCommandPath), + "node_modules", + "@openai", + "codex", + ); + const codexLauncherPath = path.join(codexPackageRoot, "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.", + ); + } + const packageJsonPath = yield* Effect.try({ + try: () => + NodeModule.createRequire(codexLauncherPath).resolve(`${target.packageName}/package.json`), + catch: (cause) => + codexHomeError( + `Command Center could not resolve the native ${target.packageName} package. Reinstall or update @openai/codex.`, + cause, + ), + }); + const nativeExecutablePath = path.join( + path.dirname(packageJsonPath), + "vendor", + target.triple, + "codex", + "codex.exe", + ); + return yield* fileSystem + .realPath(nativeExecutablePath) + .pipe( + Effect.mapError((cause) => + codexHomeError( + "Command Center could not locate the native codex.exe and its Windows sandbox resources. Reinstall or update @openai/codex.", + cause, + ), + ), + ); +}); + function isNotSymlink(error: PlatformError.PlatformError): boolean { const cause = error.reason.cause; return ( @@ -353,7 +479,7 @@ export const prepareCommandCenterCodexHome = Effect.fn( "CommandCenterProviderIsolation.prepareCodexHome", )(function* (input: PrepareCommandCenterCodexHomeInput) { const { fileSystem, path } = input; - const digest = Encoding.encodeHex( + const threadDigest = Encoding.encodeHex( yield* input.crypto .digest("SHA-256", new TextEncoder().encode(String(input.threadId))) .pipe( @@ -362,6 +488,20 @@ export const prepareCommandCenterCodexHome = Effect.fn( ), ), ); + const digest = Encoding.encodeHex( + yield* input.crypto + .digest( + "SHA-256", + new TextEncoder().encode( + input.platform === "win32" ? "windows-control-v1" : String(input.threadId), + ), + ) + .pipe( + Effect.mapError((cause) => + codexHomeError("Command Center could not derive its isolated Codex home.", cause), + ), + ), + ); const canonicalStateDir = yield* fileSystem .realPath(input.stateDir) .pipe( @@ -371,15 +511,18 @@ export const prepareCommandCenterCodexHome = Effect.fn( ); const homesRoot = path.join(canonicalStateDir, "provider-homes", "codex-command-center"); const homePath = path.join(homesRoot, digest); + const codexTempPath = path.join(homePath, "tmp"); + const threadStatePath = + input.platform === "win32" ? path.join(homePath, "thread-state", threadDigest) : homePath; const layout = { homePath, helperBinPath: path.join(homePath, "provider-bin"), - tempPath: path.join(homePath, "tmp"), - xdgConfigPath: path.join(homePath, "xdg-config"), - xdgCachePath: path.join(homePath, "xdg-cache"), - xdgDataPath: path.join(homePath, "xdg-data"), - appDataPath: path.join(homePath, "app-data"), - localAppDataPath: path.join(homePath, "local-app-data"), + tempPath: path.join(threadStatePath, "tmp"), + xdgConfigPath: path.join(threadStatePath, "xdg-config"), + xdgCachePath: path.join(threadStatePath, "xdg-cache"), + xdgDataPath: path.join(threadStatePath, "xdg-data"), + appDataPath: path.join(threadStatePath, "app-data"), + localAppDataPath: path.join(threadStatePath, "local-app-data"), } satisfies CommandCenterCodexHomeLayout; const makePrivateDirectory = (directoryPath: string) => @@ -393,6 +536,8 @@ export const prepareCommandCenterCodexHome = Effect.fn( [ homesRoot, layout.homePath, + codexTempPath, + threadStatePath, layout.helperBinPath, layout.tempPath, layout.xdgConfigPath, @@ -420,8 +565,8 @@ export const prepareCommandCenterCodexHome = Effect.fn( ), ); if ( - canonicalHomesRoot !== homesRoot || - canonicalHomePath !== layout.homePath || + !isSameResolvedPath(path, canonicalHomesRoot, homesRoot) || + !isSameResolvedPath(path, canonicalHomePath, layout.homePath) || !isWithinRoot(path, canonicalHomePath, canonicalHomesRoot) ) { return yield* codexHomeError("Command Center's isolated Codex home contains a symlink escape."); @@ -471,7 +616,10 @@ export const prepareCommandCenterCodexHome = Effect.fn( codexHomeError("Command Center could not inspect its Codex runtime executable.", cause), ), ); - if (runtimeStat.type !== "File" || (runtimeStat.mode & 0o111) === 0) { + if ( + runtimeStat.type !== "File" || + (input.platform !== "win32" && (runtimeStat.mode & 0o111) === 0) + ) { return yield* codexHomeError( "Command Center requires the Codex runtime to be a regular executable file.", ); @@ -486,16 +634,25 @@ export const prepareCommandCenterCodexHome = Effect.fn( codexHomeError("Command Center could not inspect its Codex runtime format.", cause), ), ); - if ( - Option.isNone(runtimeHeader) || - runtimeHeader.value.length !== 4 || - runtimeHeader.value[0] !== 0x7f || - runtimeHeader.value[1] !== 0x45 || - runtimeHeader.value[2] !== 0x4c || - runtimeHeader.value[3] !== 0x46 - ) { + const header = Option.getOrUndefined(runtimeHeader); + const headerMagic = header === undefined ? "" : Encoding.encodeHex(header); + const expectedNativeFormat = + (input.platform === "linux" && headerMagic === "7f454c46") || + (input.platform === "darwin" && + [ + "feedface", + "feedfacf", + "cefaedfe", + "cffaedfe", + "cafebabe", + "bebafeca", + "cafebabf", + "bfbafeca", + ].includes(headerMagic)) || + (input.platform === "win32" && header?.[0] === 0x4d && header[1] === 0x5a); + if (!expectedNativeFormat) { return yield* codexHomeError( - "Command Center requires a native ELF Codex runtime; script and Node.js launchers are blocked.", + `Command Center requires a native Codex runtime for '${input.platform}'; script, shim, and cross-platform launchers are blocked.`, ); } const canonicalWritableRoots = yield* Effect.forEach( @@ -515,32 +672,7 @@ export const prepareCommandCenterCodexHome = Effect.fn( "Command Center refuses a Codex runtime located under a provider-writable root.", ); } - const [canonicalEnv, canonicalBash] = yield* Effect.all([ - fileSystem.realPath("/usr/bin/env"), - fileSystem.realPath("/usr/bin/bash"), - ]).pipe( - Effect.mapError((cause) => - codexHomeError( - "Command Center requires canonical /usr/bin/env and /usr/bin/bash helpers on Linux.", - cause, - ), - ), - ); - if (canonicalEnv !== "/usr/bin/env" || canonicalBash !== "/usr/bin/bash") { - return yield* codexHomeError( - "Command Center refuses non-canonical Linux environment or shell helpers.", - ); - } const shellQuote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; - const helperWrapper = (alias: (typeof COMMAND_CENTER_CODEX_RUNTIME_ALIASES)[number]) => - [ - "#!/usr/bin/bash", - "set -euo pipefail", - `canonical_codex=${shellQuote(canonicalRuntimeExecutable)}`, - `isolated_home=${shellQuote(layout.homePath)}`, - `exec /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="$isolated_home" LANG=C.UTF-8 /usr/bin/bash -c 'exec -a ${alias} "$@"' _ "$canonical_codex" "$@"`, - "", - ].join("\n"); const writePrivateFile = Effect.fn("CommandCenterProviderIsolation.writePrivateFile")(function* ( targetPath: string, contents: Uint8Array, @@ -550,7 +682,7 @@ export const prepareCommandCenterCodexHome = Effect.fn( path.dirname(targetPath), `.cc-${yield* input.crypto.randomUUIDv4.pipe( Effect.mapError((cause) => - codexHomeError("Command Center could not stage its Linux sandbox helper.", cause), + codexHomeError("Command Center could not stage its native sandbox helper.", cause), ), )}.tmp`, ); @@ -559,25 +691,81 @@ export const prepareCommandCenterCodexHome = Effect.fn( Effect.andThen(fileSystem.rename(temporaryPath, targetPath)), Effect.andThen(fileSystem.chmod(targetPath, mode)), Effect.mapError((cause) => - codexHomeError("Command Center could not install its Linux sandbox helper.", cause), + codexHomeError("Command Center could not install its native sandbox helper.", cause), ), Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)), ); }); - yield* Effect.forEach( - COMMAND_CENTER_CODEX_RUNTIME_ALIASES, - (alias) => - writePrivateFile( - path.join(layout.helperBinPath, alias), - new TextEncoder().encode(helperWrapper(alias)), - 0o500, - ), - { concurrency: 1, discard: true }, + yield* writePrivateFile( + path.join(layout.homePath, ".cc-provider-isolation-canary"), + new TextEncoder().encode("Command Center private provider state.\n"), + 0o600, ); - // Codex normally creates an argv0 alias under this path. A mode-000 regular - // file makes that update fail closed and forces the documented PATH helper - // lookup, where the scrubbed wrapper above is first. - yield* writePrivateFile(path.join(layout.tempPath, "arg0"), new Uint8Array(), 0o000); + + if (input.platform === "linux" || input.platform === "darwin") { + const expectedEnv = "/usr/bin/env"; + const expectedBash = input.platform === "linux" ? "/usr/bin/bash" : "/bin/bash"; + const [canonicalEnv, canonicalBash, canonicalNetcat] = yield* Effect.all([ + fileSystem.realPath(expectedEnv), + fileSystem.realPath(expectedBash), + input.platform === "darwin" ? fileSystem.realPath("/usr/bin/nc") : Effect.void, + ]).pipe( + Effect.mapError((cause) => + codexHomeError( + `Command Center requires canonical ${expectedEnv} and ${expectedBash} helpers on ${input.platform}.`, + cause, + ), + ), + ); + if ( + canonicalEnv !== expectedEnv || + canonicalBash !== expectedBash || + (input.platform === "darwin" && canonicalNetcat !== "/usr/bin/nc") + ) { + return yield* codexHomeError( + `Command Center refuses non-canonical ${input.platform} environment or shell helpers.`, + ); + } + const aliases = + input.platform === "linux" + ? COMMAND_CENTER_CODEX_LINUX_RUNTIME_ALIASES + : COMMAND_CENTER_CODEX_DARWIN_RUNTIME_ALIASES; + const helperWrapper = (alias: (typeof aliases)[number]) => + [ + `#!${expectedBash}`, + "set -euo pipefail", + `canonical_codex=${shellQuote(canonicalRuntimeExecutable)}`, + `isolated_home=${shellQuote(layout.homePath)}`, + `exec ${expectedEnv} -i PATH=/usr/local/bin:/usr/bin:/bin HOME="$isolated_home" LANG=C.UTF-8 ${expectedBash} -c 'exec -a ${alias} "$@"' _ "$canonical_codex" "$@"`, + "", + ].join("\n"); + yield* Effect.forEach( + aliases, + (alias) => + writePrivateFile( + path.join(layout.helperBinPath, alias), + new TextEncoder().encode(helperWrapper(alias)), + 0o500, + ), + { concurrency: 1, discard: true }, + ); + } else if (input.platform === "win32") { + const escapedRuntime = canonicalRuntimeExecutable.replaceAll("%", "%%"); + const applyPatchWrapper = new TextEncoder().encode( + `@echo off\r\n"${escapedRuntime}" --codex-run-as-apply-patch %*\r\n`, + ); + yield* Effect.forEach( + ["apply_patch.bat", "applypatch.bat"], + (alias) => writePrivateFile(path.join(layout.helperBinPath, alias), applyPatchWrapper, 0o600), + { concurrency: 1, discard: true }, + ); + } + if (input.platform !== "win32") { + // Codex normally creates an argv0 alias under this path. A mode-000 regular + // file makes that update fail closed and forces the documented PATH helper + // lookup, where the scrubbed wrapper above is first. + yield* writePrivateFile(path.join(codexTempPath, "arg0"), new Uint8Array(), 0o000); + } const sourceAuthPath = path.join(path.resolve(input.sourceHomePath), "auth.json"); const targetAuthPath = path.join(layout.homePath, "auth.json"); @@ -694,7 +882,11 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( fileSystem.stat(target), fileSystem.realPath(target), ]).pipe(Effect.mapError((cause) => managedWorktreeError(issue, cause))); - if (info.type !== "File" || Option.getOrUndefined(info.nlink) !== 1 || canonical !== target) { + if ( + info.type !== "File" || + Option.getOrUndefined(info.nlink) !== 1 || + !isSameResolvedPath(path, canonical, target) + ) { return yield* managedWorktreeError(issue); } }); @@ -724,7 +916,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( `Command Center's ${config.description} is unavailable.`, ); } - if (canonical.value !== config.target) { + if (!isSameResolvedPath(path, canonical.value, config.target)) { return yield* managedWorktreeError( `Command Center's ${config.description} must not be a symlink.`, ); @@ -768,7 +960,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( ), ); if ( - canonicalAfter !== canonical.value || + !isSameResolvedPath(path, canonicalAfter, canonical.value) || !sameControlFileIdentity(expectedIdentity, secureControlFileIdentity(after)) ) { return yield* managedWorktreeError( @@ -801,7 +993,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( ), ]); const expectedWorktreesDir = path.join(canonicalBaseDir, "worktrees"); - if (canonicalWorktreesDir !== expectedWorktreesDir) { + if (!isSameResolvedPath(path, canonicalWorktreesDir, expectedWorktreesDir)) { return yield* managedWorktreeError( "Command Center's managed worktree directory escapes its runtime base directory.", ); @@ -810,7 +1002,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( const dotGitPath = path.join(canonicalCwd, ".git"); const dotGitStat = yield* statOptional(dotGitPath); const inManagedWorktrees = - canonicalCwd !== canonicalWorktreesDir && + !isSameResolvedPath(path, canonicalCwd, canonicalWorktreesDir) && isWithinRoot(path, canonicalCwd, canonicalWorktreesDir); if (!inManagedWorktrees) { @@ -852,7 +1044,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( gitDirCandidate, "Command Center could not canonicalize the managed worktree Git directory.", ); - if (path.normalize(gitDirCandidate) !== canonicalGitDir) { + if (!isSameResolvedPath(path, path.normalize(gitDirCandidate), canonicalGitDir)) { return yield* managedWorktreeError( "The managed Command Center worktree Git directory contains a symlink escape.", ); @@ -881,7 +1073,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( commonDirCandidate, "Command Center could not canonicalize the managed common Git directory.", ); - if (path.normalize(commonDirCandidate) !== canonicalCommonDir) { + if (!isSameResolvedPath(path, path.normalize(commonDirCandidate), canonicalCommonDir)) { return yield* managedWorktreeError( "The managed Command Center common Git directory contains a symlink escape.", ); @@ -892,7 +1084,7 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( repositoriesDir, "Command Center could not canonicalize its managed repository directory.", ); - if (canonicalRepositoriesDir !== repositoriesDir) { + if (!isSameResolvedPath(path, canonicalRepositoriesDir, repositoriesDir)) { return yield* managedWorktreeError( "Command Center's managed repository directory contains a symlink escape.", ); @@ -950,8 +1142,8 @@ export const resolveCommandCenterManagedGitMetadata = Effect.fn( "Command Center could not canonicalize the workspace Git metadata pointer.", ); if ( - path.normalize(reversePointerCandidate) !== canonicalReversePointer || - canonicalReversePointer !== canonicalDotGitPath + !isSameResolvedPath(path, path.normalize(reversePointerCandidate), canonicalReversePointer) || + !isSameResolvedPath(path, canonicalReversePointer, canonicalDotGitPath) ) { return yield* managedWorktreeError( "The managed Command Center worktree Git metadata pointers do not round-trip safely.", @@ -981,6 +1173,8 @@ export function commandCenterCodexIsolation( managedGitMetadata?: CommandCenterManagedGitMetadata, runtimeExecutablePath?: string, codexHome?: Pick, + platform: NodeJS.Platform = "linux", + windowsSandboxMode: "elevated" | "unelevated" = "elevated", ): CommandCenterCodexIsolation | undefined { if ( runtimeMode === "full-access" || @@ -1004,6 +1198,7 @@ export function commandCenterCodexIsolation( }); return { permissionProfile, + ...(platform === "win32" ? { windowsSandboxMode } : {}), appServerArgs: [ "--strict-config", "-c", @@ -1042,6 +1237,9 @@ export function commandCenterCodexIsolation( `default_permissions=${JSON.stringify(permissionProfile)}`, "-c", `permissions.${permissionProfile}=${profile}`, + ...(platform === "win32" + ? ["-c", `windows.sandbox=${JSON.stringify(windowsSandboxMode)}`] + : []), ], }; } diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 7c5ea91f043..f4e32ad9ea8 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -28,6 +28,20 @@ Log in with Codex normally: codex login ``` +## Command Center Chats + +User-started Command Center chats run directly on Linux, macOS, and Windows. Windows does not +require WSL. The first native Windows chat may ask for administrator approval while Codex prepares +its stronger sandbox. If that setup is unavailable, T3 Code tries Codex's non-administrator sandbox +and continues only when the same filesystem, secret, Git, and network checks pass. + +Keep Codex current if Command Center reports that native sandbox setup or packaged helper files are +missing. T3 Code blocks the chat when the installed runtime cannot prove the required isolation and +shows the setup problem without a diagnostic stack trace. + +Scheduled and unattended Command Center automation still requires a Linux host. This limitation +does not apply to a chat you start yourself on macOS or Windows. + ## I Want Work And Personal Codex Accounts Use one real Codex home and one shadow home.