From 8de47b6d4e2b1841864f3e47cc0be4c1557d9bc8 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 11 Sep 2026 10:53:29 +0700 Subject: [PATCH 1/2] fix(desktop): wait for Chromium's proxy setup before the SSO setup requests On corporate networks Chromium queues every request, the main process's net.fetch included, behind its proxy initialization (PAC download / WPAD discovery), which can take tens of seconds. The gateway SSO controller's first request to the CoDev backend carried a 10 s AbortSignal.timeout that started ticking inside that queue, so with a saved SSO session (no browser round-trip to absorb the wait) fetchSetup timed out three times and the setup dialog failed with "Error invoking remote method 'gateway-sso-fetch-setup': TimeoutError". - The controller takes a warmProxy hook and awaits it, once per process and bounded to 60 s, before its first timed gateway request (startLogin, fetchSetup, fetchModels), showing "Waiting for the system proxy configuration..." meanwhile. A failed warm-up never blocks the request. - The desktop passes session.defaultSession.resolveProxy(BACKEND_URL), which settles once proxy initialization is done, and logs the resolved proxy for the next report from such a network. - The setup dialog's busy step shows the controller's status line, so the proxy wait and retries are visible instead of a bare "Setting up...". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NKYSegP3wRxtnv15S2S9Cj --- CHANGELOG.md | 11 +++ DIVERGENCES.md | 2 +- .../components/gateway/dialog-configure.tsx | 11 ++- packages/codev-gateway/src/index.ts | 1 + .../src/main/gateway-sso/controller.test.ts | 69 +++++++++++++++++++ .../src/main/gateway-sso/controller.ts | 41 ++++++++++- packages/desktop/src/main/index.ts | 10 +++ 7 files changed, 142 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f168171a..33ff182f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ 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, + shows "Waiting for the system proxy configuration..." while it does, and 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..ca72f9207 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 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..141ab8982 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -196,6 +196,75 @@ 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) + }) + + 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..c7b256441 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -41,11 +41,24 @@ 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 + // 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 @@ -137,6 +150,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 +184,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({ @@ -269,7 +305,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), ) @@ -337,6 +375,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..42a8799f2 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,15 @@ 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 }) + }), // 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 From 55f8e625d2a69e60fc9c3956b37343031632e8eb Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 11 Sep 2026 11:45:13 +0700 Subject: [PATCH 2/2] fix(desktop): retry a gateway request over Node's stack after a Chromium timeout The same TimeoutError hit machines with a working internet connection, not only ones behind a corporate proxy, so the proxy warm-up alone cannot be the whole fix. Chromium's stack can be wedged by a leftover proxy or PAC setting on a machine whose network is otherwise fine, while the sidecar's model traffic over Node's stack (env proxy via useEnvProxy, system roots) works. The controller now routes every gateway request through a stack dispatcher. A request over net.fetch that times out gets one more attempt over Node's fetch, whatever the retry count, and the switch sticks for the rest of the process. The desktop logs each request that takes the fallback so a debug export names the failing stack. Verified in Electron's main process that Node's fetch honors setGlobalProxyFromEnv (CONNECT reaches the env proxy). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NKYSegP3wRxtnv15S2S9Cj --- CHANGELOG.md | 12 ++- DIVERGENCES.md | 2 +- .../src/main/gateway-sso/controller.test.ts | 76 ++++++++++++++++++- .../src/main/gateway-sso/controller.ts | 28 ++++++- packages/desktop/src/main/index.ts | 8 ++ 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ff182f6..11aadf2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,14 @@ file is the human-readable summary of what changed and why. '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, - shows "Waiting for the system proxy configuration..." while it does, and the - setup screen shows the request in progress and any retry instead of a - bare "Setting up..." line. + 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 ca72f9207..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`, 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 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/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index 141ab8982..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 () => { @@ -241,6 +248,69 @@ describe("gateway sso controller", () => { 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 () => {}, diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index c7b256441..889d8b53f 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -55,6 +55,12 @@ 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. @@ -111,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: "" } @@ -198,7 +209,7 @@ export function createGatewaySsoController(deps: Deps) { openBrowser() }, openUrl: deps.openUrl, - fetchImpl: deps.fetchImpl, + fetchImpl: dispatch, signal: abort.signal, }) })() @@ -234,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})...` }) } } @@ -352,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, }) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 42a8799f2..b82d30e9e 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -436,6 +436,14 @@ const main = Effect.gen(function* () { 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