diff --git a/CHANGELOG.md b/CHANGELOG.md index da64a69a5b..3f446495a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,28 @@ 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. +- **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 + 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..8bec08ab47 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, 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). | | 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/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 new file mode 100644 index 0000000000..c976ef93b4 --- /dev/null +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -0,0 +1,194 @@ +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"]) + }) + + // 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({ + 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..190be9a8fb --- /dev/null +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -0,0 +1,199 @@ +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 + // 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 +} + +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 }) + // 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/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..e7ca4da3a4 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,26 @@ 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]), + // 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() 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) + }, } }