Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Show>`; 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. |
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/gateway/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,16 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) {
</Match>

<Match when={step().kind === "busy"}>
<p class="text-14-regular text-text-base">{(step() as Extract<Step, { kind: "busy" }>).text}</p>
<div class="flex flex-col gap-1">
<p class="text-14-regular text-text-base">{(step() as Extract<Step, { kind: "busy" }>).text}</p>
{/* 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. */}
<Show when={ssoState().status}>
<p class="text-12-regular text-text-weak">{ssoState().status}</p>
</Show>
</div>
</Match>
</Switch>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
145 changes: 142 additions & 3 deletions packages/desktop/src/main/gateway-sso/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<void>()
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[] = []
Expand Down
Loading
Loading