From 968fcedd2cb6dbd2234b4451eceea9746bb85f84 Mon Sep 17 00:00:00 2001 From: Yishay Polatov Date: Wed, 22 Jul 2026 21:18:50 +0300 Subject: [PATCH] Add one-click browser account connect for OpenAI, Anthropic, and Google Silent managed installs chain straight into each provider's browser sign-in; adds native ChatGPT OAuth and first-run account onboarding. --- .../Layers/ProviderConnection.test.ts | 253 ++++++++++-- .../src/provider/Layers/ProviderConnection.ts | 131 ++++-- .../src/provider/acp/AcpSessionRuntime.ts | 13 +- .../provider/oauth/openaiNativeOAuth.test.ts | 282 +++++++++++++ .../src/provider/oauth/openaiNativeOAuth.ts | 382 ++++++++++++++++++ .../components/OnboardingConnectAccounts.tsx | 177 ++++++++ .../ProviderConnectionDialog.browser.tsx | 108 ++++- .../components/ProviderConnectionDialog.tsx | 83 +++- apps/web/src/composerDraftStore.ts | 74 ++-- apps/web/src/lib/onboarding.logic.test.ts | 159 ++++++++ apps/web/src/lib/onboarding.logic.ts | 83 ++++ .../providerConnectionPresentation.test.ts | 123 +++++- .../src/lib/providerConnectionPresentation.ts | 81 +++- .../src/providerConnectionDialogStore.test.ts | 22 + apps/web/src/providerConnectionDialogStore.ts | 26 +- apps/web/src/routes/__root.tsx | 2 + apps/web/src/routes/_chat.settings.tsx | 124 +++--- 17 files changed, 1936 insertions(+), 187 deletions(-) create mode 100644 apps/server/src/provider/oauth/openaiNativeOAuth.test.ts create mode 100644 apps/server/src/provider/oauth/openaiNativeOAuth.ts create mode 100644 apps/web/src/components/OnboardingConnectAccounts.tsx create mode 100644 apps/web/src/lib/onboarding.logic.test.ts create mode 100644 apps/web/src/lib/onboarding.logic.ts diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 580bd9968..80461969b 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { ProviderKind, ServerProviderConnectionState, @@ -158,6 +162,18 @@ function makeConnectionTestLayer(input?: { readonly binaryPath?: string; readonly cwd?: string; }) => void; + readonly settingsOverrides?: Parameters[0]; + readonly openAiNativeOAuthFlowOverrides?: NonNullable< + Parameters[0] + >["openAiNativeOAuthFlowOverrides"]; + // Shapes availability/installation per refresh call so tests can model a + // managed install still running while another verification path polls. + readonly refreshStatusOverride?: (refreshCall: number) => + | { + readonly available?: boolean; + readonly installationState?: ServerProviderStatus["installationState"]; + } + | undefined; }) { let connectionState: ServerProviderConnectionState | undefined; const connectionStateWaiters = new Set< @@ -165,21 +181,25 @@ function makeConnectionTestLayer(input?: { >(); let authenticated = input?.initiallyAuthenticated ?? false; let refreshCalls = 0; - const status = (): ServerProviderStatus => ({ - provider: input?.provider ?? "claudeAgent", - status: authenticated ? "ready" : "error", - available: input?.available ?? true, - authStatus: authenticated ? "authenticated" : "unauthenticated", - ...(input?.requiresProviderAccount === null - ? {} - : input?.requiresProviderAccount !== undefined - ? { requiresProviderAccount: input.requiresProviderAccount } - : input?.provider === "codex" - ? { requiresProviderAccount: true } - : {}), - checkedAt: new Date().toISOString(), - ...(connectionState ? { connectionState } : {}), - }); + const status = (): ServerProviderStatus => { + const shaped = input?.refreshStatusOverride?.(refreshCalls); + return { + provider: input?.provider ?? "claudeAgent", + status: authenticated ? "ready" : "error", + available: shaped?.available ?? input?.available ?? true, + authStatus: authenticated ? "authenticated" : "unauthenticated", + ...(input?.requiresProviderAccount === null + ? {} + : input?.requiresProviderAccount !== undefined + ? { requiresProviderAccount: input.requiresProviderAccount } + : input?.provider === "codex" + ? { requiresProviderAccount: true } + : {}), + checkedAt: new Date().toISOString(), + ...(shaped?.installationState ? { installationState: shaped.installationState } : {}), + ...(connectionState ? { connectionState } : {}), + }; + }; const providerHealthLayer = Layer.succeed(ProviderHealth, { getStatuses: Effect.sync(() => [status()]), refresh: Effect.sync(() => { @@ -388,8 +408,11 @@ function makeConnectionTestLayer(input?: { ...(input?.droidAuthenticationProbe ? { droidAuthenticationProbe: input.droidAuthenticationProbe } : {}), + ...(input?.openAiNativeOAuthFlowOverrides + ? { openAiNativeOAuthFlowOverrides: input.openAiNativeOAuthFlowOverrides } + : {}), }).pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettingsService.layerTest(input?.settingsOverrides ?? {})), Layer.provideMerge(Layer.succeed(ServerConfig, TEST_CONFIG)), Layer.provideMerge(providerHealthLayer), Layer.provideMerge(providerDiscoveryLayer), @@ -1420,30 +1443,196 @@ describe("ProviderConnectionLive", () => { expect(onSpawn).not.toHaveBeenCalled(); }); - it("starts a fresh Codex login when runtime recovery explicitly requests reauthentication", async () => { + it("runs the native Codex OAuth end to end and persists auth.json", async () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-native-")); const onSpawn = vi.fn(); + const idTokenSegment = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account-native" } }), + ).toString("base64url"); + const idToken = `${Buffer.from("{}").toString("base64url")}.${idTokenSegment}.signature`; + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; const fixture = makeConnectionTestLayer({ provider: "codex", initiallyAuthenticated: true, onSpawn, + settingsOverrides: { providers: { codex: { homePath: codexHome } } }, + openAiNativeOAuthFlowOverrides: { ports: [0], fetchImpl }, }); - await Effect.runPromise( - Effect.gen(function* () { - const connection = yield* ProviderConnection; - yield* connection.start({ - provider: "codex", - method: "codex_browser", - mode: "reauthenticate", - }); - yield* Effect.sleep(Duration.millis(20)); - expect(fixture.getConnectionState()?.status).toBe("connected"); - }).pipe(Effect.provide(fixture.layer)), - ); + try { + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + const waiting = yield* Effect.promise(() => + fixture.waitForConnectionState( + (state) => + state?.status === "waiting_for_browser" && state.authorizationUrl !== undefined, + ), + ); + const authorizationUrl = new URL(waiting!.authorizationUrl!); + expect(authorizationUrl.origin).toBe("https://auth.openai.com"); + expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256"); + const redirectUri = authorizationUrl.searchParams.get("redirect_uri")!; + const state = authorizationUrl.searchParams.get("state")!; + yield* Effect.promise(() => fetch(`${redirectUri}?code=test-code&state=${state}`)); + yield* Effect.promise(() => + fixture.waitForConnectionState((current) => current?.status === "connected"), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + // The native flow spawns no CLI process at all. + expect(onSpawn).not.toHaveBeenCalled(); + const authJson = JSON.parse( + fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"), + ) as Record; + expect(authJson.tokens).toMatchObject({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + account_id: "account-native", + }); + } finally { + fs.rmSync(codexHome, { recursive: true, force: true }); + } + }); - expect(onSpawn).toHaveBeenCalledWith( - expect.objectContaining({ command: "codex", args: ["login"] }), - ); + it("waits for a parallel managed install before verifying native Codex OAuth", async () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-native-")); + const onSpawn = vi.fn(); + const idTokenSegment = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account-native" } }), + ).toString("base64url"); + const idToken = `${Buffer.from("{}").toString("base64url")}.${idTokenSegment}.signature`; + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + let installingRefreshes = 0; + const fixture = makeConnectionTestLayer({ + provider: "codex", + initiallyAuthenticated: true, + onSpawn, + settingsOverrides: { providers: { codex: { homePath: codexHome } } }, + openAiNativeOAuthFlowOverrides: { ports: [0], fetchImpl }, + // Refresh #1 is the preflight; the codex-native pre-verification wait begins + // at refresh #2. Report an in-progress managed install (unavailable plus a + // non-terminal installationState) for that first wait refresh, then let the + // fixture's defaults flip it to available so the wait releases. + refreshStatusOverride: (refreshCall) => { + if (refreshCall !== 2) return undefined; + installingRefreshes += 1; + return { + available: false, + installationState: { + operationId: "codex-managed-install", + operation: "install", + status: "downloading", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Downloading the managed Codex runtime.", + }, + }; + }, + }); + + try { + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + const waiting = yield* Effect.promise(() => + fixture.waitForConnectionState( + (state) => + state?.status === "waiting_for_browser" && state.authorizationUrl !== undefined, + ), + ); + const authorizationUrl = new URL(waiting!.authorizationUrl!); + const redirectUri = authorizationUrl.searchParams.get("redirect_uri")!; + const state = authorizationUrl.searchParams.get("state")!; + yield* Effect.promise(() => fetch(`${redirectUri}?code=test-code&state=${state}`)); + yield* Effect.promise(() => + fixture.waitForConnectionState((current) => current?.status === "connected"), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + // The pre-verification wait must have consumed at least one in-progress + // install refresh before the account was verified as connected. + expect(installingRefreshes).toBeGreaterThanOrEqual(1); + expect(onSpawn).not.toHaveBeenCalled(); + const authJson = JSON.parse( + fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"), + ) as Record; + expect(authJson.tokens).toMatchObject({ account_id: "account-native" }); + } finally { + fs.rmSync(codexHome, { recursive: true, force: true }); + } + }); + + it("falls back to the CLI login when the OAuth callback port is unavailable", async () => { + const http = await import("node:http"); + const blocker = http.createServer(() => undefined); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + const blockedPort = (blocker.address() as { port: number }).port; + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-native-")); + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + initiallyAuthenticated: true, + onSpawn, + settingsOverrides: { providers: { codex: { homePath: codexHome } } }, + openAiNativeOAuthFlowOverrides: { ports: [blockedPort] }, + }); + + try { + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + yield* Effect.promise(() => + fixture.waitForConnectionState((current) => current?.status === "connected"), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith(expect.objectContaining({ args: ["login"] })); + const spawnedCommand = (onSpawn.mock.calls[0]?.[0] as CapturedCommand | undefined)?.command; + expect(path.basename(spawnedCommand ?? "").toLowerCase()).toMatch(/^codex(\.exe)?$/u); + } finally { + blocker.close(); + fs.rmSync(codexHome, { recursive: true, force: true }); + } }); it("refuses official Codex reauthentication for a custom Codex provider", async () => { diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index e1aac571a..3c421f4a0 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -42,6 +42,11 @@ import { PtyAdapter } from "../../terminal/Services/PTY"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; import { buildCursorAgentCommand } from "../acp/CursorAcpCommand"; import { probeDroidAcpAuthentication } from "../acp/DroidAcpSupport"; +import { + OpenAiNativeOAuthError, + runOpenAiNativeOAuthFlow, + type RunOpenAiNativeOAuthFlowOptions, +} from "../oauth/openaiNativeOAuth"; import { ProviderConnection, type ProviderConnectionShape } from "../Services/ProviderConnection"; import { ProviderDiscoveryService } from "../Services/ProviderDiscoveryService"; import { ProviderHealth } from "../Services/ProviderHealth"; @@ -71,12 +76,17 @@ interface ActiveConnection { } interface ConnectionCommand { + /** Empty only for the codex-native-oauth strategy when no runtime is installed yet. */ readonly executable: string; readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; readonly cwd?: string; readonly waitingMessage: string; - readonly strategy?: "antigravity-browser"; + readonly strategy?: "antigravity-browser" | "codex-native-oauth"; + /** codex-native-oauth: destination CODEX_HOME for auth.json. */ + readonly codexHome?: string; + /** codex-native-oauth: configured binary override for post-flow verification. */ + readonly configuredBinaryPath?: string; } interface ConnectionOutputObserver { @@ -266,6 +276,10 @@ export function makeProviderConnectionLive(options?: { readonly beforeAntigravityOutputPublication?: Effect.Effect; readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect; readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; + /** Test seam: overrides issuer/client/ports/fetch for the native OpenAI flow. */ + readonly openAiNativeOAuthFlowOverrides?: Partial< + Omit + >; }) { const timeout = options?.timeout ?? CONNECTION_TIMEOUT; const antigravityCodeWindowTimeout = @@ -380,12 +394,15 @@ export function makeProviderConnectionLive(options?: { }); } const homePath = settings.providers.codex.homePath.trim() || undefined; + const configuredBinaryPath = settings.providers.codex.binaryPath.trim() || undefined; const runtimeEnv = yield* Effect.promise(() => buildCodexProcessEnv(homePath ? { homePath } : {}), ); - const executable = yield* resolveExecutable( - settings.providers.codex.binaryPath.trim() || undefined, - "codex", + // The native OAuth strategy signs in without a local runtime, so a + // missing binary must not block it; the empty executable is only a + // CLI-fallback marker and is never spawned while empty. + const executable = yield* resolveExecutable(configuredBinaryPath, "codex").pipe( + Effect.catch(() => Effect.succeed("")), ); return { executable, @@ -395,6 +412,9 @@ export function makeProviderConnectionLive(options?: { CODEX_HOME: resolveBaseCodexHomePath(process.env, homePath), }, waitingMessage: "Finish signing in to ChatGPT in the browser window.", + strategy: "codex-native-oauth", + codexHome: resolveBaseCodexHomePath(process.env, homePath), + ...(configuredBinaryPath ? { configuredBinaryPath } : {}), } satisfies ConnectionCommand; } @@ -730,7 +750,10 @@ export function makeProviderConnectionLive(options?: { // Provider-owned sign-in and verification never need a project cwd. // Keep them in Scient's private probe directory so authentication // cannot trigger macOS access to whichever project launched the app. - const command = { ...commandResult.success, cwd: providerConnectionCwd }; + const command: ConnectionCommand = { + ...commandResult.success, + cwd: providerConnectionCwd, + }; const refreshedBeforeStart = yield* providerHealth.refresh; const currentStatus = refreshedBeforeStart.find((status) => status.provider === provider); const requestsCodexReauthentication = @@ -814,6 +837,35 @@ export function makeProviderConnectionLive(options?: { }, } : undefined; + let strategyFailureMessage: string | null = null; + const runCodexNativeOAuth: Effect.Effect = runOpenAiNativeOAuthFlow({ + codexHome: command.codexHome ?? resolveBaseCodexHomePath(process.env), + onAuthorizationUrl: (url) => + publishState( + provider, + state({ + status: "waiting_for_browser", + message: command.waitingMessage, + authorizationUrl: url, + }), + ).pipe(Effect.asVoid), + ...(options?.openAiNativeOAuthFlowOverrides ?? {}), + } satisfies RunOpenAiNativeOAuthFlowOptions).pipe( + Effect.scoped, + Effect.as(0), + Effect.catch((error) => { + if (error instanceof OpenAiNativeOAuthError) { + if (error.reason === "port_in_use" && command.executable) { + // The CLI owns the same credential file; its own login flow + // is a safe fallback when the loopback port is occupied. + return runCommand(command).pipe(Effect.scoped); + } + strategyFailureMessage = error.message; + return Effect.succeed(1); + } + return Effect.fail(error); + }), + ); const connectionProcess: Effect.Effect = provider === "droid" ? (options?.droidAuthenticationProbe ?? probeDroidAcpAuthentication)({ @@ -821,24 +873,26 @@ export function makeProviderConnectionLive(options?: { childProcessSpawner: spawner, cwd: providerConnectionCwd, }).pipe(Effect.as(0)) - : command.strategy === "antigravity-browser" - ? runAntigravityConnection( - command, - operationId, - authorizationCodeInput, - authorizationCodeAccepted, - authorizationCodeSubmissionState, - authorizationCodeLifecycleClosed, - publishState( - provider, - state({ - status: "verifying", - message: "Verifying the connection.", - }), - ).pipe(Effect.asVoid), - oauthOutputObserver, - ) - : runCommand(command, oauthOutputObserver).pipe(Effect.scoped); + : command.strategy === "codex-native-oauth" + ? runCodexNativeOAuth + : command.strategy === "antigravity-browser" + ? runAntigravityConnection( + command, + operationId, + authorizationCodeInput, + authorizationCodeAccepted, + authorizationCodeSubmissionState, + authorizationCodeLifecycleClosed, + publishState( + provider, + state({ + status: "verifying", + message: "Verifying the connection.", + }), + ).pipe(Effect.asVoid), + oauthOutputObserver, + ) + : runCommand(command, oauthOutputObserver).pipe(Effect.scoped); const operationTimeout = provider === "antigravity" && options?.timeout === undefined ? antigravityTimeout @@ -876,9 +930,10 @@ export function makeProviderConnectionLive(options?: { state({ status: "failed", message: - provider === "grok" + strategyFailureMessage ?? + (provider === "grok" ? "Grok authorization was not completed. Close any old xAI page, update Grok if an update is available, then try again to start a fresh secure browser sign-in." - : "Sign in was not completed. No credentials were saved by Scient.", + : "Sign in was not completed. No credentials were saved by Scient."), finished: true, }), ); @@ -889,6 +944,21 @@ export function makeProviderConnectionLive(options?: { provider, state({ status: "verifying", message: "Verifying the connection." }), ); + if (command.strategy === "codex-native-oauth") { + // The native flow can finish before a concurrently started managed + // install does. Credentials are already on disk, so wait for the + // runtime instead of failing verification against a missing binary. + for (let waitAttempt = 0; waitAttempt < 150; waitAttempt += 1) { + const refreshed = yield* providerHealth.refresh; + const current = refreshed.find((status) => status.provider === provider); + const installation = current?.installationState; + const installRunning = + installation !== undefined && + !["installed", "succeeded", "failed", "cancelled"].includes(installation.status); + if (current?.available || !installRunning) break; + yield* Effect.sleep(Duration.seconds(2)); + } + } let verified: ServerProviderStatus | undefined; for (let attempt = 0; attempt < 10; attempt += 1) { const refreshed = yield* providerHealth.refresh; @@ -907,10 +977,19 @@ export function makeProviderConnectionLive(options?: { ); return; } + const catalogBinaryPath = + command.strategy === "codex-native-oauth" && !command.executable + ? yield* providerRuntimeManager + .resolve(provider, command.configuredBinaryPath) + .pipe( + Effect.map((runtime) => runtime.executable ?? "codex"), + Effect.catch(() => Effect.succeed("codex")), + ) + : command.executable; const modelReadiness = yield* providerDiscovery .listModels({ provider, - binaryPath: command.executable, + binaryPath: catalogBinaryPath, cwd: providerConnectionCwd, }) .pipe(Effect.timeoutOption(Duration.seconds(30)), Effect.result); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 403d16d2b..7308495e6 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -808,7 +808,18 @@ const makeAcpSessionRuntime = ( ), setModel: (model) => getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.flatMap((started) => + started.modelConfigId + ? setConfigOption(started.modelConfigId, model) + : runLoggedRequest( + "session/set_model", + { sessionId: started.sessionId, modelId: model }, + acp.raw.request("session/set_model", { + sessionId: started.sessionId, + modelId: model, + }), + ), + ), Effect.asVoid, ), forkSession: (payload) => diff --git a/apps/server/src/provider/oauth/openaiNativeOAuth.test.ts b/apps/server/src/provider/oauth/openaiNativeOAuth.test.ts new file mode 100644 index 000000000..39d035d75 --- /dev/null +++ b/apps/server/src/provider/oauth/openaiNativeOAuth.test.ts @@ -0,0 +1,282 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { Effect } from "effect"; +import { describe, expect, it, vi } from "vitest"; + +import { + OPENAI_OAUTH_CLIENT_ID, + OpenAiNativeOAuthError, + buildOpenAiAuthorizeUrl, + createPkcePair, + parseChatGptAccountId, + runOpenAiNativeOAuthFlow, + serializeCodexAuthJson, +} from "./openaiNativeOAuth"; + +function makeIdToken(claims: Record): string { + const segment = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${segment({ alg: "RS256" })}.${segment(claims)}.signature`; +} + +describe("openaiNativeOAuth", () => { + it("derives an S256 PKCE challenge from the verifier", () => { + const pair = createPkcePair(); + expect(pair.verifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/u); + expect(pair.challenge).toBe(createHash("sha256").update(pair.verifier).digest("base64url")); + }); + + it("builds the codex-equivalent authorization URL", () => { + const url = new URL( + buildOpenAiAuthorizeUrl({ + issuer: "https://auth.openai.com", + clientId: OPENAI_OAUTH_CLIENT_ID, + redirectUri: "http://localhost:1455/auth/callback", + codeChallenge: "challenge-value", + state: "state-value", + }), + ); + expect(url.origin).toBe("https://auth.openai.com"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe(OPENAI_OAUTH_CLIENT_ID); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-value"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-value"); + expect(url.searchParams.get("codex_cli_simplified_flow")).toBe("true"); + expect(url.searchParams.get("id_token_add_organizations")).toBe("true"); + expect(url.searchParams.get("scope")).toContain("offline_access"); + }); + + it("extracts the ChatGPT account id from the id_token auth claim", () => { + const idToken = makeIdToken({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + expect(parseChatGptAccountId(idToken)).toBe("account-123"); + expect(parseChatGptAccountId(makeIdToken({}))).toBeNull(); + expect(parseChatGptAccountId("not-a-jwt")).toBeNull(); + }); + + it("serializes auth.json exactly like codex-rs AuthDotJson", () => { + const serialized = serializeCodexAuthJson( + { + idToken: "id-token-raw", + accessToken: "access-token-raw", + refreshToken: "refresh-token-raw", + accountId: "account-123", + }, + { now: new Date("2026-07-22T10:00:00.000Z") }, + ); + expect(JSON.parse(serialized)).toEqual({ + OPENAI_API_KEY: null, + tokens: { + id_token: "id-token-raw", + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + account_id: "account-123", + }, + last_refresh: "2026-07-22T10:00:00.000Z", + }); + const withoutAccount = JSON.parse( + serializeCodexAuthJson({ + idToken: "a", + accessToken: "b", + refreshToken: "c", + accountId: null, + }), + ) as { tokens: Record }; + expect("account_id" in withoutAccount.tokens).toBe(false); + }); + + it("completes the loopback flow and persists auth.json", async () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + const idToken = makeIdToken({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-xyz" }, + }); + const fetchImpl = vi.fn(async (input: Parameters[0], init?: RequestInit) => { + expect(String(input)).toBe("https://auth.openai.com/oauth/token"); + const body = new URLSearchParams(String(init?.body)); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("test-code"); + expect(body.get("client_id")).toBe(OPENAI_OAUTH_CLIENT_ID); + expect(body.get("code_verifier")).toMatch(/^[A-Za-z0-9_-]+$/u); + return new Response( + JSON.stringify({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + let authorizationUrl: string | null = null; + const flow = runOpenAiNativeOAuthFlow({ + codexHome, + ports: [0], + fetchImpl, + onAuthorizationUrl: (url) => + Effect.sync(() => { + authorizationUrl = url; + }), + }).pipe(Effect.scoped); + + const flowPromise = Effect.runPromise(flow as Effect.Effect); + await vi.waitFor(() => { + if (!authorizationUrl) throw new Error("authorization url not published yet"); + }); + const published = new URL(authorizationUrl!); + const redirectUri = new URL(published.searchParams.get("redirect_uri")!); + const state = published.searchParams.get("state")!; + const callback = await fetch(`${redirectUri.toString()}?code=test-code&state=${state}`); + expect(callback.status).toBe(200); + await flowPromise; + + const authJson = JSON.parse( + fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"), + ) as Record; + expect(authJson.OPENAI_API_KEY).toBeNull(); + expect(authJson.tokens).toMatchObject({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + account_id: "account-xyz", + }); + fs.rmSync(codexHome, { recursive: true, force: true }); + }); + + it("binds the next candidate port when the first is occupied", async () => { + const http = await import("node:http"); + const blocker = http.createServer(() => undefined); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + const blockedPort = (blocker.address() as { port: number }).port; + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + const idToken = makeIdToken({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-fallback" }, + }); + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + id_token: idToken, + access_token: "access-token-raw", + refresh_token: "refresh-token-raw", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + let authorizationUrl: string | null = null; + const flow = runOpenAiNativeOAuthFlow({ + codexHome, + ports: [blockedPort, 0], + fetchImpl, + onAuthorizationUrl: (url) => + Effect.sync(() => { + authorizationUrl = url; + }), + }).pipe(Effect.scoped); + + const flowPromise = Effect.runPromise(flow as Effect.Effect); + try { + await vi.waitFor(() => { + if (!authorizationUrl) throw new Error("authorization url not published yet"); + }); + const published = new URL(authorizationUrl!); + const redirectUri = new URL(published.searchParams.get("redirect_uri")!); + // The occupied first port is skipped for the second (ephemeral) candidate. + expect(redirectUri.port).not.toBe(String(blockedPort)); + expect(Number(redirectUri.port)).toBeGreaterThan(0); + const state = published.searchParams.get("state")!; + const callback = await fetch(`${redirectUri.toString()}?code=test-code&state=${state}`); + expect(callback.status).toBe(200); + await flowPromise; + + const authJson = JSON.parse( + fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"), + ) as Record; + expect(authJson.tokens).toMatchObject({ account_id: "account-fallback" }); + } finally { + blocker.close(); + fs.rmSync(codexHome, { recursive: true, force: true }); + } + }); + + it("fails with authorization_denied when the callback carries an error", async () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + let authorizationUrl: string | null = null; + const flow = runOpenAiNativeOAuthFlow({ + codexHome, + ports: [0], + onAuthorizationUrl: (url) => + Effect.sync(() => { + authorizationUrl = url; + }), + }).pipe(Effect.scoped); + const flowPromise = Effect.runPromise(Effect.flip(flow as Effect.Effect)); + await vi.waitFor(() => { + if (!authorizationUrl) throw new Error("authorization url not published yet"); + }); + const published = new URL(authorizationUrl!); + const redirectUri = new URL(published.searchParams.get("redirect_uri")!); + const callback = await fetch(`${redirectUri.toString()}?error=access_denied`); + expect(callback.status).toBe(200); + const failure = await flowPromise; + expect(failure).toBeInstanceOf(OpenAiNativeOAuthError); + expect((failure as OpenAiNativeOAuthError).reason).toBe("authorization_denied"); + expect(fs.existsSync(path.join(codexHome, "auth.json"))).toBe(false); + fs.rmSync(codexHome, { recursive: true, force: true }); + }); + + it("rejects a callback with a mismatched state", async () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + let authorizationUrl: string | null = null; + const flow = runOpenAiNativeOAuthFlow({ + codexHome, + ports: [0], + onAuthorizationUrl: (url) => + Effect.sync(() => { + authorizationUrl = url; + }), + }).pipe(Effect.scoped); + const flowPromise = Effect.runPromise(Effect.flip(flow as Effect.Effect)); + await vi.waitFor(() => { + if (!authorizationUrl) throw new Error("authorization url not published yet"); + }); + const published = new URL(authorizationUrl!); + const redirectUri = new URL(published.searchParams.get("redirect_uri")!); + const callback = await fetch(`${redirectUri.toString()}?code=test-code&state=wrong-state`); + expect(callback.status).toBe(400); + const failure = await flowPromise; + expect(failure).toBeInstanceOf(OpenAiNativeOAuthError); + expect((failure as OpenAiNativeOAuthError).reason).toBe("state_mismatch"); + fs.rmSync(codexHome, { recursive: true, force: true }); + }); + + it("fails with port_in_use when every callback port is occupied", async () => { + const http = await import("node:http"); + const blocker = http.createServer(() => undefined); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + const blockedPort = (blocker.address() as { port: number }).port; + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + try { + const failure = await Effect.runPromise( + Effect.flip( + runOpenAiNativeOAuthFlow({ + codexHome, + ports: [blockedPort], + onAuthorizationUrl: () => Effect.void, + }).pipe(Effect.scoped) as Effect.Effect, + ), + ); + expect(failure).toBeInstanceOf(OpenAiNativeOAuthError); + expect((failure as OpenAiNativeOAuthError).reason).toBe("port_in_use"); + } finally { + blocker.close(); + fs.rmSync(codexHome, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/provider/oauth/openaiNativeOAuth.ts b/apps/server/src/provider/oauth/openaiNativeOAuth.ts new file mode 100644 index 000000000..b955ce08c --- /dev/null +++ b/apps/server/src/provider/oauth/openaiNativeOAuth.ts @@ -0,0 +1,382 @@ +// FILE: openaiNativeOAuth.ts +// Purpose: Native ChatGPT OAuth sign-in for the Codex provider. Runs the same +// PKCE authorization-code flow as `codex login` (identical client id, +// loopback callback, and credential format) so the resulting +// auth.json is indistinguishable from one written by the Codex CLI, +// which then owns the credentials and their refresh lifecycle. +// Tokens never leave this module except as the auth.json file inside +// CODEX_HOME; nothing is logged or sent to the renderer. +// Layer: Server provider integration + +import { createHash, randomBytes } from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; + +import { Deferred, Effect } from "effect"; + +import { writeFileStringAtomically } from "../../atomicWrite"; + +/** Mirrors codex-rs `login`: public OAuth client used by `codex login`. */ +export const OPENAI_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +export const OPENAI_OAUTH_ISSUER = "https://auth.openai.com"; +/** Loopback ports registered for the client's redirect URI (primary, fallback). */ +export const OPENAI_OAUTH_CALLBACK_PORTS: readonly number[] = [1455, 1457]; +export const OPENAI_OAUTH_CALLBACK_PATH = "/auth/callback"; +/** Scope string matches codex-rs verbatim so issued tokens behave identically. */ +const OPENAI_OAUTH_SCOPE = + "openid profile email offline_access api.connectors.read api.connectors.invoke"; +const OPENAI_OAUTH_ORIGINATOR = "codex_cli_rs"; + +export type OpenAiNativeOAuthFailureReason = + | "port_in_use" + | "authorization_denied" + | "state_mismatch" + | "token_exchange_failed" + | "credentials_write_failed"; + +export class OpenAiNativeOAuthError extends Error { + constructor( + readonly reason: OpenAiNativeOAuthFailureReason, + message: string, + ) { + super(message); + this.name = "OpenAiNativeOAuthError"; + } +} + +export interface PkcePair { + readonly verifier: string; + readonly challenge: string; +} + +export function createPkcePair(): PkcePair { + const verifier = randomBytes(64).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function buildOpenAiAuthorizeUrl(input: { + readonly issuer: string; + readonly clientId: string; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly state: string; +}): string { + const url = new URL(`${input.issuer.replace(/\/$/u, "")}/oauth/authorize`); + url.search = new URLSearchParams({ + response_type: "code", + client_id: input.clientId, + redirect_uri: input.redirectUri, + scope: OPENAI_OAUTH_SCOPE, + code_challenge: input.codeChallenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state: input.state, + originator: OPENAI_OAUTH_ORIGINATOR, + }).toString(); + return url.toString(); +} + +/** + * Extracts the ChatGPT account id from the id_token's + * `https://api.openai.com/auth` claim without validating the signature; the + * token was just received over TLS from the issuer's token endpoint. + */ +export function parseChatGptAccountId(idToken: string): string | null { + const segments = idToken.split("."); + if (segments.length !== 3 || !segments[1]) return null; + try { + const claims: unknown = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8")); + if (typeof claims !== "object" || claims === null) return null; + const authClaim = (claims as Record)["https://api.openai.com/auth"]; + if (typeof authClaim !== "object" || authClaim === null) return null; + const accountId = (authClaim as Record).chatgpt_account_id; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; + } catch { + return null; + } +} + +export interface OpenAiTokenBundle { + readonly idToken: string; + readonly accessToken: string; + readonly refreshToken: string; + readonly accountId: string | null; +} + +/** + * Serializes credentials exactly as codex-rs `AuthDotJson` does: `auth_mode` + * is optional and omitted, `OPENAI_API_KEY` is always present (null under + * ChatGPT-subscription auth), and `last_refresh` is an RFC3339 UTC timestamp. + */ +export function serializeCodexAuthJson( + tokens: OpenAiTokenBundle, + options?: { readonly apiKey?: string | null | undefined; readonly now?: Date }, +): string { + return `${JSON.stringify( + { + OPENAI_API_KEY: options?.apiKey ?? null, + tokens: { + id_token: tokens.idToken, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + ...(tokens.accountId ? { account_id: tokens.accountId } : {}), + }, + last_refresh: (options?.now ?? new Date()).toISOString(), + }, + null, + 2, + )}\n`; +} + +export function writeCodexAuthJson(input: { + readonly codexHome: string; + readonly tokens: OpenAiTokenBundle; + readonly apiKey?: string | null; +}) { + return writeFileStringAtomically({ + filePath: path.join(input.codexHome, "auth.json"), + contents: serializeCodexAuthJson(input.tokens, { apiKey: input.apiKey }), + }); +} + +const SUCCESS_PAGE_HTML = ` + + + + Signed in + + + +
+

You're signed in to ChatGPT

+

You can close this tab and return to Scient.

+
+ + +`; + +const FAILURE_PAGE_HTML = ` + + + + Sign-in not completed + + + +
+

Sign-in was not completed

+

Return to Scient to try again. No credentials were saved.

+
+ + +`; + +interface CallbackOutcome { + readonly kind: "code"; + readonly code: string; +} + +function listenOnFirstAvailablePort( + server: http.Server, + ports: readonly number[], +): Effect.Effect { + const tryPort = (port: number) => + Effect.callback((resume) => { + const onError = (error: NodeJS.ErrnoException) => { + server.removeListener("listening", onListening); + resume(Effect.fail(error)); + }; + const onListening = () => { + server.removeListener("error", onError); + resume(Effect.succeed((server.address() as AddressInfo).port)); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, "127.0.0.1"); + }); + const attempt = (index: number): Effect.Effect => { + const port = ports[index]; + if (port === undefined) { + return Effect.fail( + new OpenAiNativeOAuthError( + "port_in_use", + "Another sign-in is already using the OpenAI login port. Close other Codex sign-in windows and try again.", + ), + ); + } + return tryPort(port).pipe( + Effect.catch((error) => + error.code === "EADDRINUSE" || error.code === "EACCES" + ? attempt(index + 1) + : Effect.fail( + new OpenAiNativeOAuthError( + "port_in_use", + "Scient could not open the local sign-in callback port.", + ), + ), + ), + ); + }; + return attempt(0); +} + +export interface RunOpenAiNativeOAuthFlowOptions { + readonly codexHome: string; + readonly onAuthorizationUrl: (url: string) => Effect.Effect; + readonly issuer?: string; + readonly clientId?: string; + readonly ports?: readonly number[]; + readonly fetchImpl?: typeof fetch; +} + +/** + * Runs the complete browser OAuth flow and writes auth.json into + * `options.codexHome`. Succeeds with `void` once credentials are persisted; + * the caller owns timeout, cancellation (interruption closes the callback + * server), and post-flow verification. Requires an enclosing Scope. + */ +export const runOpenAiNativeOAuthFlow = Effect.fn("runOpenAiNativeOAuthFlow")(function* ( + options: RunOpenAiNativeOAuthFlowOptions, +) { + const issuer = options.issuer ?? OPENAI_OAUTH_ISSUER; + const clientId = options.clientId ?? OPENAI_OAUTH_CLIENT_ID; + const fetchImpl = options.fetchImpl ?? fetch; + const pkce = createPkcePair(); + const state = randomBytes(32).toString("base64url"); + const callbackOutcome = yield* Deferred.make(); + + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method !== "GET" || requestUrl.pathname !== OPENAI_OAUTH_CALLBACK_PATH) { + response.writeHead(404, { "content-type": "text/plain" }).end("Not found"); + return; + } + const error = requestUrl.searchParams.get("error"); + if (error) { + response + .writeHead(200, { "content-type": "text/html; charset=utf-8" }) + .end(FAILURE_PAGE_HTML); + Effect.runSync( + Deferred.fail( + callbackOutcome, + new OpenAiNativeOAuthError( + "authorization_denied", + "The browser sign-in was denied or cancelled. No credentials were saved.", + ), + ).pipe(Effect.asVoid), + ); + return; + } + const code = requestUrl.searchParams.get("code"); + const returnedState = requestUrl.searchParams.get("state"); + if (!code || returnedState !== state) { + response + .writeHead(400, { "content-type": "text/html; charset=utf-8" }) + .end(FAILURE_PAGE_HTML); + Effect.runSync( + Deferred.fail( + callbackOutcome, + new OpenAiNativeOAuthError( + "state_mismatch", + "The sign-in response could not be validated. Try again from Scient.", + ), + ).pipe(Effect.asVoid), + ); + return; + } + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(SUCCESS_PAGE_HTML); + const outcome: CallbackOutcome = { kind: "code", code }; + Effect.runSync(Deferred.succeed(callbackOutcome, outcome).pipe(Effect.asVoid)); + }); + yield* Effect.addFinalizer(() => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + // Waiting keep-alive connections must not delay interruption cleanup. + server.closeAllConnections(); + }), + ); + + const boundPort = yield* listenOnFirstAvailablePort( + server, + options.ports ?? OPENAI_OAUTH_CALLBACK_PORTS, + ); + const redirectUri = `http://localhost:${boundPort}${OPENAI_OAUTH_CALLBACK_PATH}`; + yield* options.onAuthorizationUrl( + buildOpenAiAuthorizeUrl({ + issuer, + clientId, + redirectUri, + codeChallenge: pkce.challenge, + state, + }), + ); + + const { code } = yield* Deferred.await(callbackOutcome); + + const exchange = yield* Effect.tryPromise({ + try: async () => { + const response = await fetchImpl(`${issuer.replace(/\/$/u, "")}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: pkce.verifier, + }).toString(), + }); + if (!response.ok) throw new Error(`Token endpoint returned ${response.status}`); + return (await response.json()) as { + readonly id_token?: string; + readonly access_token?: string; + readonly refresh_token?: string; + }; + }, + catch: () => + new OpenAiNativeOAuthError( + "token_exchange_failed", + "OpenAI did not accept the sign-in response. Try again from Scient.", + ), + }); + if (!exchange.id_token || !exchange.access_token || !exchange.refresh_token) { + return yield* Effect.fail( + new OpenAiNativeOAuthError( + "token_exchange_failed", + "OpenAI returned an incomplete sign-in response. Try again from Scient.", + ), + ); + } + + const tokens: OpenAiTokenBundle = { + idToken: exchange.id_token, + accessToken: exchange.access_token, + refreshToken: exchange.refresh_token, + accountId: parseChatGptAccountId(exchange.id_token), + }; + + yield* writeCodexAuthJson({ codexHome: options.codexHome, tokens }).pipe( + Effect.mapError( + () => + new OpenAiNativeOAuthError( + "credentials_write_failed", + "Scient could not save the Codex credentials file.", + ), + ), + ); +}); diff --git a/apps/web/src/components/OnboardingConnectAccounts.tsx b/apps/web/src/components/OnboardingConnectAccounts.tsx new file mode 100644 index 000000000..68e5e2f2c --- /dev/null +++ b/apps/web/src/components/OnboardingConnectAccounts.tsx @@ -0,0 +1,177 @@ +// FILE: OnboardingConnectAccounts.tsx +// Purpose: First-run "connect your accounts" surface. Lists the featured +// providers with live status and hands each Connect click to the regular +// provider connection dialog, so install + browser sign-in stay one flow. +// Layer: Root web overlay + +import { PROVIDER_DISPLAY_NAMES } from "@synara/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useLocalStorage } from "../hooks/useLocalStorage"; +import { + INITIAL_ONBOARDING_STORAGE, + ONBOARDING_FEATURED_PROVIDERS, + ONBOARDING_STORAGE_KEY, + OnboardingStorageSchema, + decideOnboardingVisibility, + onboardingProviderState, +} from "../lib/onboarding.logic"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { useProviderConnectionDialogStore } from "../providerConnectionDialogStore"; +import { PROVIDER_ICON_COMPONENT_BY_PROVIDER } from "./ProviderIcon"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Spinner } from "./ui/spinner"; + +const STATE_LABELS = { + connected: "Connected", + sign_in_pending: "Sign-in in progress", + not_connected: "Not connected", + checking: "Checking…", +} as const; + +export function OnboardingConnectAccounts() { + const navigate = useNavigate(); + const openConnectionDialog = useProviderConnectionDialogStore((store) => store.openDialog); + const [storage, setStorage] = useLocalStorage( + ONBOARDING_STORAGE_KEY, + INITIAL_ONBOARDING_STORAGE, + OnboardingStorageSchema, + ); + const configQuery = useQuery(serverConfigQueryOptions()); + const providers = configQuery.data?.providers; + const decision = decideOnboardingVisibility({ storage, providers }); + + const [open, setOpen] = useState(false); + const everOpenedRef = useRef(false); + const sheetRef = useRef(null); + + useEffect(() => { + if (decision === "show" && !everOpenedRef.current) { + everOpenedRef.current = true; + setOpen(true); + } + }, [decision]); + + // A featured account is connected: record completion once. The surface stays + // open (if it was) so the user sees the connected state and a Done button. + useEffect(() => { + if (decision !== "complete" || storage.completedAt !== null || storage.dismissed) return; + setStorage({ ...storage, completedAt: new Date().toISOString() }); + }, [decision, storage, setStorage]); + + const skip = useCallback(() => { + setOpen(false); + if (storage.completedAt === null) setStorage({ ...storage, dismissed: true }); + }, [setStorage, storage]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + setOpen(true); + return; + } + skip(); + }, + [skip], + ); + + const openAllProviders = useCallback(() => { + setOpen(false); + if (storage.completedAt === null && !storage.dismissed) { + setStorage({ ...storage, dismissed: true }); + } + void navigate({ to: "/settings", search: { section: "providers" } }); + }, [navigate, setStorage, storage]); + + if (!open) return null; + const anyConnected = storage.completedAt !== null || decision === "complete"; + + return ( + + {/* "Skip for now" is the close affordance; a popup X would duplicate it. */} + +
+ + Connect your accounts + + Sign in with the AI accounts you already have. Scient opens each provider’s + official browser sign-in — no API keys, and your credentials stay with the provider. + + + +
    + {ONBOARDING_FEATURED_PROVIDERS.map((provider) => { + const status = providers?.find((entry) => entry.provider === provider); + const stateKind = onboardingProviderState(status); + const Icon = PROVIDER_ICON_COMPONENT_BY_PROVIDER[provider]; + const label = PROVIDER_DISPLAY_NAMES[provider] ?? provider; + return ( +
  • + + + + + {label} + + {STATE_LABELS[stateKind]} + + + {stateKind === "connected" ? ( + + ) : ( + + )} +
  • + ); + })} +
+ + + + {anyConnected ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 14df1e47b..bf4849d8d 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -105,6 +105,7 @@ function installNativeApi(overrides: { describe("ProviderConnectionDialog", () => { afterEach(() => { useProviderConnectionDialogStore.getState().setOpen(false); + useProviderConnectionDialogStore.getState().clearConnectChain(); document.documentElement.style.removeProperty("--app-font-size-ui"); document.body.innerHTML = ""; vi.restoreAllMocks(); @@ -1074,7 +1075,7 @@ describe("ProviderConnectionDialog", () => { } }); - it("requires explicit consent before installing the trusted latest release", async () => { + it("downloads the trusted latest release and keeps it cancellable after one Connect click", async () => { const initialProvider = { provider: "antigravity", status: "error", @@ -1144,11 +1145,10 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Install Antigravity" }).click(); - 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 expect + .element(page.getByText("download a verified, private copy", { exact: false })) + .toBeVisible(); + await page.getByRole("button", { name: "Connect Antigravity" }).click(); await vi.waitFor(() => { expect(installProvider).toHaveBeenCalledWith({ provider: "antigravity", @@ -1157,6 +1157,98 @@ describe("ProviderConnectionDialog", () => { }); await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); await expect.element(page.getByRole("button", { name: "Cancel installation" })).toBeVisible(); + expect(useProviderConnectionDialogStore.getState().connectChain?.provider).toBe( + "antigravity", + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("starts the browser sign-in automatically once a one-click install lands", async () => { + const initialProvider = { + 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.", + }, + } satisfies ServerProviderStatus; + const installingProvider = { + ...initialProvider, + installationState: { + operationId: "install-antigravity-2", + operation: "install", + status: "downloading", + startedAt: checkedAt, + finishedAt: null, + message: "Downloading Antigravity 1.1.5.", + version: "1.1.5", + bytesDownloaded: 0, + totalBytes: 46_664_998, + }, + } satisfies ServerProviderStatus; + const installedProvider = { + ...initialProvider, + installationState: { + ...installingProvider.installationState, + status: "installed", + finishedAt: "2026-07-19T12:05:00.000Z", + message: "Antigravity 1.1.5 is installed and verified.", + }, + } satisfies ServerProviderStatus; + const prepareProviderInstall = vi.fn().mockResolvedValue({ + provider: "antigravity", + planToken: "trusted-plan-2", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: 46_664_998, + expiresAt: "2026-07-19T12:10:00.000Z", + }); + const installProvider = vi.fn().mockResolvedValue({ providers: [installingProvider] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [installedProvider] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, + startProviderConnection, + }); + const queryClient = createQueryClient(initialProvider); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Connect Antigravity" }).click(); + await vi.waitFor(() => { + expect(installProvider).toHaveBeenCalled(); + }); + await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); + + applyProviderStatusesToCache(queryClient, [installedProvider]); + await vi.waitFor(() => { + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "antigravity", + method: "antigravity_browser", + }); + }); + expect(useProviderConnectionDialogStore.getState().connectChain).toBeNull(); } finally { await screen.unmount(); queryClient.clear(); @@ -1279,11 +1371,11 @@ describe("ProviderConnectionDialog", () => { ); try { - await expect.element(page.getByRole("button", { name: "Install Antigravity" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Connect Antigravity" })).toBeVisible(); applyProviderStatusesToCache(queryClient, [ { ...antigravity, checkedAt: "2026-07-20T16:05:00.000Z" }, ]); - await expect.element(page.getByRole("button", { name: "Install Antigravity" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Connect Antigravity" })).toBeVisible(); await expect .element(page.getByRole("button", { name: "Open installation guide" })) .not.toBeInTheDocument(); diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 711a8a6d3..9a2e44613 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from "react"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, + decideConnectChainStep, describeProviderConnection, describeManagedProviderUpdate, providerConnectionMethod, @@ -45,7 +46,8 @@ function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number } export function ProviderConnectionDialog() { - const { isOpen, provider, source, setOpen } = useProviderConnectionDialogStore(); + const { isOpen, provider, source, setOpen, connectChain, beginConnectChain, clearConnectChain } = + useProviderConnectionDialogStore(); const configQuery = useQuery({ ...serverConfigQueryOptions(), enabled: isOpen }); const queryClient = useQueryClient(); const [actionPending, setActionPending] = useState(false); @@ -76,6 +78,7 @@ export function ProviderConnectionDialog() { const connectionPresentation = provider ? describeProviderConnection(provider, status, { forceReconnect: runtimeReconnectRequired, + connectChainActive: connectChain?.provider === provider, }) : null; const managedUpdateFlow = @@ -156,6 +159,47 @@ export function ProviderConnectionDialog() { if (!isOpen) setAuthorizationCode(""); }, [isOpen]); + // The native Codex OAuth flow has no CLI process to open the browser, so the + // client opens the published authorization URL exactly once per operation. + const autoOpenedAuthorizationOperationRef = useRef(null); + useEffect(() => { + if (provider !== "codex") return; + const authorizationUrl = activeConnection?.authorizationUrl; + const operationId = activeConnection?.operationId; + if (!authorizationUrl || !operationId) return; + if (autoOpenedAuthorizationOperationRef.current === operationId) return; + autoOpenedAuthorizationOperationRef.current = operationId; + void ensureNativeApi() + .shell.openExternal(authorizationUrl) + .catch(() => undefined); + }, [provider, activeConnection?.authorizationUrl, activeConnection?.operationId]); + + // One-click connect: once the managed install completes, start the + // provider's browser sign-in automatically. Driven by streamed provider + // statuses, so it keeps working while this dialog is closed. + const chainStatus = connectChain + ? configQuery.data?.providers.find((entry) => entry.provider === connectChain.provider) + : undefined; + useEffect(() => { + if (!connectChain) return; + const decision = decideConnectChainStep(chainStatus); + if (decision === "wait") return; + clearConnectChain(connectChain.token); + if (decision !== "start_sign_in") return; + const method = providerConnectionMethod(connectChain.provider); + if (!method) return; + void ensureNativeApi() + .server.startProviderConnection({ provider: connectChain.provider, method }) + .then((result) => { + applyProviderStatusesToCache(queryClient, result.providers); + }) + .catch((error) => { + setActionError( + error instanceof Error ? error.message : "The provider sign-in could not be started.", + ); + }); + }, [connectChain, chainStatus, clearConnectChain, queryClient]); + if (!provider || !presentation || !Icon) return null; const startsProviderSignIn = status?.available === true && providerConnectionMethod(provider) !== null; @@ -312,8 +356,41 @@ export function ProviderConnectionDialog() { applyProviderStatusesToCache(queryClient, result.providers); }); + // One-click connect: plan, download, and install in a single action, then + // let the chain watcher start the browser sign-in once the CLI is verified. + // Codex signs in natively without a runtime, so its browser opens + // immediately while the managed install runs in parallel. + const connect = () => + runAction(async () => { + const api = ensureNativeApi(); + if (provider === "codex") { + const started = await api.server.startProviderConnection({ + provider, + method: "codex_browser", + }); + applyProviderStatusesToCache(queryClient, started.providers); + const plan = await api.server.prepareProviderInstall({ provider }); + const result = await api.server.installProvider({ + provider, + planToken: plan.planToken, + }); + applyProviderStatusesToCache(queryClient, result.providers); + return; + } + const plan = await api.server.prepareProviderInstall({ provider }); + setInstallPlan(plan); + const result = await api.server.installProvider({ + provider, + planToken: plan.planToken, + }); + setInstallPlan(null); + applyProviderStatusesToCache(queryClient, result.providers); + beginConnectChain(provider); + }); + const cancelInstall = () => { const operationId = status?.installationState?.operationId; + if (connectChain?.provider === provider) clearConnectChain(connectChain.token); if (!operationId) return Promise.resolve(); return runAction(async () => { const result = await ensureNativeApi().server.cancelProviderInstall({ @@ -328,6 +405,8 @@ export function ProviderConnectionDialog() { switch (presentation.primaryAction) { case "install": return install(); + case "connect": + return connect(); case "sign_in": return startSignIn(); case "check_again": @@ -394,7 +473,7 @@ export function ProviderConnectionDialog() { role="group" aria-label="Sign-in progress actions" > - {(provider === "grok" || provider === "antigravity") && + {(provider === "codex" || provider === "grok" || provider === "antigravity") && activeConnection.authorizationUrl ? (