diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f168171a..11aadf2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ file is the human-readable summary of what changed and why. ### Desktop +- **Login with SSO no longer fails with a timeout behind a corporate proxy** — + on networks where Chromium's proxy setup (PAC download, WPAD discovery) takes + a long time, a sign-in that reused the saved session could fail straight + away with "Setup failed: Error invoking remote method + 'gateway-sso-fetch-setup': TimeoutError". The very first request to the + CoDev backend sat in Chromium's queue behind that proxy setup, and its + ten-second limit ran out three times before the setup had finished. The app + now waits for the proxy setup to finish before it sends the first request + and shows "Waiting for the system proxy configuration..." while it does. The + same timeout also hit machines with a working internet connection, where a + leftover proxy or PAC setting kept the app window's network stack from + reaching the backend while the local server, which uses a different one, + was fine: a request that times out on the first stack is now retried over + the local server's, and later requests follow it. The setup screen shows the + request in progress and any retry instead of a bare "Setting up..." line. - **Sign-in completes behind a corporate proxy** — on Windows machines that reach the internet through a corporate proxy, **Login with SSO** worked all the way to choosing a model and then landed back on **Set up CoDev**, with diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 213b1be42..b3273f7f5 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -224,7 +224,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` and `skillhub?: SkillHubPlatform` capabilities (skillhub wire types in fork-only `src/skillhub/types.ts`) (wire types in fork-only `src/gateway/types.ts`, exported as `@opencode-ai/app/gateway/types` — see the `packages/app/package.json` row). | Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. | Keep the field + import; take upstream's surrounding platform changes. | | `packages/app/src/components/prompt-input-v2.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy `prompt-input.tsx` carried the same swap until its deletion — see the legacy prompt-input removal row.) | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. | | `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | -| Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | +| Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal`, a `net.fetch` wrapper, and a `warmProxy` hook — `session.defaultSession.resolveProxy(BACKEND_URL)`, which the controller awaits before its first timed gateway request so Chromium's proxy initialization is not charged against a request's 10 s budget — and a `fallbackFetchImpl`, Node's global `fetch` (env proxy from `useEnvProxy`, system roots from `setDefaultCACertificates`: the sidecar's own stack), which the controller's retry loop switches to for the rest of the process after a request over `net.fetch` times out, since a stale proxy/PAC setting can wedge Chromium's stack on a machine whose network is otherwise fine) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | | Desktop Skill Hub wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/desktop/package.json`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts` | Main registers the fork-only `src/main/skillhub/` controller (over the fork-only `@codev/skillhub` package) and its `skillhub-*` IPC channels; preload exposes the `skillhub` namespace (types aliased from `@opencode-ai/app/skillhub/types`); renderer maps `skillhub: window.api.skillhub` into the platform; `@codev/skillhub` added under desktop devDependencies (electron-vite bundles devDependency workspace packages); app.tsx registers lazy `/skills` + `/skills/:id` routes inside the new-layout ``; i18n adds `sidebar.skillhub` + the `skillhub.*` family (vi.ts is fork-only). | The Skill Hub page is native (fork-only `packages/app/src/{skillhub/**,pages/skill-hub*.tsx,components/skill-hub-nav.tsx}`); all hub HTTP must originate in the Electron main process because the hub serves no ACAO on /api/v1 and 403s cross-origin writes, so the renderer reaches it only over IPC. | Keep the registration/preload/renderer/package.json lines, the two route lines, and the i18n keys; take upstream's surrounding changes. | | `packages/app/vite.js` | Added `optimizeDeps.entries`: the default `index.html` plus session-ui's `markdown.worker.ts` (absolute path derived from the plugin file, forward-slashed for fast-glob), so the dev-time dep scanner crawls the markdown worker at startup. | Vite's dep scanner does not crawl `?worker&url` entries, so the worker's deps (`@shikijs/stream`, `katex`, `marked`, `marked-shiki`, `remend`, `shiki`) were first discovered when the first markdown rendered; that mid-session re-optimize full-page reloads the dev app — e.g. a Skill Hub search reset itself the moment results rendered. A bare `optimizeDeps.include` list cannot express this fix: with bun's isolated linker the deps live only in the workspace packages' own `node_modules`, and no single include chain resolves from both consumers' roots (`packages/app` and the desktop renderer's `src/renderer`). Dev-only; production builds ignore `optimizeDeps`. | Keep the `optimizeDeps` block and keep `index.html` listed in it — setting `entries` replaces Vite's default entry inference, so dropping it unscans the app itself. If upstream moves or renames the worker file, update the path: a stale pattern matches nothing, logs nothing, and silently brings the reload back. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"`, `"./skillhub/types": "./src/skillhub/types.ts"`, and `"./i18n/*": "./src/i18n/*.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern); the desktop main process imports the locale dicts to translate the macOS menu bar (`./i18n/*` mirrors how `@opencode-ai/ui` already exports its own — see the localized desktop menu row). | Keep both exports; take upstream's other manifest changes. | diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index bbf4ea347..d33d9d49c 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -300,7 +300,16 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) { -

