From 62a0e9dac45d8f774ba2eee499c472ed726784ad Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 14 Aug 2026 17:01:58 +0700 Subject: [PATCH 1/2] feat(desktop): SSO configure gate and hidden hosted providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the CLI's first-run experience to the desktop app: - Hide upstream's hosted providers (opencode Zen free models, opencode-go) from the entire app UI via a client-side filter at the provider normalization choke point. The connected list keeps the TUI's isConnectedProvider semantics so a deliberately configured paid Zen key still counts as configured. With nothing configured, no models are offered; the composer's unpaid state opens the connect dialog instead of the now-empty free-models lists. - Force a setup dialog when the local server has no configured provider (desktop only, via a new platform.gatewaySso capability): Login with SSO, Custom provider, or Connect provider — a stateless invariant reopens it if any sub-flow is escaped, and it closes reactively once a provider appears (including setup finished in another window or the CLI). - SSO runs in the Electron main process (gateway-sso controller wrapping @codev/gateway's loopback OAuth): single-flight across windows, generation-counter cancellation, paste-callback fallback, manual API-key path, 3x retries, shell.openExternal, and net.fetch so enterprise proxies/PAC apply (fetchImpl added to the gateway backend). - Completion mirrors the TUI: saveApiKey to the shared auth file, raw global config PATCH with buildGatewayConfig, dispose, wait for the netgate provider, seed model recents. - packages/ui dialog context gains a per-dialog persistent flag (Escape/ backdrop inert; programmatic close and pushed sub-dialogs unaffected). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 + DIVERGENCES.md | 8 + packages/app/package.json | 1 + packages/app/src/app.tsx | 9 +- .../components/gateway/dialog-configure.tsx | 314 ++++++++++++++++++ .../gateway/gateway-configure-gate.tsx | 64 ++++ .../app/src/components/prompt-input-v2.tsx | 12 +- packages/app/src/components/prompt-input.tsx | 10 +- packages/app/src/context/global-sync/utils.ts | 5 +- packages/app/src/context/platform.tsx | 4 + .../app/src/gateway/configure-gate.test.ts | 28 ++ packages/app/src/gateway/configure-gate.ts | 16 + .../app/src/gateway/hidden-providers.test.ts | 54 +++ packages/app/src/gateway/hidden-providers.ts | 26 ++ packages/app/src/gateway/types.ts | 59 ++++ packages/app/src/i18n/en.ts | 27 +- packages/app/src/i18n/es.ts | 27 +- packages/codev-gateway/src/backend.ts | 24 +- .../src/main/gateway-sso/controller.test.ts | 167 ++++++++++ .../src/main/gateway-sso/controller.ts | 190 +++++++++++ packages/desktop/src/main/gateway-sso/ipc.ts | 54 +++ packages/desktop/src/main/index.ts | 14 +- packages/desktop/src/preload/index.ts | 21 +- packages/desktop/src/preload/types.ts | 8 + packages/desktop/src/renderer/index.tsx | 2 + packages/ui/src/context/dialog.tsx | 33 +- 26 files changed, 1166 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/components/gateway/dialog-configure.tsx create mode 100644 packages/app/src/components/gateway/gateway-configure-gate.tsx create mode 100644 packages/app/src/gateway/configure-gate.test.ts create mode 100644 packages/app/src/gateway/configure-gate.ts create mode 100644 packages/app/src/gateway/hidden-providers.test.ts create mode 100644 packages/app/src/gateway/hidden-providers.ts create mode 100644 packages/app/src/gateway/types.ts create mode 100644 packages/desktop/src/main/gateway-sso/controller.test.ts create mode 100644 packages/desktop/src/main/gateway-sso/controller.ts create mode 100644 packages/desktop/src/main/gateway-sso/ipc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da64a69a5b..d605891dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **First-run setup on the desktop app** — with no AI provider configured, the + desktop app now opens a setup dialog that cannot be dismissed until a + provider exists, offering the same three paths as the CLI's `codev + configure`: **Login with SSO** (sign in with your CoDev account in the + browser — with a paste-the-link fallback for machines the browser cannot + reach — then pick a default model), **Custom provider** (an OpenAI-compatible + endpoint), or **Connect provider** (Anthropic, OpenAI, and others). The gate + applies to the local server only; escaping any sub-flow returns to it, and + finishing setup in another window (or in the CLI) closes it automatically. +- **Upstream's hosted providers are no longer offered** — the OpenCode Zen + free models and OpenCode Go no longer appear anywhere in the desktop or web + UI (model pickers, connect dialog, settings), and a fresh install no longer + silently chats against them: with nothing configured there is now no model + to pick until a provider is set up. - **Selecting a folder no longer lands on "This session cannot be found"** — the app remembers the session you were last in for each folder of a sandbox, and it kept sending you there after the session itself was gone: resetting a diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 038e8c8c63..620ba65f02 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -193,7 +193,15 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | | `packages/app/src/context/command.tsx` | The DEV duplicate-command-id warning fires only for duplicates still present 1 s later (`warnIfPersistentDuplicate`); runtime keep-first behavior unchanged. | Router transitions keep the outgoing page mounted while the incoming one registers, so ids both pages provide (`command.palette`, `input.focus`) are briefly duplicated on every draft→session navigation; only a duplicate that survives the transition is a registration bug. | Keep ours; take upstream's surrounding command changes. | | `packages/desktop/src/main/logging.ts` | Added `initChromiumNoiseFilter`: an electron-log hook dropping two benign Chromium intervention messages (ResizeObserver loop, autofocus blocked) from the console transport for renderer-spied messages; file logs keep them. | The `spyRendererConsole` pipe surfaces Chromium interventions the app can neither cause nor fix as red terminal lines during `bun dev:desktop`. | Keep the hook; take upstream's surrounding logging changes. | +| `packages/app/src/context/global-sync/utils.ts` | `normalizeProviderList` returns through `filterHiddenProviders` (fork-only `src/gateway/hidden-providers.ts`): upstream's hosted providers `opencode` (Zen) and `opencode-go` are dropped from `all`/`default`, and the unauthenticated free-tier `opencode` from `connected` (a deliberately configured paid Zen key still counts as connected, mirroring the TUI's `isConnectedProvider`). | Client-side hiding at the one choke point both the global bootstrap and per-directory catalogs flow through — mirrors the TUI's dialog filters without writing `disabled_providers`. Downstream surfaces keyed off those catalog entries (`dialog-select-model-unpaid{,-v2}.tsx`, the `usage-exceeded-dialogs.tsx` Go upsell, Zen branches in `dialog-connect-provider.tsx`, hosted entries in `use-providers.ts` `popularProviders`, settings badges) become deliberately-dead code left byte-identical to upstream so merges stay cheap. | Keep the import + wrap; take upstream's normalization changes. Do not "clean up" the dead hosted-provider surfaces — they cost nothing and would churn every sync. | +| `packages/ui/src/context/dialog.tsx` | Per-dialog `persistent` flag (precedent: the TUI's persistent dialog, see the `packages/tui/src/ui/dialog.tsx` row): Escape, backdrop click, and Kobalte `onOpenChange` are no-ops for a dialog marked persistent; programmatic `close()` still works; `setPersistent` targets the top dialog so pushed sub-dialogs stay dismissable; `useDialog()` gains `persistent`/`setPersistent`. | The desktop configure gate needs a dialog the user cannot dismiss while no provider is configured. | Keep the flag and the three guards; take upstream's surrounding dialog changes. | +| `packages/app/src/app.tsx` | `` (fork-only `components/gateway/`) mounted as the first child inside `ServerScopedProviders`' `ModelsProvider`. | The desktop configure gate; the placement is load-bearing — completing the flow seeds the live `useModels()` recents store (`buildGatewayConfig` deliberately writes no `config.model` pin). | Keep the mount inside `ModelsProvider`; take upstream's surrounding shell changes. | +| `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` capability (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`, `packages/app/src/components/prompt-input.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. | 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; 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. | +| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. | +| `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | diff --git a/packages/app/package.json b/packages/app/package.json index d25fa3101e..f379c6d328 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,6 +9,7 @@ "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", "./sandbox/types": "./src/sandbox/types.ts", + "./gateway/types": "./src/gateway/types.ts", "./vite": "./vite.js", "./index.css": "./src/index.css" }, diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3852120092..fa961ef0fb 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -49,6 +49,7 @@ import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" +import { GatewayConfigureGate } from "@/components/gateway/gateway-configure-gate" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" import { usePlatform } from "@/context/platform" @@ -350,7 +351,13 @@ function ServerScopedProviders(props: ServerScopedShellProps) { return ( {props.serverScoped} - {props.children} + {/* Inside ModelsProvider: completing the configure flow seeds the live + models recents store, which is how the picked model becomes the + selection (buildGatewayConfig writes no config.model pin). */} + + + {props.children} + ) } diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx new file mode 100644 index 0000000000..006cea66e0 --- /dev/null +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -0,0 +1,314 @@ +import type { Config } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { useLanguage } from "@/context/language" +import { useModels } from "@/context/models" +import { usePlatform } from "@/context/platform" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import type { GatewaySsoState } from "@/gateway/types" +import { showToast } from "@/utils/toast" +import { DialogCustomProvider } from "@/components/dialog-custom-provider" + +const PROVIDER_WAIT_MS = 15_000 +const PROVIDER_POLL_MS = 250 + +type Step = + | { kind: "menu" } + | { kind: "sso" } + | { kind: "apikey" } + | { kind: "model"; models: string[] } + | { kind: "busy"; text: string } + +// The desktop twin of the TUI's DialogConfigure: a persistent dialog the +// configure gate keeps open while no provider is configured. Unlike the TUI +// (whose dialog system swaps element factories per step), the whole flow lives +// in this one component as internal steps, so persistence is armed once. +export function DialogConfigure() { + const dialog = useDialog() + const language = useLanguage() + const platform = usePlatform() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const models = useModels() + + const sso = platform.gatewaySso! + const [step, setStep] = createSignal({ kind: "menu" }) + const [ssoState, setSsoState] = createSignal({ phase: "idle", status: "" }) + const [pasteError, setPasteError] = createSignal() + const [pasteBusy, setPasteBusy] = createSignal(false) + const [copied, setCopied] = createSignal(false) + + onMount(() => { + dialog.setPersistent(true) + void sso.getState().then(setSsoState) + onCleanup(sso.subscribe(setSsoState)) + }) + + const fail = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + // A cancelled login is the user pressing Back, not a failure. + if (message.includes("cancelled")) { + setStep({ kind: "menu" }) + return + } + showToast({ title: language.t("gateway.configure.error.title"), description: message }) + // The gate's invariant reopens the dialog on a fresh entry screen, so a + // failed attempt never traps the user mid-flow. + dialog.setPersistent(false) + dialog.close() + } + + const runSso = async () => { + setStep({ kind: "sso" }) + setPasteError(undefined) + setPasteBusy(false) + try { + await sso.startLogin() + setStep({ kind: "busy", text: language.t("gateway.configure.status.setup") }) + const setup = await sso.fetchSetup() + if (!setup.hasApiKey) { + setStep({ kind: "apikey" }) + return + } + await toModels() + } catch (error) { + fail(error) + } + } + + const cancelSso = async () => { + await sso.cancelLogin().catch(() => {}) + setStep({ kind: "menu" }) + } + + const submitPaste = async (value: string) => { + if (!value.trim() || pasteBusy()) return + const error = await sso.submitCode(value) + setPasteError(error ?? undefined) + if (error === null) setPasteBusy(true) + } + + const submitApiKey = async (value: string) => { + const key = value.trim() + if (!key) return + setStep({ kind: "busy", text: language.t("gateway.configure.status.setup") }) + try { + await sso.setManualApiKey(key) + await toModels() + } catch (error) { + fail(error) + } + } + + const toModels = async () => { + setStep({ kind: "busy", text: language.t("gateway.configure.status.models") }) + const list = await sso.fetchModels() + setStep({ kind: "model", models: list }) + } + + const choose = async (model: string) => { + setStep({ kind: "busy", text: language.t("gateway.configure.status.configuring", { model }) }) + try { + const result = await sso.complete(model) + const client = serverSDK().client + // Raw update instead of serverSync().updateConfig: its onSuccess refetch + // races the forked server-side instance reload. The explicit dispose + // below emits global.disposed, which refreshes every window (mirrors the + // TUI's update -> dispose -> bootstrap ordering). + await client.global.config.update({ config: result.config as Config }) + await client.global.dispose() + const arrived = await waitForModel(result.providerID, model) + if (arrived) { + models.recent.push({ providerID: result.providerID, modelID: model }) + models.setVisibility({ providerID: result.providerID, modelID: model }, true) + } + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("gateway.configure.success.title"), + description: language.t("gateway.configure.success.description", { model }), + }) + dialog.setPersistent(false) + dialog.close() + } catch (error) { + fail(error) + } + } + + const waitForModel = async (providerID: string, model: string) => { + const deadline = Date.now() + PROVIDER_WAIT_MS + while (Date.now() < deadline) { + if (serverSync().data.provider.all.get(providerID)?.models[model]) return true + await new Promise((resolve) => setTimeout(resolve, PROVIDER_POLL_MS)) + } + return false + } + + const openCustomProvider = () => { + void dialog.push(() => dialog.close()} />) + } + + const openConnectProvider = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.push(() => ) + }) + } + + const copyAuthUrl = () => { + const url = ssoState().authUrl + if (!url) return + void navigator.clipboard.writeText(url).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( + +
+ + +
+
{language.t("gateway.configure.title")}
+

{language.t("gateway.configure.description")}

+
+
+ void runSso()} + /> + + +
+
+ + +
+
{language.t("gateway.configure.option.sso.title")}
+

