From 2df13735bd45fa4ead91c534a07452d888c4d623 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 00:09:18 +0300 Subject: [PATCH 1/7] Harden provider installation and sign-in --- .github/workflows/ci.yml | 3 + .../Layers/ProviderConnection.test.ts | 123 +++++++++- .../src/provider/Layers/ProviderConnection.ts | 200 +++++++++++++++- .../Layers/ProviderRuntimeManager.test.ts | 109 +++++++++ .../provider/Layers/ProviderRuntimeManager.ts | 220 ++++++++++++++++-- .../provider/Services/ProviderConnection.ts | 7 + apps/server/src/wsRpc.ts | 9 + .../ProviderConnectionDialog.browser.tsx | 134 ++++++++++- .../components/ProviderConnectionDialog.tsx | 92 +++++++- packages/contracts/src/server.test.ts | 21 ++ packages/contracts/src/server.ts | 5 + 11 files changed, 880 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79dd31d..95c6927a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,9 @@ jobs: - name: Test Effect Windows process spawn run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts + - name: Test Windows provider runtime discovery + run: bun run --cwd apps/server test src/provider/Layers/ProviderRuntimeManager.test.ts + - name: Test Windows private state initialization run: | bun run --cwd apps/server test src/serverPrivateDirectories.test.ts diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 580bd996..d07668b6 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -1,6 +1,7 @@ import type { ProviderKind, ServerProviderConnectionState, + ServerProviderInstallationState, ServerProviderRuntimeSource, ServerProviderStatus, } from "@synara/contracts"; @@ -33,6 +34,8 @@ import { expectedMethodForProvider, makeProviderConnectionLive, parseAntigravityOAuthAuthorizationUrl, + parseCodexDeviceAuthorization, + parseCodexOAuthAuthorizationUrl, parseGrokOAuthAuthorizationUrl, providerConnectionCommandArgs, } from "./ProviderConnection"; @@ -153,6 +156,7 @@ function makeConnectionTestLayer(input?: { readonly listModelsHanging?: boolean; readonly initiallyAuthenticated?: boolean; readonly requiresProviderAccount?: boolean | null; + readonly installationState?: ServerProviderInstallationState; readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; @@ -337,7 +341,7 @@ function makeConnectionTestLayer(input?: { previousReleaseAvailable: false, bundled: false, canInstall: false, - installationState: null, + installationState: input?.installationState ?? null, }), resolve: (provider, configured) => Effect.succeed({ @@ -417,6 +421,10 @@ describe("provider connection command allowlist", () => { it("uses Codex browser login with fixed argv", () => { expect(expectedMethodForProvider("codex")).toBe("codex_browser"); expect(providerConnectionCommandArgs("codex", "codex_browser")).toEqual(["login"]); + expect(providerConnectionCommandArgs("codex", "codex_device_code")).toEqual([ + "login", + "--device-auth", + ]); }); it("uses normal Claude account login by default and keeps explicit alternatives", () => { @@ -469,6 +477,49 @@ describe("provider connection command allowlist", () => { }); }); +describe("Codex authorization output parsing", () => { + const authorizationUrl = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + + it("accepts only an official PKCE browser authorization URL", () => { + expect(parseCodexOAuthAuthorizationUrl(`Open this URL:\n${authorizationUrl}\n`)).toBe( + authorizationUrl, + ); + expect( + parseCodexOAuthAuthorizationUrl( + authorizationUrl.replace("auth.openai.com", "auth.openai.com.example.com"), + ), + ).toBeNull(); + expect( + parseCodexOAuthAuthorizationUrl(authorizationUrl.replace("code_challenge", "ignored")), + ).toBeNull(); + expect( + parseCodexOAuthAuthorizationUrl( + authorizationUrl.replace( + "http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + "https%3A%2F%2Fexample.com%2Fcallback", + ), + ), + ).toBeNull(); + }); + + it("extracts the official device page and bounded one-time code", () => { + expect( + parseCodexDeviceAuthorization( + "1. Open this link\n\u001B[34mhttps://auth.openai.com/codex/device\u001B[0m\n2. Enter this one-time code\n\u001B[34mABCD-EFGH\u001B[0m\n", + ), + ).toEqual({ + authorizationUrl: "https://auth.openai.com/codex/device", + userCode: "ABCD-EFGH", + }); + expect( + parseCodexDeviceAuthorization( + "https://auth.openai.com.example.com/codex/device\nnot-a-device-code", + ), + ).toEqual({}); + }); +}); + describe("Grok OAuth authorization URL parsing", () => { const authorizationUrl = "https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge"; @@ -541,6 +592,76 @@ describe("Antigravity OAuth authorization URL parsing", () => { }); describe("ProviderConnectionLive", () => { + it("starts sign-in only after the exact requested installation succeeds", async () => { + const onSpawn = vi.fn(); + const installationState = { + operationId: "trusted-plan-1", + operation: "install", + status: "installed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:02.000Z", + message: "Codex is installed and verified.", + } satisfies ServerProviderInstallationState; + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "different-plan", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(onSpawn).not.toHaveBeenCalled(); + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "trusted-plan-1", + }); + const connected = fixture.waitForConnectionState((state) => state?.status === "connected"); + yield* Effect.promise(() => connected); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "codex", args: ["login"] }), + ); + }); + + it("publishes Codex's official device page and one-time code", async () => { + const fixture = makeConnectionTestLayer({ + provider: "codex", + hanging: true, + processStdout: + "1. Open this link\nhttps://auth.openai.com/codex/device\n2. Enter this one-time code\nABCD-EFGH\n", + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const codePublished = fixture.waitForConnectionState( + (state) => state?.userCode === "ABCD-EFGH", + ); + const started = yield* connection.start({ + provider: "codex", + method: "codex_device_code", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => codePublished); + expect(fixture.getConnectionState()).toMatchObject({ + authorizationUrl: "https://auth.openai.com/codex/device", + userCode: "ABCD-EFGH", + }); + yield* connection.cancel({ provider: "codex", operationId: operationId! }); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + it("runs managed Antigravity's browser-auth bootstrap and verifies models", async () => { const onSpawn = vi.fn(); const onAuthenticationKill = vi.fn(); diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index e1aac571..7dac17c6 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -49,6 +49,8 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import { parseAntigravityModelsAuthStatus, resolveProviderProbeCwd } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); +const INSTALLATION_HANDOFF_TIMEOUT = Duration.minutes(30); +const INSTALLATION_HANDOFF_POLL_INTERVAL = Duration.millis(250); const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60; const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS); const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500); @@ -89,10 +91,19 @@ const GOOGLE_OAUTH_AUTHORIZATION_ORIGIN = "https://accounts.google.com"; const GOOGLE_OAUTH_AUTHORIZATION_PATHS = new Set(["/o/oauth2/auth", "/o/oauth2/v2/auth"]); const ANTIGRAVITY_OAUTH_CALLBACK_ORIGIN = "https://antigravity.google"; const ANTIGRAVITY_OAUTH_CALLBACK_PATH = "/oauth-callback"; +const CODEX_OAUTH_AUTHORIZATION_ORIGINS = new Set([ + "https://auth.openai.com", + "https://chatgpt.com", +]); +const CODEX_DEVICE_PATH = "/codex/device"; const OAUTH_OUTPUT_BUFFER_MAX_CHARS = 16 * 1024; const ANTIGRAVITY_AUTH_PROMPT = "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task."; const authorizationCodeEncoder = new TextEncoder(); +const ANSI_SEQUENCE_PATTERN = new RegExp( + String.raw`\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))`, + "gu", +); function outputUrlCandidates(output: string): ReadonlyArray { return (output.match(/https:\/\/[^\s<>"']+/gu) ?? []).filter( @@ -104,6 +115,85 @@ function outputUrlCandidates(output: string): ReadonlyArray { ); } +function stripAnsi(output: string): string { + return output.replace(ANSI_SEQUENCE_PATTERN, ""); +} + +function isSafeCodexUrl(candidate: string): URL | null { + if (candidate.length > 8_192) return null; + try { + const url = new URL(candidate); + return url.protocol === "https:" && + CODEX_OAUTH_AUTHORIZATION_ORIGINS.has(url.origin) && + !url.hash && + !url.username && + !url.password + ? url + : null; + } catch { + return null; + } +} + +export function parseCodexOAuthAuthorizationUrl(output: string): string | null { + for (const candidate of outputUrlCandidates(stripAnsi(output))) { + const url = isSafeCodexUrl(candidate); + if ( + url && + url.pathname === "/oauth/authorize" && + url.searchParams.get("response_type") === "code" && + url.searchParams.get("client_id") && + url.searchParams.get("state") && + url.searchParams.get("code_challenge") && + url.searchParams.get("code_challenge_method") === "S256" + ) { + const redirectValue = url.searchParams.get("redirect_uri"); + if (!redirectValue) continue; + try { + const redirectUrl = new URL(redirectValue); + if ( + redirectUrl.protocol === "http:" && + (redirectUrl.hostname === "localhost" || redirectUrl.hostname === "127.0.0.1") && + redirectUrl.port && + redirectUrl.pathname === "/auth/callback" && + !redirectUrl.search && + !redirectUrl.hash && + !redirectUrl.username && + !redirectUrl.password + ) { + return url.toString(); + } + } catch { + // Ignore malformed or incomplete output while the CLI is still streaming. + } + } + } + return null; +} + +export function parseCodexDeviceAuthorization(output: string): { + readonly authorizationUrl?: string; + readonly userCode?: string; +} { + const cleaned = stripAnsi(output); + let authorizationUrl: string | undefined; + for (const candidate of outputUrlCandidates(cleaned)) { + const url = isSafeCodexUrl(candidate); + if (url?.pathname === CODEX_DEVICE_PATH && !url.search) { + authorizationUrl = url.toString(); + break; + } + } + const userCode = cleaned + .split(/\r?\n/gu) + .map((line) => line.trim()) + .find((line) => /^[A-Z0-9]{4,}(?:-[A-Z0-9]{2,})+$/u.test(line) && line.length <= 64); + return { + ...(authorizationUrl ? { authorizationUrl } : {}), + ...(userCode ? { userCode } : {}), + }; +} + export function parseGrokOAuthAuthorizationUrl(output: string): string | null { for (const candidate of outputUrlCandidates(output)) { if (candidate.length > 8_192) continue; @@ -230,6 +320,9 @@ export function providerConnectionCommandArgs( method: ServerProviderConnectionMethod, ): ReadonlyArray | null { if (provider === "codex" && method === "codex_browser") return ["login"]; + if (provider === "codex" && method === "codex_device_code") { + return ["login", "--device-auth"]; + } if (provider === "claudeAgent" && method === "claude_account") { return ["auth", "login"]; } @@ -394,7 +487,10 @@ export function makeProviderConnectionLive(options?: { ...runtimeEnv, CODEX_HOME: resolveBaseCodexHomePath(process.env, homePath), }, - waitingMessage: "Finish signing in to ChatGPT in the browser window.", + waitingMessage: + method === "codex_device_code" + ? "Open the secure OpenAI page and enter the one-time code shown here." + : "Finish signing in to ChatGPT in the browser window.", } satisfies ConnectionCommand; } @@ -766,6 +862,7 @@ export function makeProviderConnectionLive(options?: { readonly message: string; readonly finished?: boolean; readonly authorizationUrl?: string; + readonly userCode?: string; }): ServerProviderConnectionState => ({ operationId, method, @@ -774,11 +871,12 @@ export function makeProviderConnectionLive(options?: { finishedAt: input.finished ? new Date().toISOString() : null, message: input.message, ...(input.authorizationUrl ? { authorizationUrl: input.authorizationUrl } : {}), + ...(input.userCode ? { userCode: input.userCode } : {}), }); yield* publishState( provider, - state({ status: "starting", message: "Starting secure browser sign in." }), + state({ status: "starting", message: "Starting secure provider sign in." }), ); const operation = Effect.gen(function* () { @@ -788,27 +886,46 @@ export function makeProviderConnectionLive(options?: { ); let oauthOutputBuffer = ""; let publishedAuthorizationUrl: string | null = null; + let publishedUserCode: string | null = null; const oauthOutputObserver: ConnectionOutputObserver | undefined = - provider === "grok" || provider === "antigravity" + provider === "grok" || provider === "antigravity" || provider === "codex" ? { onOutputChunk: (chunk) => { - if (publishedAuthorizationUrl) return undefined; oauthOutputBuffer = `${oauthOutputBuffer}${Buffer.from(chunk).toString("utf8")}`.slice( -OAUTH_OUTPUT_BUFFER_MAX_CHARS, ); - const authorizationUrl = - provider === "grok" - ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer) - : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer); - if (!authorizationUrl) return undefined; - publishedAuthorizationUrl = authorizationUrl; + const codexDevice = + provider === "codex" && method === "codex_device_code" + ? parseCodexDeviceAuthorization(oauthOutputBuffer) + : null; + const authorizationUrl = codexDevice + ? (codexDevice.authorizationUrl ?? null) + : provider === "codex" + ? parseCodexOAuthAuthorizationUrl(oauthOutputBuffer) + : provider === "grok" + ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer) + : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer); + const userCode = codexDevice?.userCode ?? null; + const nextAuthorizationUrl = authorizationUrl ?? publishedAuthorizationUrl; + const nextUserCode = userCode ?? publishedUserCode; + if ( + nextAuthorizationUrl === publishedAuthorizationUrl && + nextUserCode === publishedUserCode + ) { + return undefined; + } + publishedAuthorizationUrl = nextAuthorizationUrl; + publishedUserCode = nextUserCode; return publishState( provider, state({ status: "waiting_for_browser", message: command.waitingMessage, - authorizationUrl, + ...(nextAuthorizationUrl + ? { authorizationUrl: nextAuthorizationUrl } + : {}), + ...(nextUserCode ? { userCode: nextUserCode } : {}), }), ).pipe(Effect.asVoid); }, @@ -989,6 +1106,60 @@ export function makeProviderConnectionLive(options?: { }, ); + const startAfterInstallation: ProviderConnectionShape["startAfterInstallation"] = Effect.fn( + "ProviderConnection.startAfterInstallation", + )(function* (input) { + if ( + providerConnectionCommandArgs(input.provider, input.method) === null || + (input.provider !== "codex" && expectedMethodForProvider(input.provider) !== input.method) + ) { + yield* Effect.logWarning("Ignoring an invalid provider installation handoff.", { + provider: input.provider, + method: input.method, + }); + return; + } + + const monitor = Effect.gen(function* () { + const initial = yield* providerRuntimeManager.getSnapshot(input.provider); + if (initial.installationState?.operationId !== input.installationOperationId) { + yield* Effect.logWarning("Ignoring a stale provider installation handoff.", { + provider: input.provider, + installationOperationId: input.installationOperationId, + }); + return; + } + while (true) { + const snapshot = yield* providerRuntimeManager.getSnapshot(input.provider); + const installation = snapshot.installationState; + if (installation?.operationId === input.installationOperationId) { + if (installation.status === "installed") { + const result = yield* Effect.result( + start({ provider: input.provider, method: input.method }), + ); + if (Result.isFailure(result)) { + const now = new Date().toISOString(); + yield* publishState(input.provider, { + operationId: `handoff-${input.installationOperationId}`, + method: input.method, + status: "failed", + startedAt: now, + finishedAt: now, + message: `Installation succeeded, but sign in could not start: ${result.failure.message}`, + }); + } + return; + } + if (installation.status === "failed" || installation.status === "cancelled") { + return; + } + } + yield* Effect.sleep(INSTALLATION_HANDOFF_POLL_INTERVAL); + } + }).pipe(Effect.timeoutOption(INSTALLATION_HANDOFF_TIMEOUT), Effect.asVoid); + yield* monitor.pipe(Effect.forkIn(operationScope)); + }); + const cancel: ProviderConnectionShape["cancel"] = Effect.fn("ProviderConnection.cancel")( function* (input) { const active = (yield* Ref.get(activeConnectionsRef)).get(input.provider); @@ -1075,7 +1246,12 @@ export function makeProviderConnectionLive(options?: { return { providers: yield* providerHealth.getStatuses }; }); - return { start, cancel, submitAuthorizationCode } satisfies ProviderConnectionShape; + return { + start, + cancel, + submitAuthorizationCode, + startAfterInstallation, + } satisfies ProviderConnectionShape; }), ); } diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index 412ce1e2..267f5931 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -12,7 +12,9 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes"; import { canActivateManagedRuntimeVersion, + findExecutableOnPath, ProviderRuntimeManagerLive, + windowsKnownProviderExecutableCandidates, } from "./ProviderRuntimeManager"; function sha256(filePath: string): string { @@ -33,6 +35,20 @@ function resolveAntigravity(baseDir: string, configuredExecutable?: string) { }).pipe(Effect.provide(layer), Effect.scoped); } +function getProviderSnapshot(baseDir: string, provider: "codex" | "antigravity") { + const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( + Layer.provide(NodeServices.layer), + ); + const layer = Layer.mergeAll( + configLayer, + ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)), + ).pipe(Layer.provide(NodeServices.layer)); + return Effect.gen(function* () { + const manager = yield* ProviderRuntimeManager; + return yield* manager.getSnapshot(provider); + }).pipe(Effect.provide(layer), Effect.scoped); +} + describe("ProviderRuntimeManager managed integrity", () => { it("allows install or repair at the current version but never downgrades", () => { expect( @@ -67,6 +83,87 @@ describe("ProviderRuntimeManager managed integrity", () => { } }); + it("loads the last terminal installation failure after restart", async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-")); + try { + const stateDir = path.join(baseDir, "userdata", "provider-installations"); + mkdirSync(stateDir, { recursive: true }); + const failure = { + operationId: "install-failed-1", + operation: "install", + status: "failed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:01.000Z", + message: "Installation failed while downloading the provider: connection reset.", + }; + writeFileSync(path.join(stateDir, "codex.json"), JSON.stringify(failure)); + + const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex")); + expect(snapshot.installationState).toEqual(failure); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("does not restore a stale successful installation after restart", async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-")); + try { + const stateDir = path.join(baseDir, "userdata", "provider-installations"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + path.join(stateDir, "codex.json"), + JSON.stringify({ + operationId: "install-succeeded-1", + operation: "install", + status: "installed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:01.000Z", + message: "Codex is installed and verified.", + }), + ); + + const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex")); + expect(snapshot.installationState).toBeNull(); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("recognizes the official standalone Codex install location on Windows", () => { + expect( + windowsKnownProviderExecutableCandidates({ + provider: "codex", + environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" }, + }), + ).toEqual(["C:\\Users\\Levin\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"]); + expect( + windowsKnownProviderExecutableCandidates({ + provider: "antigravity", + environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" }, + }), + ).toEqual([]); + }); + + it.runIf(process.platform === "win32")( + "finds a provider installed into a newly persisted Windows PATH entry", + async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-path-")); + try { + const executable = path.join(baseDir, "codex.exe"); + writeFileSync(executable, "test"); + expect( + await findExecutableOnPath({ + command: "codex", + pathValue: baseDir, + platform: "win32", + }), + ).toBe(executable); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it("revalidates the executable after restart and rejects later corruption", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-integrity-")); const previousPath = process.env.PATH; @@ -120,6 +217,18 @@ describe("ProviderRuntimeManager managed integrity", () => { expect(afterRestart.source).toBe("missing"); expect(afterRestart.executable).toBeNull(); expect(afterRestart.canRepair).toBe(true); + expect( + JSON.parse( + readFileSync( + path.join(baseDir, "userdata", "provider-installations", "antigravity.json"), + "utf8", + ), + ), + ).toMatchObject({ + operation: "repair", + status: "failed", + message: expect.stringContaining("damaged and must be repaired"), + }); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index a10e7058..da874054 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -2,17 +2,18 @@ import { createHash, randomUUID } from "node:crypto"; import { constants as FS_CONSTANTS, createReadStream } from "node:fs"; import FS from "node:fs/promises"; import Path from "node:path"; -import { delimiter as pathDelimiter } from "node:path"; import { PROVIDER_DISPLAY_NAMES, + ServerProviderInstallationState as ServerProviderInstallationStateSchema, type ProviderKind, ServerProviderInstallationError, type ServerProviderInstallationState, type ServerProviderRuntimeSource, } from "@synara/contracts"; import { compareSemverVersions } from "@synara/shared/providerVersions"; -import { Effect, Layer, PubSub, Stream } from "effect"; +import { mergePathEntries, readWindowsPersistentEnvironment } from "@synara/shared/shell"; +import { Effect, Layer, PubSub, Schema, Stream } from "effect"; import { ServerConfig } from "../../config"; import { writeFileStringAtomically } from "../../atomicWrite"; @@ -84,6 +85,17 @@ const PLAN_TTL_MS = 10 * 60 * 1000; const SMOKE_TIMEOUT_MS = 15_000; const SMOKE_OUTPUT_LIMIT = 64 * 1024; const MINIMUM_INSTALL_FREE_BYTES = 256 * 1024 * 1024; +const WINDOWS_ENVIRONMENT_CACHE_MS = 5_000; +const TERMINAL_INSTALLATION_STATUSES = new Set([ + "installed", + "succeeded", + "failed", + "cancelled", +]); +const RESTORABLE_INSTALLATION_STATUSES = new Set([ + "failed", + "cancelled", +]); type RuntimeOperation = ServerProviderInstallationState["operation"]; @@ -119,6 +131,39 @@ function currentRecordPath(stateDir: string, provider: ProviderKind): string { return Path.join(providerRoot(stateDir, provider), "current.json"); } +function installationStatePath(stateDir: string, provider: ProviderKind): string { + return Path.join(stateDir, "provider-installations", `${provider}.json`); +} + +const decodeInstallationState = Schema.decodeUnknownSync(ServerProviderInstallationStateSchema); + +async function readPersistedInstallationState( + stateDir: string, + provider: ProviderKind, +): Promise { + try { + const state = decodeInstallationState( + JSON.parse(await FS.readFile(installationStatePath(stateDir, provider), "utf8")), + ); + return RESTORABLE_INSTALLATION_STATUSES.has(state.status) ? state : null; + } catch { + return null; + } +} + +async function writePersistedInstallationState( + stateDir: string, + provider: ProviderKind, + state: ServerProviderInstallationState, +): Promise { + await Effect.runPromise( + writeFileStringAtomically({ + filePath: installationStatePath(stateDir, provider), + contents: `${JSON.stringify(state, null, 2)}\n`, + }), + ); +} + function releaseRoot(stateDir: string, provider: ProviderKind, releaseId: string): string { return Path.join(providerRoot(stateDir, provider), "releases", releaseId); } @@ -237,21 +282,23 @@ async function removeCurrentRecord(stateDir: string, provider: ProviderKind): Pr await FS.rm(currentRecordPath(stateDir, provider), { force: true }); } -function findExecutableOnPath(input: { +export function findExecutableOnPath(input: { readonly command: string; readonly pathValue: string; readonly platform?: NodeJS.Platform; }): Promise { const platform = input.platform ?? process.platform; + const pathApi = platform === "win32" ? Path.win32 : Path.posix; + const delimiter = platform === "win32" ? ";" : ":"; const extensions = platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((part) => part.toLowerCase()) : [""]; return (async () => { - for (const directory of input.pathValue.split(pathDelimiter).filter(Boolean)) { + for (const directory of input.pathValue.split(delimiter).filter(Boolean)) { for (const extension of extensions) { - const hasExtension = Path.extname(input.command).length > 0; - const candidate = Path.join( + const hasExtension = pathApi.extname(input.command).length > 0; + const candidate = pathApi.join( directory, hasExtension ? input.command : `${input.command}${extension}`, ); @@ -271,21 +318,45 @@ function findExecutableOnPath(input: { })(); } +export function windowsKnownProviderExecutableCandidates(input: { + readonly provider: ProviderKind; + readonly environment: NodeJS.ProcessEnv | Partial>; +}): ReadonlyArray { + if (input.provider !== "codex") return []; + const localAppData = Object.entries(input.environment) + .find(([name]) => name.toUpperCase() === "LOCALAPPDATA")?.[1] + ?.trim(); + return localAppData + ? [Path.win32.join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe")] + : []; +} + +async function firstExistingFile(candidates: ReadonlyArray): Promise { + for (const candidate of candidates) { + const stat = await FS.stat(candidate).catch(() => null); + if (stat?.isFile()) return FS.realpath(candidate).catch(() => candidate); + } + return null; +} + async function resolveConfiguredExecutable(input: { readonly command: string; readonly pathValue: string; + readonly platform?: NodeJS.Platform; }): Promise { + const platform = input.platform ?? process.platform; + const pathApi = platform === "win32" ? Path.win32 : Path.posix; const hasPathSeparator = - Path.isAbsolute(input.command) || - input.command.includes(Path.sep) || - (process.platform === "win32" && input.command.includes("/")); + pathApi.isAbsolute(input.command) || + input.command.includes(pathApi.sep) || + (platform === "win32" && input.command.includes("/")); if (!hasPathSeparator) { - return findExecutableOnPath(input); + return findExecutableOnPath({ ...input, platform }); } const stat = await FS.stat(input.command).catch(() => null); if (!stat?.isFile()) return null; - if (process.platform !== "win32") { + if (platform !== "win32") { try { await FS.access(input.command, FS_CONSTANTS.X_OK); } catch { @@ -330,16 +401,25 @@ export const ProviderRuntimeManagerLive = Layer.effect( const changes = yield* PubSub.unbounded>(); const records = new Map(); const installationStates = new Map(); + const installationStateWrites = new Map>(); const plans = new Map(); const active = new Map(); const verifiedManagedReleases = new Set(); const managedVerificationPromises = new Map>(); const basePath = process.env.PATH ?? ""; + let windowsEnvironmentCache: Partial> | null = null; + let windowsEnvironmentReadAt = 0; const managedDirectoriesAdded = new Set(); let lastAssignedPath = basePath; let disposed = false; for (const provider of PROVIDERS) { + const persistedInstallationState = yield* Effect.promise(() => + readPersistedInstallationState(config.stateDir, provider), + ); + if (persistedInstallationState) { + installationStates.set(provider, persistedInstallationState); + } const record = yield* Effect.promise(() => readCurrentRecord(config.stateDir, provider)); if (record) { records.set(provider, record); @@ -365,14 +445,42 @@ export const ProviderRuntimeManagerLive = Layer.effect( } } + const currentSystemEnvironment = async (): Promise>> => { + if (process.platform !== "win32") return process.env; + if ( + windowsEnvironmentCache && + Date.now() - windowsEnvironmentReadAt < WINDOWS_ENVIRONMENT_CACHE_MS + ) { + return windowsEnvironmentCache; + } + try { + windowsEnvironmentCache = readWindowsPersistentEnvironment(); + windowsEnvironmentReadAt = Date.now(); + } catch { + windowsEnvironmentCache = {}; + windowsEnvironmentReadAt = Date.now(); + } + return windowsEnvironmentCache; + }; + + const currentSystemPath = async (): Promise<{ + readonly pathValue: string; + readonly environment: Partial>; + }> => { + const environment = await currentSystemEnvironment(); + return { + pathValue: mergePathEntries(environment.PATH, basePath, process.platform) ?? basePath, + environment, + }; + }; + const refreshProcessPath = () => { const managedDirectories = Array.from(records.values()) .filter((record) => verifiedManagedReleases.has(`${record.provider}:${record.releaseId}`)) .map((record) => Path.dirname(record.executablePath)); for (const directory of managedDirectories) managedDirectoriesAdded.add(directory); - lastAssignedPath = [basePath, ...new Set(managedDirectories)] - .filter(Boolean) - .join(pathDelimiter); + const delimiter = process.platform === "win32" ? ";" : ":"; + lastAssignedPath = [basePath, ...new Set(managedDirectories)].filter(Boolean).join(delimiter); process.env.PATH = lastAssignedPath; }; refreshProcessPath(); @@ -412,7 +520,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( readonly totalBytes?: number | null; }, ) => { - installationStates.set(provider, { + const state: ServerProviderInstallationState = { operationId: input.operationId, operation: input.operation, status: input.status, @@ -429,7 +537,23 @@ export const ProviderRuntimeManagerLive = Layer.effect( input.totalBytes === null ? null : Math.max(0, Math.trunc(input.totalBytes)), } : {}), - }); + }; + installationStates.set(provider, state); + if (TERMINAL_INSTALLATION_STATUSES.has(state.status)) { + const previousWrite = installationStateWrites.get(provider) ?? Promise.resolve(); + const nextWrite = previousWrite + .catch(() => undefined) + .then(() => writePersistedInstallationState(config.stateDir, provider, state)) + .catch((cause) => + Effect.runPromise( + Effect.logWarning("Failed to persist provider installation diagnostics.", { + provider, + cause, + }), + ), + ); + installationStateWrites.set(provider, nextWrite); + } publish(); }; @@ -470,6 +594,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( const startOperation = (input: { readonly provider: ProviderKind; readonly operation: RuntimeOperation; + readonly operationId?: string; readonly run: (context: { readonly operationId: string; readonly startedAt: string; @@ -484,7 +609,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "A provider runtime operation is already running.", }); } - const operationId = randomUUID(); + const operationId = input.operationId ?? randomUUID(); const startedAt = new Date().toISOString(); const controller = new AbortController(); active.set(input.provider, { operationId, operation: input.operation, controller }); @@ -510,6 +635,30 @@ export const ProviderRuntimeManagerLive = Layer.effect( .run({ operationId, startedAt, controller }) .catch((cause) => { const cancelled = controller.signal.aborted; + const previousState = installationStates.get(input.provider); + const failedFromStatus = previousState?.status; + const failedAt = + input.operation === "rollback" + ? "restoring the previous provider" + : input.operation === "remove" + ? "removing the managed provider" + : failedFromStatus === "resolving" + ? "resolving the trusted release" + : failedFromStatus === "downloading" + ? "downloading the provider" + : failedFromStatus === "verifying" + ? "verifying the download" + : failedFromStatus === "smoke_testing" + ? "checking the installed provider" + : "installing the provider"; + const operationLabel = + input.operation === "repair" + ? "Repair" + : input.operation === "rollback" + ? "Rollback" + : input.operation === "remove" + ? "Removal" + : "Installation"; setInstallationState(input.provider, { operationId, operation: input.operation, @@ -518,7 +667,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( finishedAt: new Date().toISOString(), message: cancelled ? "Provider runtime operation was cancelled." - : errorMessage(cause), + : `${operationLabel} failed while ${failedAt}: ${errorMessage(cause)}`, + ...(previousState?.version ? { version: previousState.version } : {}), }); }) .finally(() => { @@ -782,6 +932,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "install", + operationId: input.planToken, run: ({ operationId, startedAt, controller }) => runInstall({ provider: input.provider, @@ -954,9 +1105,26 @@ export const ProviderRuntimeManagerLive = Layer.effect( const configured = configuredExecutable?.trim() ?? ""; const explicitCustom = configured.length > 0 && configured !== recipe.executableName; const record = records.get(provider) ?? null; - const systemExecutable = explicitCustom - ? await resolveConfiguredExecutable({ command: configured, pathValue: basePath }) - : await findExecutableOnPath({ command: recipe.executableName, pathValue: basePath }); + const systemSearch = await currentSystemPath(); + const pathExecutable = explicitCustom + ? await resolveConfiguredExecutable({ + command: configured, + pathValue: systemSearch.pathValue, + }) + : await findExecutableOnPath({ + command: recipe.executableName, + pathValue: systemSearch.pathValue, + }); + const knownExecutable = + !explicitCustom && process.platform === "win32" + ? await firstExistingFile( + windowsKnownProviderExecutableCandidates({ + provider, + environment: { ...process.env, ...systemSearch.environment }, + }), + ) + : null; + const systemExecutable = pathExecutable ?? knownExecutable; const source: ServerProviderRuntimeSource = explicitCustom ? "custom" : systemExecutable @@ -1017,18 +1185,22 @@ export const ProviderRuntimeManagerLive = Layer.effect( }); yield* Effect.addFinalizer(() => - Effect.sync(() => { + Effect.gen(function* () { disposed = true; for (const operation of active.values()) operation.controller.abort(); active.clear(); const currentPath = process.env.PATH ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; process.env.PATH = currentPath === lastAssignedPath ? basePath : currentPath - .split(pathDelimiter) + .split(delimiter) .filter((directory) => !managedDirectoriesAdded.has(directory)) - .join(pathDelimiter); + .join(delimiter); + yield* Effect.promise(() => + Promise.allSettled(installationStateWrites.values()).then(() => undefined), + ); }), ); diff --git a/apps/server/src/provider/Services/ProviderConnection.ts b/apps/server/src/provider/Services/ProviderConnection.ts index 3e43c1e2..7cc21a78 100644 --- a/apps/server/src/provider/Services/ProviderConnection.ts +++ b/apps/server/src/provider/Services/ProviderConnection.ts @@ -8,6 +8,8 @@ * @module ProviderConnection */ import type { + ProviderKind, + ServerProviderConnectionMethod, ServerProviderConnectionCancelInput, ServerProviderConnectionError, ServerProviderConnectionResult, @@ -27,6 +29,11 @@ export interface ProviderConnectionShape { readonly submitAuthorizationCode: ( input: ServerProviderConnectionSubmitAuthorizationCodeInput, ) => Effect.Effect; + readonly startAfterInstallation: (input: { + readonly provider: ProviderKind; + readonly method: ServerProviderConnectionMethod; + readonly installationOperationId: string; + }) => Effect.Effect; } export class ProviderConnection extends ServiceMap.Service< diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 7b2d4596..fc0e9262 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -1070,6 +1070,15 @@ export const makeWsRpcLayer = () => providerRuntimeManager.prepareInstall(input.provider), [WS_METHODS.serverInstallProvider]: (input) => providerRuntimeManager.install(input).pipe( + Effect.tap(() => + input.connectionMethod + ? providerConnection.startAfterInstallation({ + provider: input.provider, + method: input.connectionMethod, + installationOperationId: input.planToken, + }) + : Effect.void, + ), Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 14df1e47..11798b56 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -208,7 +208,7 @@ describe("ProviderConnectionDialog", () => { ).filter((button) => button.getAttribute("aria-label") !== "Close"); expect(popup, "Expected the provider dialog popup.").toBeTruthy(); - expect(buttons).toHaveLength(3); + expect(buttons).toHaveLength(4); const popupRect = popup!.getBoundingClientRect(); for (const button of buttons) { @@ -840,6 +840,135 @@ describe("ProviderConnectionDialog", () => { } }); + it("keeps a device-code recovery action when normal browser opening fails", async () => { + const authorizationUrl = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-active", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to ChatGPT in the browser window.", + authorizationUrl, + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockRejectedValue(new Error("browser unavailable")); + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: checkedAt, + message: "Sign in was cancelled.", + }, + } satisfies ServerProviderStatus; + const device = { + ...active, + connectionState: { + operationId: "connect-codex-device", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: active.connectionState.startedAt, + finishedAt: null, + message: "Enter the one-time code shown here.", + }, + } satisfies ServerProviderStatus; + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [device] }); + const restoreNativeApi = installNativeApi({ + openExternal, + cancelProviderConnection, + startProviderConnection, + }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Open browser again" }).click(); + await expect.element(page.getByText("browser unavailable")).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Open browser again" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Use device code instead" })) + .toBeVisible(); + await page.getByRole("button", { name: "Use device code instead" }).click(); + await vi.waitFor(() => { + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + operationId: "connect-codex-active", + }); + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_device_code", + }); + }); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("shows Codex's official device code without exposing provider credentials", async () => { + const authorizationUrl = "https://auth.openai.com/codex/device"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-device", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Enter the one-time code shown here.", + authorizationUrl, + userCode: "ABCD-EFGH", + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockResolvedValue(undefined); + const restoreNativeApi = installNativeApi({ openExternal }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect.element(page.getByText("ABCD-EFGH")).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Copy code" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Use device code instead" })) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("reopens the validated Google authorization page without terminal use", async () => { const authorizationUrl = "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; @@ -1148,11 +1277,12 @@ describe("ProviderConnectionDialog", () => { await expect.element(page.getByText("Ready to install version 1.1.5")).toBeVisible(); expect(installProvider).not.toHaveBeenCalled(); - await page.getByRole("button", { name: "Download and install" }).click(); + await page.getByRole("button", { name: "Download, install and sign in" }).click(); await vi.waitFor(() => { expect(installProvider).toHaveBeenCalledWith({ provider: "antigravity", planToken: "trusted-plan-1", + connectionMethod: "antigravity_browser", }); }); await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 711a8a6d..9ccaa188 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -7,6 +7,7 @@ import { compareSemverVersions } from "@synara/shared/providerVersions"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, @@ -62,6 +63,9 @@ export function ProviderConnectionDialog() { const activeAuthorizationCodeSubmissionRef = useRef<{ readonly operationId: string; } | null>(null); + const openedAuthorizationUrlsRef = useRef(new Set()); + const { copyToClipboard: copyDeviceCode, isCopied: isDeviceCodeCopied } = + useCopyToClipboard(); const activeConnectionOperationIdRef = useRef(null); const status = provider ? configQuery.data?.providers.find((entry) => entry.provider === provider) @@ -95,7 +99,14 @@ export function ProviderConnectionDialog() { ["starting", "waiting_for_browser", "verifying"].includes(status.connectionState.status) ? status.connectionState : null; - activeConnectionOperationIdRef.current = activeConnection?.operationId ?? null; + const activeConnectionOperationId = activeConnection?.operationId ?? null; + const activeConnectionMethod = activeConnection?.method ?? null; + const activeConnectionAuthorizationUrl = activeConnection?.authorizationUrl ?? null; + const activeCodexDeviceCode = + provider === "codex" && activeConnectionMethod === "codex_device_code" + ? (activeConnection?.userCode ?? null) + : null; + activeConnectionOperationIdRef.current = activeConnectionOperationId; useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); @@ -156,6 +167,32 @@ export function ProviderConnectionDialog() { if (!isOpen) setAuthorizationCode(""); }, [isOpen]); + useEffect(() => { + if ( + !isOpen || + activeConnectionMethod !== "codex_device_code" || + !activeConnectionOperationId || + !activeConnectionAuthorizationUrl + ) { + return; + } + const key = `${activeConnectionOperationId}:${activeConnectionAuthorizationUrl}`; + if (openedAuthorizationUrlsRef.current.has(key)) return; + openedAuthorizationUrlsRef.current.add(key); + void ensureNativeApi() + .shell.openExternal(activeConnectionAuthorizationUrl) + .catch(() => + setActionError( + "Scient could not open the browser automatically. Use Open browser again below.", + ), + ); + }, [ + isOpen, + activeConnectionOperationId, + activeConnectionMethod, + activeConnectionAuthorizationUrl, + ]); + if (!provider || !presentation || !Icon) return null; const startsProviderSignIn = status?.available === true && providerConnectionMethod(provider) !== null; @@ -242,6 +279,20 @@ export function ProviderConnectionDialog() { }); }; + const switchToCodexDeviceCode = () => { + const operation = status?.connectionState; + if (provider !== "codex" || !operation) return Promise.resolve(); + invalidateAuthorizationCodeSubmission(operation.operationId); + return runAction(async () => { + const cancelled = await ensureNativeApi().server.cancelProviderConnection({ + provider, + operationId: operation.operationId, + }); + applyProviderStatusesToCache(queryClient, cancelled.providers); + await performStartSignIn("codex_device_code"); + }); + }; + const reopenAuthorization = () => { const authorizationUrl = activeConnection?.authorizationUrl; if (!authorizationUrl) return Promise.resolve(); @@ -303,9 +354,11 @@ export function ProviderConnectionDialog() { setInstallPlan(plan); return; } + const connectionMethod = managedUpdateFlow ? null : providerConnectionMethod(provider); const result = await ensureNativeApi().server.installProvider({ provider, planToken: installPlan.planToken, + ...(connectionMethod ? { connectionMethod } : {}), }); if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); @@ -394,8 +447,7 @@ export function ProviderConnectionDialog() { role="group" aria-label="Sign-in progress actions" > - {(provider === "grok" || provider === "antigravity") && - activeConnection.authorizationUrl ? ( + {activeConnection.authorizationUrl ? ( + ) : null} {presentation.canRestart ? ( + + + ) : null} + {provider === "claudeAgent" && presentation.primaryAction === "sign_in" ? (

Other sign-in methods

@@ -550,7 +632,9 @@ export function ProviderConnectionDialog() { {installPlan && managedUpdateFlow ? "Download and update" : installPlan - ? "Download and install" + ? providerConnectionMethod(provider) + ? "Download, install and sign in" + : "Download and install" : presentation.primaryLabel} ) : null} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 41de62b1..b8d7d222 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ServerProviderConnectionCancelInput, ServerProviderConnectionStartInput, ServerProviderConnectionSubmitAuthorizationCodeInput, + ServerProviderInstallInput, ServerProviderStatus, ServerVoiceTranscriptionInput, ServerVoiceTranscriptionResult, @@ -17,6 +18,10 @@ describe("provider connection contracts", () => { provider: "codex", method: "codex_browser", }); + expect(decode({ provider: "codex", method: "codex_device_code" })).toEqual({ + provider: "codex", + method: "codex_device_code", + }); expect(decode({ provider: "codex", method: "codex_browser", mode: "reauthenticate" })).toEqual({ provider: "codex", method: "codex_browser", @@ -40,6 +45,20 @@ describe("provider connection contracts", () => { }); }); + it("decodes an explicit install-to-sign-in handoff", () => { + expect( + Schema.decodeUnknownSync(ServerProviderInstallInput)({ + provider: "codex", + planToken: "plan-1", + connectionMethod: "codex_browser", + }), + ).toEqual({ + provider: "codex", + planToken: "plan-1", + connectionMethod: "codex_browser", + }); + }); + it("rejects unknown connection methods and blank operation ids", () => { expect(() => Schema.decodeUnknownSync(ServerProviderConnectionStartInput)({ @@ -119,11 +138,13 @@ describe("provider connection contracts", () => { message: "Finish signing in in the browser.", authorizationUrl: "https://auth.x.ai/oauth2/authorize?response_type=code&state=transient-test-state", + userCode: "ABCD-EFGH", }, }); expect(decoded.connectionState?.status).toBe("waiting_for_browser"); expect(decoded.connectionState?.authorizationUrl).toContain("https://auth.x.ai/"); + expect(decoded.connectionState?.userCode).toBe("ABCD-EFGH"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("token"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("output"); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 79344427..4641eb7b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -94,6 +94,7 @@ export type ServerProviderRuntimeState = typeof ServerProviderRuntimeState.Type; export const ServerProviderConnectionMethod = Schema.Literals([ "codex_browser", + "codex_device_code", "claude_account", "claude_sso", "claude_console", @@ -122,6 +123,9 @@ export const ServerProviderConnectionState = Schema.Struct({ finishedAt: Schema.NullOr(IsoDateTime), message: TrimmedNonEmptyString, authorizationUrl: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(8_192))), + userCode: Schema.optionalKey( + TrimmedNonEmptyString.check(Schema.isMaxLength(64), Schema.isPattern(/^[A-Za-z0-9-]+$/)), + ), }); export type ServerProviderConnectionState = typeof ServerProviderConnectionState.Type; @@ -549,6 +553,7 @@ export type ServerProviderUpdateResult = typeof ServerProviderUpdateResult.Type; export const ServerProviderInstallInput = Schema.Struct({ provider: ProviderKind, planToken: TrimmedNonEmptyString, + connectionMethod: Schema.optional(ServerProviderConnectionMethod), }); export type ServerProviderInstallInput = typeof ServerProviderInstallInput.Type; From c94417c386d808d103ed3e4601fc93e7df1dc4f4 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 00:25:00 +0300 Subject: [PATCH 2/7] Fix Windows provider runtime test fixtures --- .../Layers/ProviderRuntimeManager.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index 267f5931..d6acbf46 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -1,5 +1,13 @@ import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -157,7 +165,7 @@ describe("ProviderRuntimeManager managed integrity", () => { pathValue: baseDir, platform: "win32", }), - ).toBe(executable); + ).toBe(realpathSync(executable)); } finally { rmSync(baseDir, { recursive: true, force: true }); } @@ -177,9 +185,18 @@ describe("ProviderRuntimeManager managed integrity", () => { "releases", releaseId, ); - const executablePath = path.join(releaseDir, "bin", "agy"); + const executableRelativePath = path.join( + "bin", + process.platform === "win32" ? "agy.cmd" : "agy", + ); + const executablePath = path.join(releaseDir, executableRelativePath); mkdirSync(path.dirname(executablePath), { recursive: true }); - writeFileSync(executablePath, "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n"); + writeFileSync( + executablePath, + process.platform === "win32" + ? "@echo off\r\necho Antigravity CLI 1.1.4\r\n" + : "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n", + ); chmodSync(executablePath, 0o700); const record: ProviderRuntimeCurrentRecord = { version: 1, @@ -187,7 +204,7 @@ describe("ProviderRuntimeManager managed integrity", () => { releaseId, previousReleaseId: null, runtimeVersion: "1.1.4", - executableRelativePath: path.join("bin", "agy"), + executableRelativePath, executablePath, smokeArgs: ["--version"], digestAlgorithm: "sha256", @@ -211,7 +228,12 @@ describe("ProviderRuntimeManager managed integrity", () => { expect(first.source).toBe("managed"); expect(first.executable).toBe(executablePath); - writeFileSync(executablePath, "#!/bin/sh\necho 'tampered'\n"); + writeFileSync( + executablePath, + process.platform === "win32" + ? "@echo off\r\necho tampered\r\n" + : "#!/bin/sh\necho 'tampered'\n", + ); chmodSync(executablePath, 0o700); const afterRestart = await Effect.runPromise(resolveAntigravity(baseDir)); expect(afterRestart.source).toBe("missing"); From 8f1c6e43926b6fd91cbfe73cb45076d58a9ad921 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 00:30:53 +0300 Subject: [PATCH 3/7] Make Windows PATH test path-stable --- .../Layers/ProviderRuntimeManager.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index d6acbf46..e00c0488 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -1,13 +1,5 @@ import { createHash } from "node:crypto"; -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -159,13 +151,13 @@ describe("ProviderRuntimeManager managed integrity", () => { try { const executable = path.join(baseDir, "codex.exe"); writeFileSync(executable, "test"); - expect( - await findExecutableOnPath({ - command: "codex", - pathValue: baseDir, - platform: "win32", - }), - ).toBe(realpathSync(executable)); + const discovered = await findExecutableOnPath({ + command: "codex", + pathValue: baseDir, + platform: "win32", + }); + expect(discovered).not.toBeNull(); + expect(readFileSync(discovered!, "utf8")).toBe("test"); } finally { rmSync(baseDir, { recursive: true, force: true }); } From dc5d292cafb98ec66f9a7ca1dfe0e11befaf1fe2 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:11:57 +0300 Subject: [PATCH 4/7] Close provider onboarding reliability gaps --- .../Layers/ProviderConnection.test.ts | 84 ++++++++++++- .../Layers/ProviderRuntimeManager.test.ts | 100 +++++++++++++++- .../provider/Layers/ProviderRuntimeManager.ts | 110 +++++++++++++----- .../ProviderConnectionDialog.browser.tsx | 47 ++++++++ .../providerConnectionPresentation.test.ts | 36 ++++++ .../src/lib/providerConnectionPresentation.ts | 14 +++ packages/shared/src/shell.test.ts | 21 ++++ packages/shared/src/shell.ts | 48 ++++++-- 8 files changed, 417 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index d07668b6..857a4e66 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -156,7 +156,9 @@ function makeConnectionTestLayer(input?: { readonly listModelsHanging?: boolean; readonly initiallyAuthenticated?: boolean; readonly requiresProviderAccount?: boolean | null; - readonly installationState?: ServerProviderInstallationState; + readonly installationState?: + | ServerProviderInstallationState + | (() => ServerProviderInstallationState | null); readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; @@ -341,7 +343,10 @@ function makeConnectionTestLayer(input?: { previousReleaseAvailable: false, bundled: false, canInstall: false, - installationState: input?.installationState ?? null, + installationState: + typeof input?.installationState === "function" + ? input.installationState() + : (input?.installationState ?? null), }), resolve: (provider, configured) => Effect.succeed({ @@ -592,6 +597,81 @@ describe("Antigravity OAuth authorization URL parsing", () => { }); describe("ProviderConnectionLive", () => { + it("waits for the exact installation operation before starting sign-in", async () => { + const onSpawn = vi.fn(); + let installationState: ServerProviderInstallationState = { + operationId: "trusted-plan-transition", + operation: "install", + status: "downloading", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: null, + message: "Downloading Codex.", + }; + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState: () => installationState, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "trusted-plan-transition", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(onSpawn).not.toHaveBeenCalled(); + installationState = { + ...installationState, + status: "installed", + finishedAt: "2026-07-23T10:00:02.000Z", + message: "Codex is installed and verified.", + }; + const connected = fixture.waitForConnectionState((state) => state?.status === "connected"); + yield* Effect.promise(() => connected); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "codex", args: ["login"] }), + ); + }); + + it.each(["failed", "cancelled"] as const)( + "does not start sign-in after the exact installation operation is %s", + async (status) => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState: { + operationId: `trusted-plan-${status}`, + operation: "install", + status, + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:02.000Z", + message: `Installation ${status}.`, + }, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: `trusted-plan-${status}`, + }); + yield* Effect.sleep(Duration.millis(40)); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).not.toHaveBeenCalled(); + }, + ); + it("starts sign-in only after the exact requested installation succeeds", async () => { const onSpawn = vi.fn(); const installationState = { diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index e00c0488..a1566158 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -5,14 +5,27 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, Layer } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const shellMocks = vi.hoisted(() => ({ + readWindowsPersistentEnvironmentAsync: vi.fn( + async (): Promise>> => ({ ...process.env }), + ), +})); + +vi.mock("@synara/shared/shell", async (importOriginal) => ({ + ...(await importOriginal()), + readWindowsPersistentEnvironmentAsync: shellMocks.readWindowsPersistentEnvironmentAsync, +})); import { ServerConfig } from "../../config"; import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes"; import { + cancelAndAwaitProviderRuntimeOperations, canActivateManagedRuntimeVersion, findExecutableOnPath, + makeProviderRuntimeOperationGate, ProviderRuntimeManagerLive, windowsKnownProviderExecutableCandidates, } from "./ProviderRuntimeManager"; @@ -50,6 +63,37 @@ function getProviderSnapshot(baseDir: string, provider: "codex" | "antigravity") } describe("ProviderRuntimeManager managed integrity", () => { + it("makes cancellation and activation mutually exclusive", () => { + const cancelled = makeProviderRuntimeOperationGate(); + expect(cancelled.cancel()).toBe(true); + expect(cancelled.signal.aborted).toBe(true); + expect(cancelled.beginCommit()).toBe(false); + + const committing = makeProviderRuntimeOperationGate(); + expect(committing.beginCommit()).toBe(true); + expect(committing.cancel()).toBe(false); + expect(committing.signal.aborted).toBe(false); + }); + + it("waits for active runtime operations to finish during shutdown", async () => { + const gate = makeProviderRuntimeOperationGate(); + let finishOperation: (() => void) | undefined; + let operationFinished = false; + const completion = new Promise((resolve) => { + finishOperation = () => { + operationFinished = true; + resolve(); + }; + }); + + const shutdown = cancelAndAwaitProviderRuntimeOperations([{ gate, completion }]); + expect(gate.signal.aborted).toBe(true); + expect(operationFinished).toBe(false); + finishOperation?.(); + await shutdown; + expect(operationFinished).toBe(true); + }); + it("allows install or repair at the current version but never downgrades", () => { expect( canActivateManagedRuntimeVersion({ currentVersion: null, candidateVersion: "1.1.5" }), @@ -164,6 +208,60 @@ describe("ProviderRuntimeManager managed integrity", () => { }, ); + it.runIf(process.platform === "win32")( + "refreshes the manager's persisted Windows PATH after an external provider install", + async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-manager-path-")); + const previousPath = process.env.PATH; + const emptyPath = path.join(baseDir, "empty"); + let persistedPath = emptyPath; + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + shellMocks.readWindowsPersistentEnvironmentAsync.mockClear(); + shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation(async () => ({ + PATH: persistedPath, + })); + try { + mkdirSync(emptyPath); + writeFileSync(path.join(baseDir, "codex.exe"), "test"); + process.env.PATH = emptyPath; + const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( + Layer.provide(NodeServices.layer), + ); + const runtimeLayer = ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)); + const layer = Layer.mergeAll(configLayer, runtimeLayer).pipe( + Layer.provide(NodeServices.layer), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const manager = yield* ProviderRuntimeManager; + const beforeInstall = yield* manager.resolve("codex"); + persistedPath = baseDir; + now += 5_001; + const afterInstall = yield* manager.resolve("codex"); + return { beforeInstall, afterInstall }; + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + expect(result.beforeInstall.source).toBe("missing"); + expect(result.afterInstall).toMatchObject({ + source: "system", + executable: path.join(baseDir, "codex.exe"), + }); + expect(shellMocks.readWindowsPersistentEnvironmentAsync).toHaveBeenCalledTimes(2); + } finally { + nowSpy.mockRestore(); + shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation( + async (): Promise>> => ({ ...process.env }), + ); + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it("revalidates the executable after restart and rejects later corruption", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-integrity-")); const previousPath = process.env.PATH; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index da874054..8f5fb198 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -12,7 +12,7 @@ import { type ServerProviderRuntimeSource, } from "@synara/contracts"; import { compareSemverVersions } from "@synara/shared/providerVersions"; -import { mergePathEntries, readWindowsPersistentEnvironment } from "@synara/shared/shell"; +import { mergePathEntries, readWindowsPersistentEnvironmentAsync } from "@synara/shared/shell"; import { Effect, Layer, PubSub, Schema, Stream } from "effect"; import { ServerConfig } from "../../config"; @@ -108,7 +108,43 @@ interface PreparedInstall { interface ActiveOperation { readonly operationId: string; readonly operation: RuntimeOperation; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; + completion: Promise; +} + +export interface ProviderRuntimeOperationGate { + readonly signal: AbortSignal; + readonly cancel: () => boolean; + readonly beginCommit: () => boolean; +} + +export function makeProviderRuntimeOperationGate(): ProviderRuntimeOperationGate { + const controller = new AbortController(); + let phase: "running" | "cancelled" | "committing" = "running"; + return { + signal: controller.signal, + cancel: () => { + if (phase !== "running") return false; + phase = "cancelled"; + controller.abort(); + return true; + }, + beginCommit: () => { + if (phase !== "running" || controller.signal.aborted) return false; + phase = "committing"; + return true; + }, + }; +} + +export async function cancelAndAwaitProviderRuntimeOperations( + operations: ReadonlyArray<{ + readonly gate: ProviderRuntimeOperationGate; + readonly completion: Promise; + }>, +): Promise { + for (const operation of operations) operation.gate.cancel(); + await Promise.allSettled(operations.map((operation) => operation.completion)); } function installationError(input: { @@ -433,9 +469,10 @@ export const ProviderRuntimeManagerLive = Layer.effect( // A prior remove only deactivates the runtime immediately so an already-running // provider process is never invalidated. The next app start is the safe GC point. yield* Effect.promise(() => - FS.rm(providerRoot(config.stateDir, provider), { recursive: true, force: true }).catch( - () => undefined, - ), + FS.rm(providerRoot(config.stateDir, provider), { + recursive: true, + force: true, + }).catch(() => undefined), ); } else { yield* Effect.logWarning( @@ -454,7 +491,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( return windowsEnvironmentCache; } try { - windowsEnvironmentCache = readWindowsPersistentEnvironment(); + windowsEnvironmentCache = await readWindowsPersistentEnvironmentAsync(); windowsEnvironmentReadAt = Date.now(); } catch { windowsEnvironmentCache = {}; @@ -598,7 +635,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( readonly run: (context: { readonly operationId: string; readonly startedAt: string; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; }) => Promise; }): Effect.Effect => Effect.gen(function* () { @@ -611,8 +648,14 @@ export const ProviderRuntimeManagerLive = Layer.effect( } const operationId = input.operationId ?? randomUUID(); const startedAt = new Date().toISOString(); - const controller = new AbortController(); - active.set(input.provider, { operationId, operation: input.operation, controller }); + const gate = makeProviderRuntimeOperationGate(); + const activeOperation: ActiveOperation = { + operationId, + operation: input.operation, + gate, + completion: Promise.resolve(), + }; + active.set(input.provider, activeOperation); setInstallationState(input.provider, { operationId, operation: input.operation, @@ -631,10 +674,10 @@ export const ProviderRuntimeManagerLive = Layer.effect( : "Removing the Scient-managed provider runtime.", }); - void input - .run({ operationId, startedAt, controller }) + const completion = Promise.resolve() + .then(() => input.run({ operationId, startedAt, gate })) .catch((cause) => { - const cancelled = controller.signal.aborted; + const cancelled = gate.signal.aborted; const previousState = installationStates.get(input.provider); const failedFromStatus = previousState?.status; const failedAt = @@ -675,6 +718,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( if (active.get(input.provider)?.operationId === operationId) active.delete(input.provider); }); + activeOperation.completion = completion; }); const runInstall = async (input: { @@ -683,9 +727,9 @@ export const ProviderRuntimeManagerLive = Layer.effect( readonly operationId: string; readonly operation: "install" | "repair"; readonly startedAt: string; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; }): Promise => { - const { provider, artifact, operationId, operation, startedAt, controller } = input; + const { provider, artifact, operationId, operation, startedAt, gate } = input; const root = providerRoot(config.stateDir, provider); const downloads = Path.join(root, "downloads"); await FS.mkdir(downloads, { recursive: true }); @@ -722,7 +766,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( url: artifact.url, destination: archivePath, allowedHosts: artifact.allowedHosts, - signal: controller.signal, + signal: gate.signal, ...(artifact.size ? { expectedSize: artifact.size } : {}), onProgress: (bytesDownloaded, totalBytes) => setInstallationState(provider, { @@ -749,8 +793,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( algorithm: artifact.digestAlgorithm, expectedDigest: artifact.digest, }); - if (controller.signal.aborted) - throw new DOMException("Installation cancelled.", "AbortError"); + if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError"); setInstallationState(provider, { operationId, @@ -765,7 +808,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( destination: stagedRelease, format: artifact.archiveFormat, executablePath: artifact.executablePath, - signal: controller.signal, + signal: gate.signal, }); const recipe = getProviderRuntimeRecipe(provider); const managedExecutableRelativePath = Path.join( @@ -793,7 +836,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( await smokeTestExecutable({ executable, args: artifact.smokeArgs, - signal: controller.signal, + signal: gate.signal, }); const current = records.get(provider) ?? null; @@ -834,10 +877,12 @@ export const ProviderRuntimeManagerLive = Layer.effect( catalogRevision: artifact.catalogRevision, installedAt: new Date().toISOString(), }; + if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError"); await writeFileStringAtomically({ filePath: releaseRecordPath(config.stateDir, provider, releaseId), contents: `${JSON.stringify({ ...record, previousReleaseId: null }, null, 2)}\n`, }).pipe(Effect.runPromise); + if (!gate.beginCommit()) throw new DOMException("Installation cancelled.", "AbortError"); await writeCurrentRecord(config.stateDir, provider, record); records.set(provider, record); verifiedManagedReleases.add(`${provider}:${record.releaseId}`); @@ -933,14 +978,14 @@ export const ProviderRuntimeManagerLive = Layer.effect( provider: input.provider, operation: "install", operationId: input.planToken, - run: ({ operationId, startedAt, controller }) => + run: ({ operationId, startedAt, gate }) => runInstall({ provider: input.provider, artifact: plan.artifact, operationId, operation: "install", startedAt, - controller, + gate, }), }); }); @@ -955,7 +1000,13 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "This provider runtime operation is no longer running.", }); } - operation.controller.abort(); + if (!operation.gate.cancel()) { + return yield* installationError({ + provider: input.provider, + reason: "operation_not_found", + message: "This provider runtime operation is already finishing.", + }); + } }); const repair: ProviderRuntimeManagerShape["repair"] = (input) => @@ -999,14 +1050,14 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "repair", - run: ({ operationId, startedAt, controller }) => + run: ({ operationId, startedAt, gate }) => runInstall({ provider: input.provider, artifact, operationId, operation: "repair", startedAt, - controller, + gate, }), }); }); @@ -1024,7 +1075,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "rollback", - run: async ({ operationId, startedAt, controller }) => { + run: async ({ operationId, startedAt, gate }) => { const previousMetadata = await readReleaseRecord( config.stateDir, input.provider, @@ -1039,7 +1090,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( await smokeTestExecutable({ executable: previousExecutable, args: previousMetadata.smokeArgs, - signal: controller.signal, + signal: gate.signal, }); const previousRecord: ProviderRuntimeCurrentRecord = { ...previousMetadata, @@ -1048,6 +1099,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( executableDigest: await hashExecutable(previousExecutable), installedAt: new Date().toISOString(), }; + if (!gate.beginCommit()) throw new DOMException("Rollback cancelled.", "AbortError"); await writeCurrentRecord(config.stateDir, input.provider, previousRecord); records.set(input.provider, previousRecord); verifiedManagedReleases.add(`${input.provider}:${previousRecord.releaseId}`); @@ -1076,7 +1128,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "remove", - run: async ({ operationId, startedAt }) => { + run: async ({ operationId, startedAt, gate }) => { + if (!gate.beginCommit()) throw new DOMException("Removal cancelled.", "AbortError"); await removeCurrentRecord(config.stateDir, input.provider); records.delete(input.provider); for (const key of verifiedManagedReleases) { @@ -1187,7 +1240,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* Effect.addFinalizer(() => Effect.gen(function* () { disposed = true; - for (const operation of active.values()) operation.controller.abort(); + const activeOperations = [...active.values()]; + yield* Effect.promise(() => cancelAndAwaitProviderRuntimeOperations(activeOperations)); active.clear(); const currentPath = process.env.PATH ?? ""; const delimiter = process.platform === "win32" ? ";" : ":"; diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 11798b56..faf84069 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -1382,6 +1382,53 @@ describe("ProviderConnectionDialog", () => { } }); + it("shows a persisted installation failure after restart and keeps retry available", async () => { + const failureMessage = + "Installation failed while downloading the provider: the connection was reset."; + const antigravity = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + installationState: { + operationId: "install-antigravity-failed", + operation: "install", + status: "failed", + startedAt: checkedAt, + finishedAt: "2026-07-19T12:01:00.000Z", + message: failureMessage, + }, + } satisfies ServerProviderStatus; + const queryClient = createQueryClient(antigravity); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByText(failureMessage)).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Try installation again" })) + .toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); + it("keeps managed installation available after a complete provider refresh", async () => { const antigravity = { provider: "antigravity", diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index a1091b8a..6df5aa61 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -152,6 +152,42 @@ describe("provider connection presentation", () => { expect(presentation.description).toContain("no Homebrew"); }); + it.each(["failed", "cancelled"] as const)( + "preserves a managed installation %s diagnosis after restart", + (status) => { + const message = + status === "failed" + ? "Installation failed while downloading the provider." + : "Provider runtime operation was cancelled."; + const presentation = describeProviderConnection("codex", { + ...BASE_STATUS, + available: false, + authStatus: "unknown", + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + installationState: { + operationId: `install-codex-${status}`, + operation: "install", + status, + startedAt: "2026-07-19T10:00:00.000Z", + finishedAt: "2026-07-19T10:01:00.000Z", + message, + }, + }); + + expect(presentation.description).toBe(message); + expect(presentation.primaryAction).toBe("install"); + expect(presentation.primaryLabel).toBe("Try installation again"); + }, + ); + it("offers browser login without asking for credentials", () => { const presentation = describeProviderConnection("codex", BASE_STATUS); expect(presentation.primaryAction).toBe("sign_in"); diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index 6bdc3dc9..bbe0e8f7 100644 --- a/apps/web/src/lib/providerConnectionPresentation.ts +++ b/apps/web/src/lib/providerConnectionPresentation.ts @@ -302,6 +302,20 @@ export function describeProviderConnection( } if (!status.available) { + if ( + (installation?.status === "failed" || installation?.status === "cancelled") && + status.runtime?.source === "missing" && + status.runtime.canInstall + ) { + return { + title, + description: installation.message, + primaryAction: "install", + primaryLabel: "Try installation again", + busy: false, + canCancel: false, + }; + } if (status.installationState?.status === "installed" && providerConnectionMethod(provider)) { return { title, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index de4b1dd2..d32b21fb 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -9,6 +9,7 @@ import { readPathFromLaunchctl, readPathFromLoginShell, readWindowsPersistentEnvironment, + readWindowsPersistentEnvironmentAsync, resolveLoginShell, } from "./shell"; @@ -262,3 +263,23 @@ describe("readWindowsPersistentEnvironment", () => { expect(readWindowsPersistentEnvironment(execFile)).toEqual({}); }); }); + +describe("readWindowsPersistentEnvironmentAsync", () => { + it("reads and merges the registry dump without a synchronous child process", async () => { + const run = vi.fn(async () => ({ + stdout: JSON.stringify({ + machine: { Path: "C:\\Windows\\system32" }, + user: { Path: "C:\\Users\\ramar\\.local\\bin" }, + }), + })); + + await expect(readWindowsPersistentEnvironmentAsync(run)).resolves.toMatchObject({ + PATH: "C:\\Windows\\system32;C:\\Users\\ramar\\.local\\bin", + }); + expect(run).toHaveBeenCalledWith( + expect.stringMatching(/powershell\.exe$/iu), + ["-NoProfile", "-NonInteractive", "-Command", expect.any(String)], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 92548323..5372ddd4 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,7 +3,8 @@ // Exports: shell candidate resolution plus PATH/environment capture utilities. import * as OS from "node:os"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; const PATH_CAPTURE_START = "__SYNARA_PATH_START__"; const PATH_CAPTURE_END = "__SYNARA_PATH_END__"; @@ -15,6 +16,14 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +type ExecFileAsyncLike = ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number; windowsHide: true }, +) => Promise<{ stdout: string }>; + +const execFileAsync = promisify(execFile) as ExecFileAsyncLike; + function trimNonEmpty(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -260,6 +269,20 @@ export type WindowsEnvironmentReader = ( execFile?: ExecFileSyncLike, ) => Partial>; +function parseWindowsPersistentEnvironment(output: string): Partial> { + let parsed: { + machine?: Partial>; + user?: Partial>; + }; + try { + parsed = JSON.parse(output.trim()); + } catch { + return {}; + } + + return mergeWindowsScopes(parsed.machine ?? {}, parsed.user ?? {}); +} + // NOTE: keep this on Windows PowerShell 5.1 (`powershell.exe`), not `pwsh`. WinPS 5.1's // `ConvertTo-Json` escapes non-ASCII as `\uXXXX`, so the stdout stays pure ASCII and // survives the OEM-codepage console encoding. `pwsh` emits raw UTF-8 and would corrupt @@ -288,15 +311,16 @@ export const readWindowsPersistentEnvironment: WindowsEnvironmentReader = ( { encoding: "utf8", timeout: 5000 }, ); - let parsed: { - machine?: Partial>; - user?: Partial>; - }; - try { - parsed = JSON.parse(output.trim()); - } catch { - return {}; - } - - return mergeWindowsScopes(parsed.machine ?? {}, parsed.user ?? {}); + return parseWindowsPersistentEnvironment(output); }; + +export async function readWindowsPersistentEnvironmentAsync( + run: ExecFileAsyncLike = execFileAsync, +): Promise>> { + const { stdout } = await run( + resolveWindowsPowerShellPath(), + ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_ENVIRONMENT_SCRIPT], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + return parseWindowsPersistentEnvironment(stdout); +} From fa95d3249794bd654f7e624c1d21721cb4394083 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:31:25 +0300 Subject: [PATCH 5/7] Keep post-install sign-in recovery on the correct path --- .../ProviderConnectionDialog.browser.tsx | 77 +++++++++++++++++++ .../providerConnectionPresentation.test.ts | 38 +++++++++ .../src/lib/providerConnectionPresentation.ts | 12 ++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index faf84069..89df2a23 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -1429,6 +1429,83 @@ describe("ProviderConnectionDialog", () => { } }); + it("retries sign-in instead of reinstalling after an automatic handoff failure", async () => { + const failureMessage = "Installation succeeded, but sign in could not start."; + const codex = { + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "managed", + managedVersion: "0.145.0", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + installationState: { + operationId: "install-codex-1", + operation: "install", + status: "installed", + startedAt: checkedAt, + finishedAt: "2026-07-19T12:01:00.000Z", + message: "Codex is installed and verified.", + }, + connectionState: { + operationId: "handoff-install-codex-1", + method: "codex_browser", + status: "failed", + startedAt: "2026-07-19T12:01:00.000Z", + finishedAt: "2026-07-19T12:01:01.000Z", + message: failureMessage, + }, + } satisfies ServerProviderStatus; + const retrying = { + ...codex, + connectionState: { + operationId: "connect-codex-retry", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: "2026-07-19T12:02:00.000Z", + finishedAt: null, + message: "Finish the provider sign-in in your browser.", + }, + } satisfies ServerProviderStatus; + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [retrying] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [codex] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(codex); + useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByText(failureMessage)).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open installation guide" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Try again" }).click(); + await vi.waitFor(() => + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_browser", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("keeps managed installation available after a complete provider refresh", async () => { const antigravity = { provider: "antigravity", diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index 6df5aa61..b87bbd3f 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -318,6 +318,44 @@ describe("provider connection presentation", () => { expect(presentation.primaryLabel).toBe("Try again"); }); + it("retries sign-in when the automatic handoff fails after a managed install", () => { + const failureMessage = "Installation succeeded, but sign in could not start."; + const presentation = describeProviderConnection("codex", { + ...BASE_STATUS, + available: false, + authStatus: "unknown", + runtime: { + source: "managed", + managedVersion: "0.145.0", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + installationState: { + operationId: "install-codex-1", + operation: "install", + status: "installed", + startedAt: "2026-07-19T10:00:00.000Z", + finishedAt: "2026-07-19T10:01:00.000Z", + message: "Codex is installed and verified.", + }, + connectionState: { + operationId: "handoff-install-codex-1", + method: "codex_browser", + status: "failed", + startedAt: "2026-07-19T10:01:00.000Z", + finishedAt: "2026-07-19T10:01:01.000Z", + message: failureMessage, + }, + }); + + expect(presentation.description).toBe(failureMessage); + expect(presentation.primaryAction).toBe("sign_in"); + expect(presentation.primaryLabel).toBe("Try again"); + }); + it("turns a rejected Grok OAuth operation into a fresh browser retry", () => { const presentation = describeProviderConnection("grok", { ...BASE_STATUS, diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index bbe0e8f7..074c6732 100644 --- a/apps/web/src/lib/providerConnectionPresentation.ts +++ b/apps/web/src/lib/providerConnectionPresentation.ts @@ -278,15 +278,21 @@ export function describeProviderConnection( canCancel: false, }; case "failed": - case "cancelled": + case "cancelled": { + const canRetrySignIn = + providerConnectionMethod(provider) !== null && + (status?.available === true || + installation?.status === "installed" || + status?.runtime?.source === "managed"); return { title, description: operation.message, - primaryAction: status?.available ? "sign_in" : "open_install_guide", - primaryLabel: status?.available ? "Try again" : "Open installation guide", + primaryAction: canRetrySignIn ? "sign_in" : "open_install_guide", + primaryLabel: canRetrySignIn ? "Try again" : "Open installation guide", busy: false, canCancel: false, }; + } } } From 1209a77e95bc6dc095dfc996512622054bc89c99 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:41:37 +0300 Subject: [PATCH 6/7] Canonicalize Windows runtime discovery test paths --- .../Layers/ProviderRuntimeManager.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index a1566158..bd038303 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -1,5 +1,13 @@ import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -245,10 +253,11 @@ describe("ProviderRuntimeManager managed integrity", () => { ); expect(result.beforeInstall.source).toBe("missing"); - expect(result.afterInstall).toMatchObject({ - source: "system", - executable: path.join(baseDir, "codex.exe"), - }); + expect(result.afterInstall.source).toBe("system"); + expect(result.afterInstall.executable).not.toBeNull(); + expect(realpathSync.native(result.afterInstall.executable!)).toBe( + realpathSync.native(path.join(baseDir, "codex.exe")), + ); expect(shellMocks.readWindowsPersistentEnvironmentAsync).toHaveBeenCalledTimes(2); } finally { nowSpy.mockRestore(); From df4407d153d7db93b75656e100a06acd79aec698 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 21:57:15 +0300 Subject: [PATCH 7/7] Harden provider device-code recovery --- .../Layers/ProviderConnection.test.ts | 57 +++++++++++++++ .../src/provider/Layers/ProviderConnection.ts | 32 +++++++- .../ProviderConnectionDialog.browser.tsx | 73 +++++++++++++++++++ .../components/ProviderConnectionDialog.tsx | 26 +++++-- 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 857a4e66..dc9fb0a1 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -31,6 +31,7 @@ import { import { antigravityAuthenticationCommandArgs, + CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, expectedMethodForProvider, makeProviderConnectionLive, parseAntigravityOAuthAuthorizationUrl, @@ -38,6 +39,7 @@ import { parseCodexOAuthAuthorizationUrl, parseGrokOAuthAuthorizationUrl, providerConnectionCommandArgs, + resolveProviderConnectionTimeout, } from "./ProviderConnection"; import { resolveProviderProbeCwd } from "./ProviderHealth"; @@ -130,6 +132,7 @@ function makeConnectionTestLayer(input?: { readonly provider?: ProviderKind; readonly runtimeSource?: ServerProviderRuntimeSource; readonly timeout?: Duration.Duration; + readonly codexDeviceCodeTimeout?: Duration.Duration; readonly antigravityCodeWindowTimeout?: Duration.Duration; readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; @@ -371,6 +374,9 @@ function makeConnectionTestLayer(input?: { } satisfies ProviderRuntimeManagerShape); const layer = makeProviderConnectionLive({ ...(input?.timeout ? { timeout: input.timeout } : {}), + ...(input?.codexDeviceCodeTimeout + ? { codexDeviceCodeTimeout: input.codexDeviceCodeTimeout } + : {}), ...(input?.antigravityCodeWindowTimeout ? { antigravityCodeWindowTimeout: input.antigravityCodeWindowTimeout } : {}), @@ -742,6 +748,57 @@ describe("ProviderConnectionLive", () => { ); }); + it("gives Codex device authorization its full provider window and respects explicit overrides", () => { + const defaultTimeout = Duration.minutes(10); + const antigravityTimeout = Duration.minutes(11); + expect(Duration.toMillis(CODEX_DEVICE_CODE_CONNECTION_TIMEOUT)).toBe(16 * 60 * 1_000); + expect( + Duration.toMillis( + resolveProviderConnectionTimeout({ + provider: "codex", + method: "codex_device_code", + defaultTimeout, + codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, + antigravityTimeout, + }), + ), + ).toBe(16 * 60 * 1_000); + expect( + Duration.toMillis( + resolveProviderConnectionTimeout({ + provider: "codex", + method: "codex_device_code", + explicitTimeout: Duration.millis(5), + defaultTimeout, + codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, + antigravityTimeout, + }), + ), + ).toBe(5); + }); + + it("times out and cleans up a Codex device authorization using its dedicated policy", async () => { + const onKill = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + hanging: true, + codexDeviceCodeTimeout: Duration.millis(5), + onKill, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "codex", method: "codex_device_code" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("failed"); + expect(fixture.getConnectionState()?.message).toContain("timed out"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onKill).toHaveBeenCalledTimes(1); + }); + it("runs managed Antigravity's browser-auth bootstrap and verifies models", async () => { const onSpawn = vi.fn(); const onAuthenticationKill = vi.fn(); diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index 7dac17c6..f3b3da09 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -49,6 +49,7 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import { parseAntigravityModelsAuthStatus, resolveProviderProbeCwd } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); +export const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT = Duration.minutes(16); const INSTALLATION_HANDOFF_TIMEOUT = Duration.minutes(30); const INSTALLATION_HANDOFF_POLL_INTERVAL = Duration.millis(250); const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60; @@ -349,8 +350,25 @@ function makeConnectionError(input: { return new ServerProviderConnectionError(input); } +export function resolveProviderConnectionTimeout(input: { + readonly provider: ProviderKind; + readonly method: ServerProviderConnectionMethod; + readonly explicitTimeout?: Duration.Duration; + readonly defaultTimeout: Duration.Duration; + readonly codexDeviceCodeTimeout: Duration.Duration; + readonly antigravityTimeout: Duration.Duration; +}): Duration.Duration { + if (input.explicitTimeout !== undefined) return input.explicitTimeout; + if (input.provider === "codex" && input.method === "codex_device_code") { + return input.codexDeviceCodeTimeout; + } + if (input.provider === "antigravity") return input.antigravityTimeout; + return input.defaultTimeout; +} + export function makeProviderConnectionLive(options?: { readonly timeout?: Duration.Duration; + readonly codexDeviceCodeTimeout?: Duration.Duration; readonly antigravityCodeWindowTimeout?: Duration.Duration; readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; @@ -361,6 +379,8 @@ export function makeProviderConnectionLive(options?: { readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; }) { const timeout = options?.timeout ?? CONNECTION_TIMEOUT; + const codexDeviceCodeTimeout = + options?.codexDeviceCodeTimeout ?? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT; const antigravityCodeWindowTimeout = options?.antigravityCodeWindowTimeout ?? ANTIGRAVITY_CODE_WINDOW_TIMEOUT; const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT; @@ -956,10 +976,14 @@ export function makeProviderConnectionLive(options?: { oauthOutputObserver, ) : runCommand(command, oauthOutputObserver).pipe(Effect.scoped); - const operationTimeout = - provider === "antigravity" && options?.timeout === undefined - ? antigravityTimeout - : timeout; + const operationTimeout = resolveProviderConnectionTimeout({ + provider, + method, + ...(options?.timeout !== undefined ? { explicitTimeout: options.timeout } : {}), + defaultTimeout: timeout, + codexDeviceCodeTimeout, + antigravityTimeout, + }); const exitCodeResult = yield* connectionProcess.pipe( Effect.timeoutOption(operationTimeout), Effect.result, diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 89df2a23..673012a3 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -958,7 +958,13 @@ describe("ProviderConnectionDialog", () => { try { await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); await expect.element(page.getByText("ABCD-EFGH")).toBeVisible(); + const deviceCodeStatus = page.getByRole("status", { name: "Codex device code" }); + await expect.element(deviceCodeStatus).toHaveAttribute("aria-live", "polite"); + await expect.element(deviceCodeStatus).toHaveAttribute("aria-atomic", "true"); + await expect.element(page.getByText(/Automatic timeout in (?:16:00|15:59)/u)).toBeVisible(); await expect.element(page.getByRole("button", { name: "Copy code" })).toBeVisible(); + await page.getByRole("button", { name: "Copy code" }).click(); + await expect.element(page.getByRole("button", { name: "Copied" })).toBeVisible(); await expect .element(page.getByRole("button", { name: "Use device code instead" })) .not.toBeInTheDocument(); @@ -969,6 +975,73 @@ describe("ProviderConnectionDialog", () => { } }); + it("ignores a stale automatic-browser failure after the device operation changes", async () => { + let rejectFirstOpen: (error: Error) => void = () => undefined; + const firstOpen = new Promise((_resolve, reject) => { + rejectFirstOpen = reject; + }); + const authorizationUrl = "https://auth.openai.com/codex/device"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-device-first", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Enter the one-time code shown here.", + authorizationUrl, + userCode: "ABCD-EFGH", + }, + } satisfies ServerProviderStatus; + const openExternal = vi + .fn() + .mockImplementationOnce(() => firstOpen) + .mockResolvedValue(undefined); + const restoreNativeApi = installNativeApi({ openExternal }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(1)); + applyProviderStatusesToCache(queryClient, [ + { + ...active, + connectionState: { + ...active.connectionState, + operationId: "connect-codex-device-successor", + userCode: "IJKL-MNOP", + }, + }, + ]); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(2)); + rejectFirstOpen(new Error("stale browser failure")); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + await expect + .element( + page.getByText( + "Scient could not open the browser automatically. Use Open browser again below.", + ), + ) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("reopens the validated Google authorization page without terminal use", async () => { const authorizationUrl = "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 9ccaa188..cf952af0 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -35,6 +35,7 @@ import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; +const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS = 16 * 60 * 1_000; const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number): string { @@ -179,13 +180,20 @@ export function ProviderConnectionDialog() { const key = `${activeConnectionOperationId}:${activeConnectionAuthorizationUrl}`; if (openedAuthorizationUrlsRef.current.has(key)) return; openedAuthorizationUrlsRef.current.add(key); + let disposed = false; void ensureNativeApi() .shell.openExternal(activeConnectionAuthorizationUrl) - .catch(() => + .catch(() => { + if (disposed || activeConnectionOperationIdRef.current !== activeConnectionOperationId) { + return; + } setActionError( "Scient could not open the browser automatically. Use Open browser again below.", - ), - ); + ); + }); + return () => { + disposed = true; + }; }, [ isOpen, activeConnectionOperationId, @@ -437,7 +445,9 @@ export function ProviderConnectionDialog() { clockMs, provider === "antigravity" ? ANTIGRAVITY_CONNECTION_TIMEOUT_MS - : CONNECTION_TIMEOUT_MS, + : activeConnectionMethod === "codex_device_code" + ? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS + : CONNECTION_TIMEOUT_MS, )}

) : null} @@ -528,7 +538,13 @@ export function ProviderConnectionDialog() { ) : null} {activeCodexDeviceCode ? ( -
+

Enter this one-time code on the OpenAI page