diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 61e0306a07d0..d54d8abb52d4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -439,65 +439,61 @@ 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", +it.effect("fails closed when elevated Windows Command Center isolation cannot be verified", () => { + 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, }), - ).pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { prefix: "codex-adapter-windows-fallback-" }), - ), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); + ], + }); + 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({ + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("cc:interactive:windows-fallback"); + const result = 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 }))), - ); - }, -); + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.match(result.failure.message, /elevated live isolation probe failed/u); + NodeAssert.equal(runtimeFactory.runtimes.length, 1); + NodeAssert.equal(runtimeFactory.runtimes[0]?.options.windowsSandboxMode, "elevated"); + NodeAssert.equal(runtimeFactory.runtimes[0]?.closeImpl.mock.calls.length, 1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); const sessionRuntimeFactory = makeRuntimeFactory(); const sessionErrorLayer = it.layer( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38897d52e025..4f019aa1852c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -61,8 +61,6 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, - CodexSessionRuntimeIsolationProbeError, - CodexSessionRuntimeWindowsSandboxSetupError, CodexSessionRuntimeThreadIdMissingError, makeCodexSessionRuntime, matchesCodexInterruptTarget, @@ -90,10 +88,6 @@ 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); @@ -1967,38 +1961,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); 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); - } - 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); - }), Effect.mapError( (cause) => new ProviderAdapterProcessError({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c8d69c38d11f..7bf274292a7e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -46,7 +46,7 @@ describe("Command Center native sandbox admission", () => { }), ); - it.effect("marks failed elevated setup as eligible for verified unelevated fallback", () => + it.effect("reports failed elevated setup without offering an unsafe fallback", () => Effect.gen(function* () { const error = yield* ensureCommandCenterWindowsSandbox({ client: { @@ -67,8 +67,8 @@ describe("Command Center native sandbox admission", () => { }).pipe(Effect.flip); NodeAssert.equal(error.mode, "elevated"); - NodeAssert.equal(error.allowUnelevatedFallback, true); - NodeAssert.equal(error.issue, "administrator approval was unavailable"); + NodeAssert.match(error.issue, /administrator-approved Windows sandbox/u); + NodeAssert.match(error.issue, /administrator approval was unavailable/u); }), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 533d9abe56e2..d97fff804513 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -165,7 +165,7 @@ export interface CodexSessionRuntimeOptions { readonly appServerArgs?: ReadonlyArray; readonly permissionProfile?: string; readonly commandCenterPlatform?: NodeJS.Platform; - readonly windowsSandboxMode?: "elevated" | "unelevated"; + readonly windowsSandboxMode?: "elevated"; } export interface CodexSessionRuntimeSendTurnInput { @@ -243,7 +243,6 @@ export class CodexSessionRuntimeWindowsSandboxSetupError extends Schema.TaggedEr { issue: Schema.String, mode: Schema.Literals(["elevated", "unelevated"]), - allowUnelevatedFallback: Schema.Boolean, }, ) { override get message(): string { @@ -879,24 +878,22 @@ export const ensureCommandCenterWindowsSandbox = Effect.fn( )(function* (input: { readonly client: CodexWindowsSandboxSetupClient; readonly cwd: string; - readonly mode: "elevated" | "unelevated"; + readonly mode: "elevated"; readonly setupCompleted: Effect.Effect< EffectCodexSchema.V2WindowsSandboxSetupCompletedNotification, never >; }) { - const setupError = (issue: string, allowUnelevatedFallback = input.mode === "elevated") => + const setupError = (issue: string) => 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, ), ), ); @@ -925,12 +922,13 @@ export const ensureCommandCenterWindowsSandbox = Effect.fn( 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.`); + return yield* setupError( + `Command Center requires Codex's administrator-approved Windows sandbox to enforce workspace-only filesystem access. Approve the administrator prompt and try again.${safeError ? ` Codex reported: ${safeError}` : ""}`, + ); } }); @@ -1828,7 +1826,6 @@ export const makeCodexSessionRuntime = ( return yield* new CodexSessionRuntimeWindowsSandboxSetupError({ issue: "Command Center did not select a native Windows sandbox mode.", mode: "elevated", - allowUnelevatedFallback: false, }); } yield* ensureCommandCenterWindowsSandbox({ diff --git a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts index 2cb78bea6818..8569a539453f 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.test.ts @@ -123,19 +123,9 @@ describe("CommandCenterProviderIsolation", () => { "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); @@ -168,7 +158,7 @@ describe("CommandCenterProviderIsolation", () => { 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); + NodeAssert.doesNotMatch(windowsElevated.appServerArgs.join(" "), /unelevated/u); }); it.runIf(NodeProcess.platform === "linux")( diff --git a/apps/server/src/provider/security/CommandCenterProviderIsolation.ts b/apps/server/src/provider/security/CommandCenterProviderIsolation.ts index 4e709bf17f3c..34355c7c1395 100644 --- a/apps/server/src/provider/security/CommandCenterProviderIsolation.ts +++ b/apps/server/src/provider/security/CommandCenterProviderIsolation.ts @@ -80,7 +80,7 @@ function sameControlFileIdentity( export interface CommandCenterCodexIsolation { readonly permissionProfile: string; readonly appServerArgs: ReadonlyArray; - readonly windowsSandboxMode?: "elevated" | "unelevated"; + readonly windowsSandboxMode?: "elevated"; } export interface CommandCenterManagedGitMetadata { @@ -1176,7 +1176,6 @@ export function commandCenterCodexIsolation( runtimeExecutablePath?: string, codexHome?: Pick, platform: NodeJS.Platform = "linux", - windowsSandboxMode: "elevated" | "unelevated" = "elevated", ): CommandCenterCodexIsolation | undefined { if ( runtimeMode === "full-access" || @@ -1200,7 +1199,7 @@ export function commandCenterCodexIsolation( }); return { permissionProfile, - ...(platform === "win32" ? { windowsSandboxMode } : {}), + ...(platform === "win32" ? { windowsSandboxMode: "elevated" as const } : {}), appServerArgs: [ "--strict-config", "-c", @@ -1239,9 +1238,7 @@ export function commandCenterCodexIsolation( `default_permissions=${JSON.stringify(permissionProfile)}`, "-c", `permissions.${permissionProfile}=${profile}`, - ...(platform === "win32" - ? ["-c", `windows.sandbox=${JSON.stringify(windowsSandboxMode)}`] - : []), + ...(platform === "win32" ? ["-c", 'windows.sandbox="elevated"'] : []), ], }; } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 051045b030c3..a010b0fd169b 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ServerProviderModel } from "@t3tools/contracts"; import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; +import { getProviderMaintenancePresentation } from "./providerStatus"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -34,3 +35,33 @@ describe("deriveProviderModelsForDisplay", () => { ).toEqual(["server-model", "kept-custom"]); }); }); + +describe("getProviderMaintenancePresentation", () => { + it("offers update or reinstall even when the installed version is current", () => { + expect( + getProviderMaintenancePresentation({ + status: "current", + currentVersion: "1.2.3", + latestVersion: "1.2.3", + updateCommand: "npm install -g @openai/codex@latest", + canUpdate: true, + checkedAt: null, + message: null, + }), + ).toEqual({ updateCommand: "npm install -g @openai/codex@latest" }); + }); + + it("withholds maintenance when the installation cannot be updated safely", () => { + expect( + getProviderMaintenancePresentation({ + status: "unknown", + currentVersion: null, + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: null, + message: null, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ef56f5fee6f4..738283c39d4f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -44,6 +44,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + getProviderMaintenancePresentation, getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, getProviderSummary, @@ -411,7 +412,8 @@ export function ProviderInstanceCard({ const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); - const updateCommand = versionAdvisory?.updateCommand ?? null; + const providerMaintenance = getProviderMaintenancePresentation(liveProvider?.versionAdvisory); + const updateCommand = providerMaintenance?.updateCommand ?? null; const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -764,6 +766,31 @@ export function ProviderInstanceCard({ /> + {onRunUpdate && updateCommand ? ( +
+
+
+
Provider installation
+

+ Update to the latest release or reinstall the current release to repair + missing native files. +

+
+ +
+
+ ) : null} + {driverOption ? ( { + async (candidate: Pick) => { if (!primaryEnvironment) return; let started = false; setUpdatingProviderDrivers((previous) => { @@ -2178,12 +2179,15 @@ export function ProviderSettingsPanel() { const updateCandidate = liveProvider ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) : undefined; + const providerMaintenance = getProviderMaintenancePresentation( + liveProvider?.versionAdvisory, + ); const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || + liveProvider !== undefined && + (updatingProviderDrivers.has(liveProvider.driver) || serverProviders.some( (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + provider.driver === liveProvider.driver && isProviderUpdateActive(provider), )); const showInlineUpdateButton = updateCandidate !== undefined && @@ -2192,6 +2196,15 @@ export function ProviderSettingsPanel() { updateCandidate !== undefined && canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && !updatingProviderDrivers.has(updateCandidate.driver); + const showProviderMaintenanceButton = + liveProvider !== undefined && + providerMaintenance !== null && + (updateCandidate === undefined || showInlineUpdateButton); + const canRunProviderMaintenance = + showProviderMaintenanceButton && + !updatingProviderDrivers.has(liveProvider.driver) && + !isProviderUpdateActive(liveProvider) && + (updateCandidate === undefined || canRunInlineUpdate); const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { hiddenModels: [], modelOrder: [], @@ -2255,16 +2268,14 @@ export function ProviderSettingsPanel() { }) } onRunUpdate={ - showInlineUpdateButton && updateCandidate + showProviderMaintenanceButton && liveProvider ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); + if (!canRunProviderMaintenance) return; + void runProviderUpdate(liveProvider); } : undefined } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + isUpdating={showProviderMaintenanceButton ? isDriverUpdateRunning : undefined} /> ); })} diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index a616d4643e54..404b67d0a7b6 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -116,3 +116,12 @@ export function getProviderVersionAdvisoryPresentation( emphasis: "normal" as const, }; } + +export function getProviderMaintenancePresentation( + advisory: ServerProviderVersionAdvisory | undefined, +): { readonly updateCommand: string } | null { + if (advisory?.canUpdate !== true || advisory.updateCommand === null) { + return null; + } + return { updateCommand: advisory.updateCommand }; +} diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index f4e32ad9ea84..a9ce25ed89be 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -32,11 +32,14 @@ codex login 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. +its stronger sandbox. Approve that prompt: the non-administrator Windows sandbox cannot enforce the +workspace-only filesystem policy Command Center requires, so T3 Code will not fall back to it or run +the provider without isolation. 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 +missing. Expand the Codex provider in Settings and choose **Update or reinstall**; the action remains +available even when the installed version number is already current so it can repair missing native +files. 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