{ssoState().status}

+
+ + {(url) => ( +
+

{language.t("gateway.configure.sso.pending")}

+
+ + +
+ +
+ )} +
+ +
+ + +
+
{language.t("gateway.configure.apikey.title")}
+

{language.t("gateway.configure.apikey.description")}

+
+ + +
+ + +
{language.t("gateway.configure.model.title")}
+
+ ).models}> + {(model) => ( + + )} + +
+
+ + +

{(step() as Extract).text}

+
+
+
+
+ ) +} + +function MenuOption(props: { title: string; description: string; onClick: () => void }) { + return ( + + ) +} + +function PasteField(props: { + busy: boolean + error?: string + label: string + placeholder: string + submitLabel: string + onSubmit: (value: string) => void | Promise +}) { + const [value, setValue] = createSignal("") + return ( +
{ + event.preventDefault() + void props.onSubmit(value()) + }} + > + + + + ) +} diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx new file mode 100644 index 0000000000..c40f79cca0 --- /dev/null +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -0,0 +1,64 @@ +import { createEffect, createSignal, onCleanup } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { shouldOpenConfigureGate } from "@/gateway/configure-gate" +import { DialogConfigure } from "./dialog-configure" + +// ServerScopedProviders can be mounted by the legacy and new shells at the +// same time on /:dir routes, so the mounted gates elect a single driver +// reactively: if the driver unmounts, the next instance takes over. +const [instances, setInstances] = createSignal([]) + +export function GatewayConfigureGate() { + const platform = usePlatform() + // No platform capability means no way to run the SSO flow (the browser web + // deployment): never gate there. + if (!platform.gatewaySso) return null + + const self = {} + setInstances((list) => [...list, self]) + onCleanup(() => setInstances((list) => list.filter((item) => item !== self))) + + const server = useServer() + const serverSync = useServerSync() + const dialog = useDialog() + + // Plain flag, deliberately non-reactive: both effects are (re)triggered by + // their reactive reads; this only records whether the active dialog is ours. + let gateDialogOpen = false + + // Stateless invariant (twin of the TUI gate in packages/tui/src/app.tsx): + // whenever the dialog stack empties while unconfigured, reopen the gate — + // escaping any sub-flow bounces back until a provider is configured. + createEffect(() => { + if (instances()[0] !== self) return + const open = shouldOpenConfigureGate({ + enabled: true, + isLocal: server.isLocal(), + syncReady: serverSync().data.ready, + connected: serverSync().data.provider.connected.length > 0, + dialogOpen: dialog.active !== undefined, + }) + if (!open) return + gateDialogOpen = true + void dialog.show( + () => , + () => { + gateDialogOpen = false + }, + ) + }) + + // Setup finished elsewhere (another window, the CLI) propagates through the + // provider store; drop our gate dialog when it does. + createEffect(() => { + if (serverSync().data.provider.connected.length === 0) return + if (!gateDialogOpen) return + dialog.setPersistent(false) + dialog.close() + }) + + return null +} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 921d8ff45c..523803f9cc 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -8,7 +8,6 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client" import { createEffect, createMemo, on, Show } from "solid-js" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import type { PromptInputProps } from "@/components/prompt-input/contracts" import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" @@ -72,9 +71,14 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { providerID={props.controller.model.selection.current()?.provider?.id} modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")} onClose={props.controller.restoreFocus} - onUnpaidClick={() => - dialog.show(() => ) - } + onUnpaidClick={() => { + // With the hosted free-model providers hidden (gateway/hidden-providers), + // "unpaid" means nothing is connected, so offer the connect flow instead + // of a free-models list that would render empty. + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => ) + }) + }} /> } /> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ed8a6696f8..15229bc472 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -44,8 +44,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" @@ -1682,7 +1680,13 @@ export const PromptInput: Component = (props) => { class="min-w-0 max-w-[320px] text-13-regular text-text-base group" style={control()} onClick={() => { - dialog.show(() => ) + // With the hosted free-model providers hidden, "unpaid" + // means nothing is connected; offer the connect flow. + void import("@/components/dialog-connect-provider").then( + ({ DialogConnectProvider }) => { + void dialog.show(() => ) + }, + ) }} > diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index e54bc88d4d..1bba515cf3 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,5 +1,6 @@ import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import { filterHiddenProviders } from "@/gateway/hidden-providers" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -19,7 +20,7 @@ export function normalizeAgentList(input: unknown): Agent[] { } export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { - return { + return filterHiddenProviders({ ...input, all: new Map( input.all.map( @@ -35,7 +36,7 @@ export function normalizeProviderList(input: ProviderListResponse): NormalizedPr ] as const, ), ), - } + }) } export function sanitizeProject(project: Project) { diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 23844fe982..22d5f780e6 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" import type { SandboxesPlatform } from "../sandbox/types" +import type { GatewaySsoPlatform } from "../gateway/types" import type { UpdaterPlatform } from "../updater" type PickerPaths = string | string[] | null @@ -89,6 +90,9 @@ type PlatformBase = { /** Manage sandboxed sidecar servers (Electron only) */ sandboxes?: SandboxesPlatform + /** Gateway SSO login for the configure gate (Electron only) */ + gatewaySso?: GatewaySsoPlatform + /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/gateway/configure-gate.test.ts b/packages/app/src/gateway/configure-gate.test.ts new file mode 100644 index 0000000000..c0a188b1ba --- /dev/null +++ b/packages/app/src/gateway/configure-gate.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { shouldOpenConfigureGate } from "./configure-gate" + +const base = { enabled: true, isLocal: true, syncReady: true, connected: false, dialogOpen: false } + +test("opens when unconfigured on the local server with no dialog up", () => { + expect(shouldOpenConfigureGate(base)).toBe(true) +}) + +test("stays closed without the platform capability (web)", () => { + expect(shouldOpenConfigureGate({ ...base, enabled: false })).toBe(false) +}) + +test("stays closed on non-local servers", () => { + expect(shouldOpenConfigureGate({ ...base, isLocal: false })).toBe(false) +}) + +test("waits for provider data before judging", () => { + expect(shouldOpenConfigureGate({ ...base, syncReady: false })).toBe(false) +}) + +test("stays closed once a provider is connected", () => { + expect(shouldOpenConfigureGate({ ...base, connected: true })).toBe(false) +}) + +test("does not fight an open dialog", () => { + expect(shouldOpenConfigureGate({ ...base, dialogOpen: true })).toBe(false) +}) diff --git a/packages/app/src/gateway/configure-gate.ts b/packages/app/src/gateway/configure-gate.ts new file mode 100644 index 0000000000..0a551d6d2e --- /dev/null +++ b/packages/app/src/gateway/configure-gate.ts @@ -0,0 +1,16 @@ +// Pure predicate for the desktop configure gate, the app twin of the TUI's +// packages/tui/src/component/configure-gate.ts. The gate is a stateless +// invariant: while it holds, the configure dialog is (re)opened, so escaping +// any sub-flow bounces back until a provider is configured. +export function shouldOpenConfigureGate(input: { + // Platform capability present (desktop) — the web deployment never gates. + enabled: boolean + // Only the local sidecar server gates; remote/WSL/sandbox servers do not. + isLocal: boolean + // Provider data has loaded; an empty list before bootstrap is not evidence. + syncReady: boolean + connected: boolean + dialogOpen: boolean +}) { + return input.enabled && input.isLocal && input.syncReady && !input.connected && !input.dialogOpen +} diff --git a/packages/app/src/gateway/hidden-providers.test.ts b/packages/app/src/gateway/hidden-providers.test.ts new file mode 100644 index 0000000000..90bf56eb8a --- /dev/null +++ b/packages/app/src/gateway/hidden-providers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import type { Provider } from "@opencode-ai/sdk/v2" +import { filterHiddenProviders } from "./hidden-providers" + +const provider = (id: string, costInput: number) => + ({ + id, + name: id, + models: { "model-1": { id: "model-1", name: "model-1", cost: { input: costInput, output: 0 } } }, + }) as unknown as Provider + +const list = (providers: Provider[], connected: string[], defaults: Record) => ({ + all: new Map(providers.map((p) => [p.id, p])), + connected, + default: defaults, +}) + +describe("filterHiddenProviders", () => { + test("drops the hosted providers from the catalog and defaults", () => { + const input = list( + [provider("opencode", 0), provider("opencode-go", 3), provider("anthropic", 3)], + ["anthropic"], + { opencode: "model-1", "opencode-go": "model-1", anthropic: "model-1" }, + ) + const result = filterHiddenProviders(input) + expect([...result.all.keys()]).toEqual(["anthropic"]) + expect(result.default).toEqual({ anthropic: "model-1" }) + }) + + test("drops the unauthenticated free tier from connected", () => { + const input = list([provider("opencode", 0)], ["opencode"], {}) + expect(filterHiddenProviders(input).connected).toEqual([]) + }) + + test("keeps a paid opencode account counted as connected", () => { + const input = list([provider("opencode", 3)], ["opencode"], {}) + expect(filterHiddenProviders(input).connected).toEqual(["opencode"]) + }) + + test("keeps other connected providers and is idempotent", () => { + const input = list([provider("opencode", 0), provider("netgate", 3)], ["opencode", "netgate"], {}) + const once = filterHiddenProviders(input) + expect(once.connected).toEqual(["netgate"]) + const twice = filterHiddenProviders(once) + expect(twice.connected).toEqual(["netgate"]) + expect([...twice.all.keys()]).toEqual(["netgate"]) + }) + + test("handles an empty catalog", () => { + const result = filterHiddenProviders(list([], [], {})) + expect(result.all.size).toBe(0) + expect(result.connected).toEqual([]) + }) +}) diff --git a/packages/app/src/gateway/hidden-providers.ts b/packages/app/src/gateway/hidden-providers.ts new file mode 100644 index 0000000000..a33c2cd6ad --- /dev/null +++ b/packages/app/src/gateway/hidden-providers.ts @@ -0,0 +1,26 @@ +import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" + +// Upstream's hosted providers are not offered in this fork (mirrors the TUI's +// dialog-provider/dialog-model filters): they never appear in the catalog, so +// no picker, connect dialog, or default-model fallback can reach them. +const HIDDEN_PROVIDERS = ["opencode", "opencode-go"] + +export function filterHiddenProviders(list: NormalizedProviderListResponse): NormalizedProviderListResponse { + const all = new Map(list.all) + for (const id of HIDDEN_PROVIDERS) all.delete(id) + return { + ...list, + all, + // `connected` feeds the configure gate's "is anything set up" check, so it + // follows the TUI's isConnectedProvider semantics instead of the catalog + // rule: the server auto-registers `opencode` with free models and a public + // key when nothing is configured, and only that unauthenticated free tier + // is dropped — a deliberately configured paid Zen account still counts as + // configured (though its models stay hidden, as in the TUI). + connected: list.connected.filter((id) => { + if (id !== "opencode") return true + return Object.values(list.all.get(id)?.models ?? {}).some((model) => model.cost?.input) + }), + default: Object.fromEntries(Object.entries(list.default).filter(([id]) => !HIDDEN_PROVIDERS.includes(id))), + } +} diff --git a/packages/app/src/gateway/types.ts b/packages/app/src/gateway/types.ts new file mode 100644 index 0000000000..e56560740b --- /dev/null +++ b/packages/app/src/gateway/types.ts @@ -0,0 +1,59 @@ +// Wire contract between the desktop main process's gateway SSO controller and +// the renderer, mirroring the sandbox pattern (src/sandbox/types.ts): the app +// owns the types, preload aliases them, and the main process keeps the +// node-flavored implementation in packages/desktop — the app must never import +// @codev/gateway. + +export type GatewaySsoPhase = + | "idle" + | "signing-in" + | "awaiting-callback" + | "exchanging" + | "fetching-setup" + | "ready" + | "error" + +export type GatewaySsoState = { + phase: GatewaySsoPhase + // Human-readable progress line streamed from the login/setup steps. + status: string + // The manual authorize URL, present while awaiting the browser callback so + // the dialog can offer an open-link/copy fallback. + authUrl?: string + // Signed-in account label once authentication completed. + email?: string + error?: string +} + +export type GatewaySetupResult = { + gatewayUrl: string + // False when the account has no provisioned key: the dialog must collect one + // manually via setManualApiKey before fetching models. + hasApiKey: boolean +} + +export type GatewayCompleteResult = { + providerID: string + // The buildGatewayConfig fragment the renderer PATCHes into the global + // config; the API key crosses IPC only inside this block. + config: Record +} + +export type GatewaySsoPlatform = { + getState(): Promise + subscribe(cb: (state: GatewaySsoState) => void): () => void + // Starts (or attaches to) the login attempt; resolves once authenticated. + startLogin(): Promise + // Manual paste fallback; resolves an inline error message, or null when the + // pasted callback/code was accepted. + submitCode(pasted: string): Promise + cancelLogin(): Promise + // Fetches and persists the gateway configuration and exchanges the SSO + // session for an API key. + fetchSetup(): Promise + setManualApiKey(apiKey: string): Promise + fetchModels(): Promise + // Persists credentials to the shared auth file and returns the provider + // config fragment for the chosen default model. + complete(model: string): Promise +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d93966e4ed..06152e3f8d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -885,8 +885,33 @@ export const dict = { "sidebar.recents.viewAll": "View all", "sidebar.workspaces.enable": "Enable workspaces", "sidebar.workspaces.disable": "Disable workspaces", + "gateway.configure.title": "Set up CoDev", + "gateway.configure.description": "Connect a model provider to start using CoDev.", + "gateway.configure.option.sso.title": "Login with SSO", + "gateway.configure.option.sso.description": "Sign in with your CoDev account", + "gateway.configure.option.custom.title": "Custom provider", + "gateway.configure.option.custom.description": "OpenAI-compatible endpoint", + "gateway.configure.option.connect.title": "Connect provider", + "gateway.configure.option.connect.description": "Anthropic, OpenAI, and others", + "gateway.configure.sso.pending": "Complete the login in your browser.", + "gateway.configure.sso.open": "Open login page", + "gateway.configure.sso.copy": "Copy link", + "gateway.configure.sso.copied": "Copied", + "gateway.configure.sso.paste.label": "Callback URL or code", + "gateway.configure.sso.paste.placeholder": "Paste the callback URL or code if the browser can't reach this machine", + "gateway.configure.apikey.title": "Gateway API key", + "gateway.configure.apikey.description": "Your account has no gateway API key. Enter one manually.", + "gateway.configure.apikey.label": "API key", + "gateway.configure.model.title": "Choose default model", + "gateway.configure.status.setup": "Fetching gateway configuration...", + "gateway.configure.status.models": "Fetching models...", + "gateway.configure.status.configuring": "Configuring {{model}}...", + "gateway.configure.success.title": "CoDev configured", + "gateway.configure.success.description": "Using {{model}}", + "gateway.configure.error.title": "Setup failed", + "gateway.configure.back": "Back", "sidebar.gettingStarted.title": "Getting started", - "sidebar.gettingStarted.line1": "CoDev includes free models so you can start immediately.", + "sidebar.gettingStarted.line1": "Set up a provider to start using CoDev.", "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9d83d1bcba..91bbd368d5 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -817,8 +817,33 @@ export const dict = { "sidebar.recents.viewAll": "Ver todo", "sidebar.workspaces.enable": "Habilitar espacios de trabajo", "sidebar.workspaces.disable": "Deshabilitar espacios de trabajo", + "gateway.configure.title": "Configura CoDev", + "gateway.configure.description": "Conecta un proveedor de modelos para empezar a usar CoDev.", + "gateway.configure.option.sso.title": "Iniciar sesión con SSO", + "gateway.configure.option.sso.description": "Inicia sesión con tu cuenta de CoDev", + "gateway.configure.option.custom.title": "Proveedor personalizado", + "gateway.configure.option.custom.description": "Endpoint compatible con OpenAI", + "gateway.configure.option.connect.title": "Conectar proveedor", + "gateway.configure.option.connect.description": "Anthropic, OpenAI y otros", + "gateway.configure.sso.pending": "Completa el inicio de sesión en tu navegador.", + "gateway.configure.sso.open": "Abrir página de inicio de sesión", + "gateway.configure.sso.copy": "Copiar enlace", + "gateway.configure.sso.copied": "Copiado", + "gateway.configure.sso.paste.label": "URL de callback o código", + "gateway.configure.sso.paste.placeholder": "Pega la URL de callback o el código si el navegador no puede llegar a esta máquina", + "gateway.configure.apikey.title": "Clave API del gateway", + "gateway.configure.apikey.description": "Tu cuenta no tiene clave API del gateway. Introduce una manualmente.", + "gateway.configure.apikey.label": "Clave API", + "gateway.configure.model.title": "Elige el modelo predeterminado", + "gateway.configure.status.setup": "Obteniendo la configuración del gateway...", + "gateway.configure.status.models": "Obteniendo modelos...", + "gateway.configure.status.configuring": "Configurando {{model}}...", + "gateway.configure.success.title": "CoDev configurado", + "gateway.configure.success.description": "Usando {{model}}", + "gateway.configure.error.title": "La configuración falló", + "gateway.configure.back": "Atrás", "sidebar.gettingStarted.title": "Empezando", - "sidebar.gettingStarted.line1": "CoDev incluye modelos gratuitos para que puedas empezar inmediatamente.", + "sidebar.gettingStarted.line1": "Configura un proveedor para empezar a usar CoDev.", "sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", diff --git a/packages/codev-gateway/src/backend.ts b/packages/codev-gateway/src/backend.ts index a74598eebb..48f2a06914 100644 --- a/packages/codev-gateway/src/backend.ts +++ b/packages/codev-gateway/src/backend.ts @@ -5,6 +5,10 @@ interface ErrorResponse { error?: string } +// Callers embedded in Electron pass the host-aware net.fetch so enterprise +// proxies and PAC files apply; everything else uses the runtime's fetch. +type FetchOptions = { fetchImpl?: typeof fetch } + const VALIDATE_TIMEOUT_MS = 5_000 const MODELS_TIMEOUT_MS = 10_000 // Backend endpoints are quick: a token exchange and a tiny config blob. Cap @@ -13,8 +17,9 @@ const BACKEND_TIMEOUT_MS = 10_000 // SSO access token → gateway API key (backend /auth/exchange). An empty key is // returned, not thrown — callers route to the manual-credentials fallback. -export async function fetchApiKey(accessToken: string): Promise { - const res = await fetch(`${BACKEND_URL}/auth/exchange`, { +export async function fetchApiKey(accessToken: string, opts?: FetchOptions): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch + const res = await fetchImpl(`${BACKEND_URL}/auth/exchange`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), @@ -30,8 +35,9 @@ export async function fetchApiKey(accessToken: string): Promise { // Pulls the runtime coordinates that are not baked into the binary — most // importantly the public gateway base URL. Persisted next to the SSO session // via saveCodevConfig, where the hub also reads it. -export async function fetchCodevConfig(accessToken: string): Promise { - const res = await fetch(`${BACKEND_URL}/config`, { +export async function fetchCodevConfig(accessToken: string, opts?: FetchOptions): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch + const res = await fetchImpl(`${BACKEND_URL}/config`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), @@ -63,8 +69,9 @@ function gatewayV1Url(baseUrl: string, suffix: string): string { // Fetches the model ids available to the given key via the OpenAI-compatible // /v1/models endpoint. Throws on non-2xx, network errors, timeout, or an // empty result — the caller decides whether to fall back. -export async function fetchModels(apiKey: string, baseUrl: string): Promise { - const res = await fetch(gatewayV1Url(baseUrl, "models"), { +export async function fetchModels(apiKey: string, baseUrl: string, opts?: FetchOptions): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch + const res = await fetchImpl(gatewayV1Url(baseUrl, "models"), { headers: { accept: "application/json", Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(MODELS_TIMEOUT_MS), }) @@ -82,10 +89,11 @@ export async function fetchModels(apiKey: string, baseUrl: string): Promise { +export async function validateApiKey(apiKey: string, baseUrl: string, opts?: FetchOptions): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch const stripped = baseUrl.replace(/\/?v1\/?$/, "") const url = `${stripped.endsWith("/") ? stripped : `${stripped}/`}key/info` - const res = await fetch(url, { + const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), }) diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts new file mode 100644 index 0000000000..a25200424c --- /dev/null +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import type { AuthData } from "@codev/gateway" +import { createGatewaySsoController } from "./controller" + +const auth: AuthData = { + access_token: "token", + id_token: "id", + expires_at: Date.now() + 60_000, + user: { sub: "sub", email: "user@example.test", displayName: "User" }, +} + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const controllerWith = (gateway: Parameters[0]["gateway"]) => + createGatewaySsoController({ openUrl: async () => {}, gateway }) + +describe("gateway sso controller", () => { + test("startLogin is single-flight across callers", async () => { + let logins = 0 + const gate = deferred() + const controller = controllerWith({ + silentSso: async () => null, + login: async () => { + logins++ + return gate.promise + }, + }) + + const first = controller.startLogin() + const second = controller.startLogin() + gate.resolve(auth) + await Promise.all([first, second]) + expect(logins).toBe(1) + expect(controller.getState().phase).toBe("ready") + expect(controller.getState().email).toBe("user@example.test") + }) + + test("cancelLogin drops a stale attempt's late result", async () => { + const gate = deferred() + const controller = controllerWith({ + silentSso: async () => null, + login: async () => gate.promise, + }) + + const run = controller.startLogin() + controller.cancelLogin() + expect(controller.getState().phase).toBe("idle") + + gate.resolve(auth) + expect(run).rejects.toThrow("login cancelled") + // The stale success must not flip the state away from idle. + await run.catch(() => {}) + expect(controller.getState().phase).toBe("idle") + }) + + test("submitCode passes the inline error contract through", async () => { + const gate = deferred() + const controller = controllerWith({ + silentSso: async () => null, + login: async (options) => { + options.onReady( + () => {}, + "https://sso.example.test/authorize", + (pasted) => (pasted === "good" ? null : "No authorization code found."), + ) + return gate.promise + }, + }) + + const run = controller.startLogin() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(controller.getState().phase).toBe("awaiting-callback") + expect(controller.getState().authUrl).toBe("https://sso.example.test/authorize") + expect(controller.submitCode("bad")).toBe("No authorization code found.") + expect(controller.submitCode("good")).toBe(null) + gate.resolve(auth) + await run + }) + + test("fetchSetup retries and reports the manual-key path", async () => { + let configCalls = 0 + const saved: unknown[] = [] + const controller = controllerWith({ + silentSso: async () => auth, + fetchCodevConfig: async () => { + configCalls++ + if (configCalls < 3) throw new Error("flaky") + return { supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" } + }, + fetchApiKey: async () => "", + saveCodevConfig: (config) => { + saved.push(config) + }, + }) + + await controller.startLogin() + const setup = await controller.fetchSetup() + expect(configCalls).toBe(3) + expect(saved).toHaveLength(1) + expect(setup).toEqual({ gatewayUrl: "https://gw.example.test", hasApiKey: false }) + }) + + test("retry exhaustion surfaces the last error", async () => { + const controller = controllerWith({ + silentSso: async () => auth, + fetchCodevConfig: async () => { + throw new Error("backend down") + }, + }) + + await controller.startLogin() + expect(controller.fetchSetup()).rejects.toThrow("backend down") + }) + + test("complete writes the auth file and returns the netgate config", async () => { + const savedKeys: unknown[] = [] + const controller = controllerWith({ + silentSso: async () => auth, + fetchCodevConfig: async () => ({ supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" }), + fetchApiKey: async () => "sk-live", + fetchModels: async () => ["model-a", "model-b"], + saveCodevConfig: () => {}, + saveApiKey: (creds) => { + savedKeys.push(creds) + }, + }) + + await controller.startLogin() + await controller.fetchSetup() + await controller.fetchModels() + const result = controller.complete("model-a") + + // SSO-minted keys record no base_url (mirrors the TUI flow). + expect(savedKeys).toEqual([{ apiKey: "sk-live", baseUrl: undefined, model: "model-a" }]) + expect(result.providerID).toBe("netgate") + const provider = (result.config as { provider: Record }> }).provider + expect(Object.keys(provider)).toEqual(["netgate"]) + expect(Object.keys(provider.netgate.models)).toEqual(["model-a", "model-b"]) + }) + + test("a manually entered key records its base URL", async () => { + const savedKeys: { baseUrl?: string }[] = [] + const controller = controllerWith({ + silentSso: async () => auth, + fetchCodevConfig: async () => ({ supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" }), + fetchApiKey: async () => "", + saveCodevConfig: () => {}, + saveApiKey: (creds) => { + savedKeys.push(creds) + }, + }) + + await controller.startLogin() + await controller.fetchSetup() + controller.setManualApiKey("sk-manual") + controller.complete("model-a") + expect(savedKeys[0]?.baseUrl).toBe("https://gw.example.test") + }) +}) diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts new file mode 100644 index 0000000000..f45eb601c7 --- /dev/null +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -0,0 +1,190 @@ +import { + buildGatewayConfig, + fetchApiKey, + fetchCodevConfig, + fetchModels, + GATEWAY_PROVIDER, + login, + saveApiKey, + saveCodevConfig, + silentSso, + type AuthData, +} from "@codev/gateway" +import type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" + +const RETRIES = 3 + +type Deps = { + openUrl: (url: string) => Promise + // Electron's net.fetch so enterprise proxies and PAC files apply. + fetchImpl?: typeof fetch + // Injectable for tests; defaults to @codev/gateway. + gateway?: Partial +} + +type GatewayFns = { + silentSso: typeof silentSso + login: typeof login + fetchCodevConfig: typeof fetchCodevConfig + fetchApiKey: typeof fetchApiKey + fetchModels: typeof fetchModels + saveCodevConfig: typeof saveCodevConfig + saveApiKey: typeof saveApiKey +} + +export type GatewaySsoController = ReturnType + +export function createGatewaySsoController(deps: Deps) { + const gateway: GatewayFns = { + silentSso, + login, + fetchCodevConfig, + fetchApiKey, + fetchModels, + saveCodevConfig, + saveApiKey, + ...deps.gateway, + } + const fetchOpts = { fetchImpl: deps.fetchImpl } + const listeners = new Set<(state: GatewaySsoState) => void>() + + let state: GatewaySsoState = { phase: "idle", status: "" } + const setState = (next: Partial) => { + state = { + phase: next.phase ?? state.phase, + status: next.status ?? state.status, + authUrl: next.authUrl, + email: next.email ?? state.email, + error: next.error, + } + listeners.forEach((listener) => listener(state)) + } + + // login() has no cancel API: its promise settles only on callback, paste, + // error, or its own five-minute timeout. Cancelling bumps the generation so + // the abandoned attempt's eventual result is dropped; a late saveAuth to + // disk is harmless (the next silentSso just succeeds). + let generation = 0 + let loginRun: Promise | undefined + let submitStash: ((pasted: string) => string | null) | undefined + + let session: AuthData | undefined + let apiKey: string | undefined + let gatewayUrl: string | undefined + let manualKey = false + let models: string[] = [] + + const cancelled = () => new Error("login cancelled") + + const startLogin = async () => { + // Single-flight: a second window attaches to the in-flight attempt instead + // of racing a second loopback server. + if (loginRun) { + await loginRun + return + } + const active = ++generation + setState({ phase: "signing-in", status: "Starting SSO login...", error: undefined }) + const run = (async () => { + const cached = await gateway.silentSso() + if (cached) return cached + return gateway.login({ + onLog: (message) => { + if (generation === active) setState({ status: message }) + }, + onReady: (openBrowser, authUrl, submit) => { + if (generation !== active) return + submitStash = submit + setState({ phase: "awaiting-callback", status: "Waiting for the browser sign-in...", authUrl }) + openBrowser() + }, + openUrl: deps.openUrl, + }) + })() + loginRun = run + try { + const auth = await run + if (generation !== active) throw cancelled() + session = auth + setState({ phase: "ready", status: "Signed in.", email: auth.user.email }) + } catch (error) { + if (generation !== active) throw cancelled() + setState({ phase: "error", status: "", error: error instanceof Error ? error.message : String(error) }) + throw error + } finally { + if (generation === active) { + loginRun = undefined + submitStash = undefined + } + } + } + + const withRetries = async (label: string, run: () => Promise) => { + for (let attempt = 1; ; attempt++) { + try { + return await run() + } catch (error) { + if (attempt >= RETRIES) throw error + setState({ status: `${label} failed — retrying (${attempt}/${RETRIES})...` }) + } + } + } + + return { + getState: () => state, + subscribe(listener: (state: GatewaySsoState) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + startLogin, + submitCode(pasted: string) { + if (!submitStash) throw new Error("no login in progress") + return submitStash(pasted) + }, + cancelLogin() { + generation++ + loginRun = undefined + submitStash = undefined + setState({ phase: "idle", status: "", error: undefined }) + }, + async fetchSetup(): Promise { + const auth = session + if (!auth) throw new Error("not signed in") + setState({ phase: "fetching-setup", status: "Fetching gateway configuration..." }) + const config = await withRetries("Fetching gateway configuration", () => + gateway.fetchCodevConfig(auth.access_token, fetchOpts), + ) + gateway.saveCodevConfig(config) + gatewayUrl = config.gatewayUrl + setState({ status: "Fetching gateway API key..." }) + apiKey = await withRetries("Fetching gateway API key", () => gateway.fetchApiKey(auth.access_token, fetchOpts)) + manualKey = false + setState({ phase: "ready", status: "" }) + return { gatewayUrl: config.gatewayUrl, hasApiKey: apiKey.length > 0 } + }, + setManualApiKey(key: string) { + if (!key.length) throw new Error("empty API key") + apiKey = key + manualKey = true + }, + async fetchModels() { + if (!apiKey || !gatewayUrl) throw new Error("gateway setup incomplete") + const key = apiKey + const url = gatewayUrl + setState({ status: "Fetching models..." }) + models = await withRetries("Fetching models", () => gateway.fetchModels(key, url, fetchOpts)) + setState({ status: "" }) + return models + }, + complete(model: string): GatewayCompleteResult { + if (!apiKey || !gatewayUrl) throw new Error("gateway setup incomplete") + // SSO-minted keys write no base_url to the auth file; only a manually + // entered key records the URL it belongs to (mirrors the TUI flow). + gateway.saveApiKey({ apiKey, baseUrl: manualKey ? gatewayUrl : undefined, model }) + return { + providerID: GATEWAY_PROVIDER.id, + config: buildGatewayConfig({ apiKey, baseUrl: gatewayUrl, model, models }), + } + }, + } +} diff --git a/packages/desktop/src/main/gateway-sso/ipc.ts b/packages/desktop/src/main/gateway-sso/ipc.ts new file mode 100644 index 0000000000..967d417c6e --- /dev/null +++ b/packages/desktop/src/main/gateway-sso/ipc.ts @@ -0,0 +1,54 @@ +import { app, ipcMain } from "electron" +import type { IpcMainInvokeEvent } from "electron" +import type { GatewaySsoController } from "./controller" + +export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController) { + const subscriptions = new Map void>() + const unsubscribe = (id: number) => { + const off = subscriptions.get(id) + if (!off) return + off() + subscriptions.delete(id) + } + + app.once("will-quit", () => { + subscriptions.forEach((off) => off()) + subscriptions.clear() + }) + + ipcMain.handle("gateway-sso-subscribe", (event) => { + const id = event.sender.id + if (subscriptions.has(id)) return + subscriptions.set( + id, + controller.subscribe((state) => { + if (event.sender.isDestroyed()) { + unsubscribe(id) + return + } + event.sender.send("gateway-sso-event", state) + }), + ) + event.sender.once("destroyed", () => unsubscribe(id)) + }) + ipcMain.handle("gateway-sso-unsubscribe", (event) => unsubscribe(event.sender.id)) + ipcMain.handle("gateway-sso-get-state", () => controller.getState()) + ipcMain.handle("gateway-sso-start-login", () => controller.startLogin()) + ipcMain.handle("gateway-sso-submit-code", (_event: IpcMainInvokeEvent, pasted: unknown) => + controller.submitCode(requireGatewayIpcString("pasted code", pasted)), + ) + ipcMain.handle("gateway-sso-cancel-login", () => controller.cancelLogin()) + ipcMain.handle("gateway-sso-fetch-setup", () => controller.fetchSetup()) + ipcMain.handle("gateway-sso-set-manual-api-key", (_event: IpcMainInvokeEvent, key: unknown) => + controller.setManualApiKey(requireGatewayIpcString("API key", key)), + ) + ipcMain.handle("gateway-sso-fetch-models", () => controller.fetchModels()) + ipcMain.handle("gateway-sso-complete", (_event: IpcMainInvokeEvent, model: unknown) => + controller.complete(requireGatewayIpcString("model", model)), + ) +} + +function requireGatewayIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index be8868daf4..475a70d0e9 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app } from "electron" +import { app, net, shell } from "electron" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" @@ -49,6 +49,8 @@ import { spawnWslSidecar } from "./wsl/sidecar" import { createDesktopSandboxController } from "./sandbox/controller" import { registerSandboxIpcHandlers } from "./sandbox/ipc" import { flushStoreWrites } from "./store" +import { createGatewaySsoController } from "./gateway-sso/controller" +import { registerGatewaySsoIpcHandlers } from "./gateway-sso/ipc" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" @@ -313,6 +315,16 @@ const main = Effect.gen(function* () { }) registerWslIpcHandlers(wslServers) registerSandboxIpcHandlers(sandboxes) + registerGatewaySsoIpcHandlers( + createGatewaySsoController({ + openUrl: (url) => shell.openExternal(url), + // Chromium's fetch so host proxies, PAC files, and enterprise roots + // apply (same reasoning as the sandbox egress broker); the gateway + // functions only pass string URLs and JSON bodies. + fetchImpl: (input, init) => + net.fetch(input instanceof URL ? input.toString() : input, init as Parameters[1]), + }), + ) void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 5fc4a9a077..c3a418dc7f 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import type { ElectronAPI, SandboxesEvent, WslServersEvent } from "./types" +import type { ElectronAPI, GatewaySsoState, SandboxesEvent, WslServersEvent } from "./types" import type { UpdaterState } from "@opencode-ai/app/updater" const updaterCallbacks = new Set<(state: UpdaterState) => void>() @@ -58,6 +58,25 @@ const api: ElectronAPI = { stop: (id) => ipcRenderer.invoke("sandbox-stop", id), updateMounts: (id, mounts) => ipcRenderer.invoke("sandbox-update-mounts", id, mounts), }, + gatewaySso: { + getState: () => ipcRenderer.invoke("gateway-sso-get-state"), + subscribe: (cb) => { + const handler = (_: unknown, state: GatewaySsoState) => cb(state) + ipcRenderer.on("gateway-sso-event", handler) + void ipcRenderer.invoke("gateway-sso-subscribe") + return () => { + ipcRenderer.removeListener("gateway-sso-event", handler) + void ipcRenderer.invoke("gateway-sso-unsubscribe") + } + }, + startLogin: () => ipcRenderer.invoke("gateway-sso-start-login"), + submitCode: (pasted) => ipcRenderer.invoke("gateway-sso-submit-code", pasted), + cancelLogin: () => ipcRenderer.invoke("gateway-sso-cancel-login"), + fetchSetup: () => ipcRenderer.invoke("gateway-sso-fetch-setup"), + setManualApiKey: (apiKey) => ipcRenderer.invoke("gateway-sso-set-manual-api-key", apiKey), + fetchModels: () => ipcRenderer.invoke("gateway-sso-fetch-models"), + complete: (model) => ipcRenderer.invoke("gateway-sso-complete", model), + }, updater: { subscribe: async (cb) => { updaterCallbacks.add(cb) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 284e92052d..8f4f7bed7c 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,6 +1,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" import type { SandboxesPlatform } from "@opencode-ai/app/sandbox/types" +import type { GatewaySsoPlatform } from "@opencode-ai/app/gateway/types" import type { UpdaterState } from "@opencode-ai/app/updater" export type { WslDistroProbe, @@ -24,6 +25,11 @@ export type { SandboxesEvent, SandboxesState, } from "@opencode-ai/app/sandbox/types" +export type { + GatewayCompleteResult, + GatewaySetupResult, + GatewaySsoState, +} from "@opencode-ai/app/gateway/types" export type ServerReadyData = { url: string @@ -33,6 +39,7 @@ export type ServerReadyData = { export type WslServersAPI = WslServersPlatform export type SandboxesAPI = SandboxesPlatform +export type GatewaySsoAPI = GatewaySsoPlatform export type UpdaterAPI = { subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> check: () => Promise @@ -58,6 +65,7 @@ export type ElectronAPI = { awaitInitialization: () => Promise wslServers: WslServersAPI sandboxes: SandboxesAPI + gatewaySso: GatewaySsoAPI updater: UpdaterAPI consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index fea4557c25..cd19d9ad03 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -299,6 +299,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { sandboxes: window.api.sandboxes, + gatewaySso: window.api.gatewaySso, + getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) }, diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 39ef8ea1c7..aeb872c1eb 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -25,6 +25,10 @@ type Active = { owner: Owner onClose?: () => void setClosing: (closing: boolean) => void + // While set, user-driven closes (Escape, backdrop click, Kobalte outside + // interaction) are no-ops for this dialog; programmatic close() still works + // so flows can advance. Dialogs pushed on top stay dismissable. + persistent: boolean } const Context = createContext>() @@ -34,6 +38,17 @@ function init() { const timer = { current: undefined as ReturnType | undefined } const lock = { value: false } + const isPersistent = (id?: string) => { + const items = stack() + const current = id ? items.find((item) => item.id === id) : items.at(-1) + return current?.persistent === true + } + + const setPersistent = (value: boolean) => { + const current = stack().at(-1) + if (current) current.persistent = value + } + onCleanup(() => { if (timer.current === undefined) return clearTimeout(timer.current) @@ -67,7 +82,7 @@ function init() { const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return - close() + if (!isPersistent()) close() event.preventDefault() event.stopPropagation() } @@ -92,6 +107,7 @@ function init() { open={!closing()} onOpenChange={(open: boolean) => { if (open) return + if (isPersistent(id)) return close(id) }} > @@ -99,7 +115,10 @@ function init() { close(id)} + onClick={() => { + if (isPersistent(id)) return + close(id) + }} />
[...items, active]) } @@ -152,6 +171,8 @@ function init() { close, show, push, + isPersistent, + setPersistent, } } @@ -193,5 +214,11 @@ export function useDialog() { close() { ctx.close() }, + get persistent() { + return ctx.isPersistent() + }, + setPersistent(value: boolean) { + ctx.setPersistent(value) + }, } } From 2d2533200c79f8d00927757ae3391d899d27565c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 17 Aug 2026 11:06:02 +0700 Subject: [PATCH 2/2] fix(desktop): hand new gateway credentials to the running sandboxes A sandbox guest is given its codev config once, at boot: the provider block resolveSandboxEgress builds from the gateway credentials, injected through CODEV_CONFIG, with the guest's file watcher off. Signing in afterwards writes the auth file and patches the local server's config, so Code gets the model that was just chosen while Work is left with a guest whose config is nothing but `{ "permission": "allow" }` -- no provider, no model, and no broker either, since a start with no credentials resolves an empty route table and never opens one. The configure gate makes that the guaranteed first run: sandboxes start at launch, the gate blocks until a provider is configured, so every sandbox is already up and credential-less by the time the login completes. The guest cannot be told about this without restarting, so the SSO controller takes an onConfigured callback -- fired after saveApiKey, so readers of the auth file see the new credentials -- and main wires it to the sandbox controller's new restartRunning(), which restarts the ready sandboxes so their next start re-resolves egress. Nothing is interrupted: the configure flow is only reachable while no provider is configured, so those guests had no model to be working with in the first place. Only sandboxes that are ready. Restarting one that is still starting would run a second guest against the same instance disk until the first one's stale-attempt guard caught up. Verified against the real sandbox: with credentials on disk, a start now injects the netgate provider at the broker stub, and the guest server reports netgate connected with the chosen model. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +++ DIVERGENCES.md | 2 +- packages/codev-sandbox/src/controller.test.ts | 54 +++++++++++++++++++ packages/codev-sandbox/src/controller.ts | 18 +++++++ .../src/main/gateway-sso/controller.test.ts | 27 ++++++++++ .../src/main/gateway-sso/controller.ts | 9 ++++ packages/desktop/src/main/index.ts | 10 ++++ 7 files changed, 127 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d605891dc1..3f446495a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ file is the human-readable summary of what changed and why. endpoint), or **Connect provider** (Anthropic, OpenAI, and others). The gate applies to the local server only; escaping any sub-flow returns to it, and finishing setup in another window (or in the CLI) closes it automatically. +- **Signing in now reaches your sandboxes too** — a sandbox is handed its model + provider when it starts, so signing in afterwards left **Work** with no model + at all while **Code** had the one you just picked, and the only way to fix it + was stopping and starting the sandbox by hand. Finishing setup now restarts + the sandboxes that are up, so the model you chose is there in Work as well. + Only sandboxes that are running are touched, and nothing is interrupted: + setup is only offered while no provider is configured, so those sandboxes had + no model to work with either. - **Upstream's hosted providers are no longer offered** — the OpenCode Zen free models and OpenCode Go no longer appear anywhere in the desktop or web UI (model pickers, connect dialog, settings), and a fresh install no longer diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 620ba65f02..8bec08ab47 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -199,7 +199,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` capability (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`, `packages/app/src/components/prompt-input.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. | 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; 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` 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. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. | | `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | diff --git a/packages/codev-sandbox/src/controller.test.ts b/packages/codev-sandbox/src/controller.test.ts index d2f1a5ab24..e3340ebe83 100644 --- a/packages/codev-sandbox/src/controller.test.ts +++ b/packages/codev-sandbox/src/controller.test.ts @@ -696,3 +696,57 @@ test("stopAll stops running sandboxes and invalidates pending starts", async () controller.stopAll() expect(sandbox.stopped).toBe(true) }) + +// Signing in after a sandbox booted leaves its guest holding a config with no +// provider, because the provider resolves egress in start(). The restart is +// what re-resolves it. +test("restartRunning restarts the ready sandboxes and leaves the rest alone", async () => { + const starts: string[] = [] + const running = new Map>() + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async (input) => { + starts.push(input.id) + const sandbox = fakeRunning(`http://127.0.0.1:4200`) + running.set(input.id, sandbox) + return sandbox + }, + }), + }, + readSandboxes: () => [config, { ...other, stopped: true }], + writeSandboxes: () => undefined, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + const first = running.get(config.id) + expect(starts).toEqual([config.id]) + + await controller.restartRunning() + await waitForRuntimeKind(controller, config.id, "ready") + // The ready one came back up; the stopped one stayed down and was never + // started, which is also what keeps its stopped flag meaningful. + expect(starts).toEqual([config.id, config.id]) + expect(first?.stopped).toBe(true) + expect(runtimeOf(controller, other.id)).toEqual({ kind: "stopped" }) +}) + +test("restartRunning skips a sandbox that failed to start", async () => { + let starts = 0 + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => { + starts++ + throw new Error("no image") + }, + }), + }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "failed") + await controller.restartRunning() + expect(starts).toBe(1) +}) diff --git a/packages/codev-sandbox/src/controller.ts b/packages/codev-sandbox/src/controller.ts index edf0764187..ed4f819916 100644 --- a/packages/codev-sandbox/src/controller.ts +++ b/packages/codev-sandbox/src/controller.ts @@ -333,6 +333,24 @@ export function createSandboxController(options: SandboxControllerOptions) { await startSandboxInternal(id) }, + // Restarts every sandbox that is up, so its guest picks up host-side state + // it only reads at boot — the injected codev config and the broker's route + // table, both resolved in the provider's start(). Stopped, failed and + // half-created sandboxes need nothing: whenever they next start they + // resolve everything fresh. + // + // Deliberately only sandboxes that are `ready`. Restarting one that is + // still starting would run a second guest against the same instance disk + // until the first one's stale-attempt guard caught up, and the window this + // is called in (finishing first-run setup) has had every sandbox up since + // launch. + async restartRunning() { + for (const item of state.sandboxes) { + if (item.runtime.kind !== "ready") continue + await startSandboxInternal(item.config.id) + } + }, + // Shuts the guest down and keeps it down: unlike a failed or restarting // sandbox this is a decision, so it persists and survives a relaunch. // Cancels a start already in flight too — the stale-attempt guard makes it diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index a25200424c..c976ef93b4 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -146,6 +146,33 @@ describe("gateway sso controller", () => { expect(Object.keys(provider.netgate.models)).toEqual(["model-a", "model-b"]) }) + // The sandboxes read the auth file at boot, so they have to be told a login + // happened — and only after it is on disk, or they would re-resolve egress + // from the credentials they already had. + test("complete announces the new credentials once they are saved", async () => { + const order: string[] = [] + const controller = createGatewaySsoController({ + openUrl: async () => {}, + onConfigured: () => order.push("configured"), + gateway: { + silentSso: async () => auth, + fetchCodevConfig: async () => ({ + supabaseUrl: "s", + supabaseAnonKey: "k", + gatewayUrl: "https://gw.example.test", + }), + fetchApiKey: async () => "sk-live", + saveCodevConfig: () => {}, + saveApiKey: () => order.push("saved"), + }, + }) + + await controller.startLogin() + await controller.fetchSetup() + controller.complete("model-a") + expect(order).toEqual(["saved", "configured"]) + }) + test("a manually entered key records its base URL", async () => { const savedKeys: { baseUrl?: string }[] = [] const controller = controllerWith({ diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index f45eb601c7..190be9a8fb 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -18,6 +18,11 @@ type Deps = { openUrl: (url: string) => Promise // Electron's net.fetch so enterprise proxies and PAC files apply. fetchImpl?: typeof fetch + // 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 + // credentials that appear afterwards. + onConfigured?: () => void // Injectable for tests; defaults to @codev/gateway. gateway?: Partial } @@ -181,6 +186,10 @@ export function createGatewaySsoController(deps: Deps) { // SSO-minted keys write no base_url to the auth file; only a manually // entered key records the URL it belongs to (mirrors the TUI flow). gateway.saveApiKey({ apiKey, baseUrl: manualKey ? gatewayUrl : undefined, model }) + // After the write, so anything reading the auth file sees the new + // credentials, and before the caller patches the local server's config — + // the two are independent consumers of the same login. + deps.onConfigured?.() return { providerID: GATEWAY_PROVIDER.id, config: buildGatewayConfig({ apiKey, baseUrl: gatewayUrl, model, models }), diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 475a70d0e9..e7ca4da3a4 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -323,6 +323,16 @@ 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]), + // 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 + // one. Restarting the running sandboxes is what re-resolves egress — + // guest config and broker routes alike. Nothing is interrupted: the + // configure flow is only reachable while no provider is configured, so + // those guests have no model to be working with in the first place. + onConfigured: () => { + void sandboxes.restartRunning().catch((error) => logger.error("sandbox egress refresh failed", error)) + }, }), ) void updater.start()