{(step() as Extract).text}

+
+

{(step() as Extract).text}

+ {/* The controller's own progress line: which request is running, + a retry in progress, or the wait for the system proxy + configuration — the reason a corporate-network sign-in sits + here for a while. */} + +

{ssoState().status}

+
+
diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index ab74c486c..0992d2b1f 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -25,6 +25,7 @@ export { type AnalysisBackendSession, type ModelWindow, } from "./backend" +export { BACKEND_URL } from "./const" export { cacheModelLimits, limitsFor, type ModelLimits } from "./model-limits" export { ensureGatewayKey, type EnsureKeyFns, type EnsureKeyOptions, type GatewayKeyCheck } from "./ensure-key" export { buildCustomProviderConfig, buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block" diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index 4041c8a93..4907db31a 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -117,7 +117,11 @@ describe("gateway sso controller", () => { }) test("the host fetch reaches the silent refresh and the login exchange", async () => { - const fetchImpl: typeof fetch = (async (_input: string | URL | Request) => new Response("")) as typeof fetch + let hostCalls = 0 + const fetchImpl: typeof fetch = (async (_input: string | URL | Request) => { + hostCalls++ + return new Response("") + }) as typeof fetch let silentFetch: typeof fetch | undefined let loginFetch: typeof fetch | undefined const controller = createGatewaySsoController({ @@ -135,8 +139,11 @@ describe("gateway sso controller", () => { }, }) await controller.startLogin() - expect(silentFetch).toBe(fetchImpl) - expect(loginFetch).toBe(fetchImpl) + // Both receive the controller's stack dispatcher, which sends to the host + // fetch until a timeout switches it to the fallback stack. + await silentFetch?.("https://sso.example.test/token") + await loginFetch?.("https://sso.example.test/token") + expect(hostCalls).toBe(2) }) test("fetchSetup retries and reports the manual-key path", async () => { @@ -196,6 +203,138 @@ describe("gateway sso controller", () => { expect(controller.fetchSetup()).rejects.toThrow("backend down") }) + // Chromium queues every request behind its proxy initialization, which on + // corporate networks outlasts a request's own timeout: the wait has to land + // before the first timed request, and only once per process. + test("gateway requests wait for the proxy warm-up before the first request, once", async () => { + const gate = deferred() + let warms = 0 + let logins = 0 + const controller = createGatewaySsoController({ + openUrl: async () => {}, + warmProxy: () => { + warms++ + return gate.promise + }, + gateway: { + silentSso: async () => { + logins++ + return auth + }, + fetchCodevConfig: async () => ({ + supabaseUrl: "s", + supabaseAnonKey: "k", + gatewayUrl: "https://gw.example.test", + }), + fetchApiKey: async () => "sk-live", + fetchModels: async () => ["model-a"], + fetchModelWindows: async () => ({}), + cacheModelLimits: () => {}, + saveCodevConfig: () => {}, + }, + }) + + const run = controller.startLogin() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(logins).toBe(0) + expect(controller.getState().status).toBe("Waiting for the system proxy configuration...") + gate.resolve() + await run + expect(logins).toBe(1) + expect(controller.getState().phase).toBe("ready") + + await controller.fetchSetup() + await controller.fetchModels() + expect(warms).toBe(1) + }) + + // A stale proxy or PAC setting can wedge Chromium's stack on a machine whose + // network is otherwise fine; Node's stack (the local server's) is the way + // around it, and once a request needed it every later request takes it. + test("a request that times out over Chromium's stack is retried over Node's, and the switch sticks", async () => { + const stacks: string[] = [] + const timeout = () => new DOMException("The operation was aborted due to timeout", "TimeoutError") + const controller = createGatewaySsoController({ + openUrl: async () => {}, + fetchImpl: async () => { + stacks.push("chromium") + throw timeout() + }, + fallbackFetchImpl: async () => { + stacks.push("node") + return new Response("{}") + }, + gateway: { + silentSso: async () => auth, + fetchCodevConfig: async (_token, opts) => { + await opts?.fetchImpl?.("https://backend.example.test/config") + return { supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" } + }, + fetchApiKey: async (_token, opts) => { + await opts?.fetchImpl?.("https://backend.example.test/auth/exchange") + return "sk-live" + }, + fetchModels: async (_key, _url, opts) => { + await opts?.fetchImpl?.("https://gw.example.test/v1/models") + return ["model-a"] + }, + fetchModelWindows: async () => ({}), + cacheModelLimits: () => {}, + saveCodevConfig: () => {}, + }, + }) + + await controller.startLogin() + const setup = await controller.fetchSetup() + expect(setup.hasApiKey).toBe(true) + await controller.fetchModels() + // One Chromium timeout, then every request over Node's stack. + expect(stacks).toEqual(["chromium", "node", "node", "node"]) + }) + + test("a timeout with no fallback stack still counts against the retries", async () => { + let calls = 0 + const controller = createGatewaySsoController({ + openUrl: async () => {}, + gateway: { + silentSso: async () => auth, + fetchCodevConfig: async () => { + calls++ + throw new DOMException("The operation was aborted due to timeout", "TimeoutError") + }, + }, + }) + + await controller.startLogin() + expect(controller.fetchSetup()).rejects.toThrow("aborted due to timeout") + await controller.fetchSetup().catch(() => {}) + expect(calls).toBe(6) + }) + + test("a failed proxy warm-up does not block the requests", async () => { + const controller = createGatewaySsoController({ + openUrl: async () => {}, + warmProxy: async () => { + throw new Error("proxy resolution failed") + }, + gateway: { + silentSso: async () => auth, + fetchCodevConfig: async () => ({ + supabaseUrl: "s", + supabaseAnonKey: "k", + gatewayUrl: "https://gw.example.test", + }), + fetchApiKey: async () => "sk-live", + saveCodevConfig: () => {}, + }, + }) + + await controller.startLogin() + const setup = await controller.fetchSetup() + expect(setup.hasApiKey).toBe(true) + expect(controller.getState().status).toBe("") + }) + test("complete writes the auth file and returns the key beside a keyless aigw config", async () => { const savedKeys: unknown[] = [] const cachedWindows: unknown[] = [] diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index d2f4ff919..889d8b53f 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -41,11 +41,30 @@ const KEY_CHECK_TIMEOUT_MS = 2_500 // getAccount refetches on every state event. const ACCOUNT_PROBE_TIMEOUT_MS = 2_500 const ACCOUNT_PROBE_TTL_MS = 60_000 +// Chromium holds every request — the main process's net.fetch included — +// until its proxy initialization (PAC download, WPAD discovery) has finished, +// which on corporate networks runs well past the 10 s the gateway functions +// give one request. With an SSO session already on disk nothing had absorbed +// that wait by the time the setup fetch fired, so all three attempts timed +// out on what was really the proxy warm-up. The wait now happens before the +// request clock starts. Bounded so a resolution that never settles cannot +// wedge the dialog — the request then fails on its own terms. +const PROXY_WARMUP_TIMEOUT_MS = 60_000 type Deps = { openUrl: (url: string) => Promise // Electron's net.fetch so enterprise proxies and PAC files apply. fetchImpl?: typeof fetch + // Node's fetch — the stack the local server sends every model call over, + // with the env proxy and the system roots applied at startup. Tried when a + // request over fetchImpl times out: a stale proxy or PAC setting wedges + // Chromium's stack even where the network itself is fine, and this stack + // works wherever chat works. The switch sticks for the rest of the process. + fallbackFetchImpl?: typeof fetch + // Settles once the network stack behind fetchImpl is ready to send — the + // desktop passes session.resolveProxy, which waits out Chromium's proxy + // initialization. Awaited before the first timed gateway request. + warmProxy?: () => Promise // Called once the gateway credentials are on disk, for the parts of the app // that read them somewhere other than the local server's config — the // sandboxes, which inject a guest config from them at boot and so cannot see @@ -98,7 +117,12 @@ export function createGatewaySsoController(deps: Deps) { clearSkillhubCookie, ...deps.gateway, } - const fetchOpts = { fetchImpl: deps.fetchImpl } + let fallback = false + const dispatch: typeof fetch = (input, init) => { + const impl = (fallback && deps.fallbackFetchImpl) || deps.fetchImpl || fetch + return impl(input, init) + } + const fetchOpts = { fetchImpl: dispatch } const listeners = new Set<(state: GatewaySsoState) => void>() let state: GatewaySsoState = { phase: "idle", status: "" } @@ -137,6 +161,28 @@ export function createGatewaySsoController(deps: Deps) { const cancelled = () => new Error("login cancelled") + // Memoized on success: proxy initialization happens once per process. A + // warm-up that failed or timed out is retried by the next request. + let proxyWarm: Promise | undefined + let proxyReady = false + const awaitProxy = async () => { + if (!deps.warmProxy || proxyReady) return + setState({ status: "Waiting for the system proxy configuration..." }) + proxyWarm ??= deps.warmProxy().then( + () => true, + () => false, + ) + let timer: ReturnType | undefined + proxyReady = await Promise.race([ + proxyWarm, + new Promise((resolve) => { + timer = setTimeout(resolve, PROXY_WARMUP_TIMEOUT_MS, false) + }), + ]) + clearTimeout(timer) + if (!proxyReady) proxyWarm = undefined + } + const startLogin = async () => { // Single-flight: a second window attaches to the in-flight attempt instead // of racing a second loopback server. @@ -149,6 +195,7 @@ export function createGatewaySsoController(deps: Deps) { loginAbort = abort setState({ phase: "signing-in", status: "Starting SSO login...", authUrl: undefined, error: undefined }) const run = (async () => { + await awaitProxy() const cached = await gateway.silentSso(fetchOpts) if (cached) return cached return gateway.login({ @@ -162,7 +209,7 @@ export function createGatewaySsoController(deps: Deps) { openBrowser() }, openUrl: deps.openUrl, - fetchImpl: deps.fetchImpl, + fetchImpl: dispatch, signal: abort.signal, }) })() @@ -198,7 +245,16 @@ export function createGatewaySsoController(deps: Deps) { // A refusal is a policy answer, not a flaky network: the same request // gets the same answer, so spending the budget on it only delays the // renderer's fallback. - if (attempt >= RETRIES || isKeyRefusal(error)) throw error + if (isKeyRefusal(error)) throw error + // A timeout over Chromium's stack gets one more attempt over Node's, + // whatever the count: the request may have been a victim of that + // stack's proxy state rather than of the network. + if (!fallback && deps.fallbackFetchImpl && error instanceof Error && error.name === "TimeoutError") { + fallback = true + setState({ status: `${label} timed out — retrying over the local server's network stack...` }) + continue + } + if (attempt >= RETRIES) throw error setState({ status: `${label} failed — retrying (${attempt}/${RETRIES})...` }) } } @@ -269,7 +325,9 @@ export function createGatewaySsoController(deps: Deps) { async fetchSetup(): Promise { const auth = session if (!auth) throw new Error("not signed in") - setState({ phase: "fetching-setup", status: "Fetching gateway configuration..." }) + setState({ phase: "fetching-setup", status: "" }) + await awaitProxy() + setState({ status: "Fetching gateway configuration..." }) const config = await withRetries("Fetching gateway configuration", () => gateway.fetchCodevConfig(auth.access_token, fetchOpts), ) @@ -314,7 +372,7 @@ export function createGatewaySsoController(deps: Deps) { // the rest through, needs-login reason included: the renderer's gate // prompts for a rejected key only, like the TUI. const result = await ensureGatewayKey({ - fetchImpl: deps.fetchImpl, + fetchImpl: dispatch, timeoutMs: KEY_CHECK_TIMEOUT_MS, fns: gateway, }) @@ -337,6 +395,7 @@ export function createGatewaySsoController(deps: Deps) { if (!apiKey || !gatewayUrl) throw new Error("gateway setup incomplete") const key = apiKey const url = gatewayUrl + await awaitProxy() setState({ status: "Fetching models..." }) models = await withRetries("Fetching models", () => gateway.fetchModels(key, url, fetchOpts)) // Gateway-reported windows override the static table in the config block diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c4ad5267f..b82d30e9e 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,6 +7,7 @@ import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" import { app, BrowserWindow, net, Notification, session, shell } from "electron" +import { BACKEND_URL } from "@codev/gateway" import { shouldNudgeSignIn } from "@codev/upload/nudge" import type { ServerSource as UploadServerSource } from "@codev/upload" @@ -426,6 +427,23 @@ const main = Effect.gen(function* () { // functions only pass string URLs and JSON bodies. fetchImpl: (input, init) => net.fetch(input instanceof URL ? input.toString() : input, init as Parameters[1]), + // Settles once Chromium has finished its proxy initialization (PAC + // download / WPAD discovery), which on corporate networks outlasts the + // 10 s a gateway request is allowed; the controller waits here before + // its first request instead of timing out on that wait. Logged so a + // report from such a network says which proxy the backend went through. + warmProxy: () => + session.defaultSession.resolveProxy(BACKEND_URL).then((proxy) => { + logger.log("gateway proxy resolved", { proxy }) + }), + // Node's fetch, the stack the sidecar sends every model call over (env + // proxy from useEnvProxy, system roots from setDefaultCACertificates), + // for a request Chromium's stack timed out on. Logged per request: a + // report that shows this line names the failing stack. + fallbackFetchImpl: (input, init) => { + logger.log("gateway request over Node's network stack", { url: String(input) }) + return fetch(input, init) + }, // A sandbox guest is handed its codev config once, at boot, with the // file watcher off, so credentials that appear after it started never // reach it: signing in left Work with no provider at all while Code had