diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 54372cce2f9..a58a1f2e0c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -755,7 +755,11 @@ material off it. Muse Spark is also reachable through resellers, with a narrower **Meta Muse Code (`meta-muse`).** A plain macOS login first tries the API key already stored by `muse login`. With no local credential, or on another platform, it starts the -browser device-approval flow. Add-account and reauthentication skip local import to avoid +browser device-approval flow. The dashboard shows the verification URL, human-readable +device code, and current provider instructions. Approve the code in the browser; OpenCodex +keeps polling and saves the account automatically, with no callback paste required. +The paste field stays hidden while a device code is active, and cancellation clears the hint. +Add-account and reauthentication skip local import to avoid reusing the account being replaced. OpenCodex never launches the Muse CLI. If device login fails without cancellation, an available manual-input surface can accept a pasted key; that key faces the same format and Model API validation as an imported key. See diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index 6f9d30f85cb..2e705ec757d 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -119,7 +119,7 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH )} {hint.instructions &&
{hint.instructions}
} - {paste && ( + {paste && !deviceCode && (
{t("prov.pasteRedirectHint")}
diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index b28fa4473c4..7c19b82a6ce 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; +import type { LoginHintData } from "./login-url-block"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; @@ -123,15 +124,20 @@ export function useAddProviderOAuth({ await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current || !isCurrent()) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); - const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; + const s = sRes ? await readJsonIfOk(sRes) : null; if (!aliveRef.current || !isCurrent()) return; + if (s) { + setOauthUrl(s.url ?? "", providerId, s.deviceCode, s.instructions); + if (!s.done && !s.error) setOauthMsg(s.url || s.deviceCode + ? t("modal.waitingLogin") : (s.instructions || t("modal.loggingIn"))); + } if (s?.error) { activeProvidersRef.current.delete(providerId); setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } - if (s?.loggedIn) { + if (s?.loggedIn && s.done !== false) { activeProvidersRef.current.delete(providerId); onAdded(providerId); return; @@ -150,7 +156,10 @@ export function useAddProviderOAuth({ setOauthMsg(t("modal.networkError")); } } finally { - if (aliveRef.current && isCurrent()) setOauthBusy(false); + if (aliveRef.current && isCurrent()) { + setOauthBusy(false); + setOauthUrl("", providerId); + } } }, [aliveRef, apiBase, bumpLoginGeneration, cancelServerLogin, onAdded, t]); diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 347e314b2de..53b8073fe45 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -754,3 +754,30 @@ for (const surface of ["providers", "modal"] as const) { } }); } + +test("modal polling replaces device hints, restores manual fallback, and settles automatically", async () => { + const server = raceServer(); + const settled: string[] = []; + try { + await mountRaceSurface("modal", settled); + await act(async () => { server.logins[0]!.resolve(Response.json({ url: A_URL, deviceCode: "FIRST-CODE", instructions: "Approve first" })); }); + expect(host.textContent).toContain("FIRST-CODE"); + expect(host.querySelector(".login-hint-paste")).toBeNull(); + server.holdStatus(Promise.resolve(Response.json({ loggedIn: true, done: false, url: B_URL, deviceCode: "NEXT-CODE", instructions: "Approve next" }))); + await server.tick(); + expect(settled).toEqual([]); + expect(host.textContent).toContain("NEXT-CODE"); + expect(host.textContent).toContain("Approve next"); + expect(host.textContent).not.toContain("FIRST-CODE"); + expect(host.textContent).not.toContain(A_URL); + server.holdStatus(Promise.resolve(Response.json({ loggedIn: false, done: false, url: "", instructions: "Use manual fallback" }))); + await server.tick(); + expect(host.textContent).not.toContain("NEXT-CODE"); + expect(host.textContent).not.toContain(B_URL); + expect(host.textContent).toContain("Use manual fallback"); + expect(host.querySelector(".login-hint-paste")).not.toBeNull(); + server.holdStatus(undefined); + await server.finish(); + expect(settled).toEqual(["claude"]); + } finally { await server.dispose(); } +}); diff --git a/gui/tests/login-url-block.test.tsx b/gui/tests/login-url-block.test.tsx index 77e283a2e8a..7c33d20fe77 100644 --- a/gui/tests/login-url-block.test.tsx +++ b/gui/tests/login-url-block.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; -import { LoginUrlBlock } from "../src/components/login-url-block"; +import { LoginHint, LoginUrlBlock } from "../src/components/login-url-block"; /** * Contract for the shared OAuth login-URL block. It owns the copy state for @@ -177,3 +177,18 @@ test("renders nothing without a URL", async () => { expect(host.querySelector(".login-url-block")).toBeNull(); expect(host.textContent).not.toContain("Copy link"); }); + +test("device hints hide callback paste until the flow switches to manual input", async () => { + const { createRoot } = await import("react-dom/client"); + const paste = { value: "", busy: false, message: "", ok: true, onChange: () => {}, onSubmit: () => {} }; + for (const deviceCode of ["ABCD-EFGH", undefined]) { + await act(async () => { + root ??= createRoot(host); + root.render(); + }); + expect(host.textContent).toContain(URL_A); + expect(host.textContent).toContain("Approve in browser"); + expect(host.querySelector("input") !== null).toBe(!deviceCode); + if (deviceCode) expect(host.textContent).toContain(deviceCode); + } +}); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 84d76a6d520..69bc1b4cf1e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1764,7 +1764,7 @@ export interface OAuthAccountSummary { * the config at its request boundary and resolves the policy there with `emailMaskingEnabled`. * The default masks, so every existing caller keeps today's behaviour. */ -export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } { +export function getLoginStatus(provider: string, maskEmails = true): { url?: string; deviceCode?: string; instructions?: string; loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } { const cred = getCredential(provider); const st = loginState.get(provider); const set = getAccountSet(provider); @@ -1795,6 +1795,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: source: cred?.source, error: st?.error, done: st?.done ?? false, + ...(!st?.done ? st?.hint : undefined), ...(set ? { activeAccountId: set.activeAccountId, accounts } : {}), }; } @@ -1849,6 +1850,8 @@ export async function startLoginFlow( let urlResolved = false; const ctrl: OAuthController = { onAuth: ({ url, instructions, deviceCode }) => { + if (loginAbort.get(provider)?.controller !== abort) return; + loginState.set(provider, { done: false, hint: { url, instructions, deviceCode } }); urlResolved = true; resolve({ url, instructions, deviceCode }); }, diff --git a/src/oauth/login-flow-state.ts b/src/oauth/login-flow-state.ts index 1a32dd23b47..109124a5e71 100644 --- a/src/oauth/login-flow-state.ts +++ b/src/oauth/login-flow-state.ts @@ -1,3 +1,4 @@ +import type { OAuthController } from "./types"; import { parseCallbackInput } from "./callback-server"; import { retainedUtf8Bytes } from "../lib/admission"; import type { GenerationContext } from "../lib/state-store-sweeper"; @@ -10,7 +11,7 @@ import type { GenerationContext } from "../lib/state-store-sweeper"; * a login that has started and not yet settled, and none of it reads or writes a stored * credential. `index.ts` re-exports the two public names, so existing importers are unaffected. */ -export const loginState = new Map(); +export const loginState = new Map>[0] }>(); export const loginAbort = new Map(); export const kiroLoginSettling = new Set(); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9f23cb400b3..7a004cde8c7 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -182,7 +182,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the five operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged; it never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | -| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | +| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Active status polling returns the latest `url`, `deviceCode`, and `instructions`, replacing earlier hints; cancellation clears them. The add-provider UI hides callback paste while `deviceCode` is present and waits for settlement even if an older account is already logged in. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: paired dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index d09433afca1..b5e413fc23c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -13,6 +13,10 @@ device login, add-account and reauthentication. This principal is not a checkbox forged GUI headers and raw management credentials do not substitute for it. Direct CLI login and other OAuth providers retain their existing policies. `src/oauth/meta-muse-device.ts` cancels unparsed authorization/mint failures, including mint429, without reflecting their bodies. +`src/oauth/login-flow-state.ts` retains the current flow-owned `onAuth` hint; +`getLoginStatus` in `src/oauth/index.ts` exposes its URL, human-readable device code and +instructions only while active. Every hint replaces the previous one, and cancellation or +settlement discards it. Late hints from a superseded flow cannot overwrite current state. The capture-only bridge in `src/adapters/coding-agent/turn.ts` reports staging failures with the fixed `tool_bridge_setup_failed` error, never an OS error carrying private file paths. diff --git a/tests/oauth/oauth-login-open-browser.test.ts b/tests/oauth/oauth-login-open-browser.test.ts index 676641a305e..dba7bce0d77 100644 --- a/tests/oauth/oauth-login-open-browser.test.ts +++ b/tests/oauth/oauth-login-open-browser.test.ts @@ -73,3 +73,32 @@ describe("POST /api/oauth/login browser opening", () => { expect(body.deviceCode).toBeUndefined(); }); }); + +test("login status replaces live hints and ignores hints from cancelled flows", async () => { + const oauth = await import("../../src/oauth"); + const controllers: import("../../src/oauth/types").OAuthController[] = []; + const pending = Promise.withResolvers(); + const first = { url: "https://example.test/device", deviceCode: "ABCD-EFGH", instructions: "Approve code" }; + const login = spyOn(oauth.OAUTH_PROVIDERS["meta-muse"]!, "login").mockImplementation(async ctrl => { + controllers.push(ctrl); + ctrl.onAuth?.(first); + return pending.promise; + }); + try { + await oauth.startLoginFlow("meta-muse"); + expect(oauth.getLoginStatus("meta-muse")).toMatchObject(first); + controllers[0]!.onAuth?.({ url: "", instructions: "Paste the fallback key" }); + expect(oauth.getLoginStatus("meta-muse")).toMatchObject({ url: "", instructions: "Paste the fallback key" }); + expect(oauth.getLoginStatus("meta-muse").deviceCode).toBeUndefined(); + oauth.cancelLoginFlow("meta-muse"); + expect(oauth.getLoginStatus("meta-muse").instructions).toBeUndefined(); + await oauth.startLoginFlow("meta-muse"); + controllers[0]!.onAuth?.({ url: "https://example.test/stale" }); + expect(oauth.getLoginStatus("meta-muse")).toMatchObject(first); + } finally { + oauth.clearLoginState("meta-muse"); + pending.reject(new Error("test cleanup")); + login.mockRestore(); + await new Promise(resolve => setTimeout(resolve, 0)); + } +}); diff --git a/tests/oauth/oauth-reauth-bind.test.ts b/tests/oauth/oauth-reauth-bind.test.ts index 27ae71607eb..238c0848b99 100644 --- a/tests/oauth/oauth-reauth-bind.test.ts +++ b/tests/oauth/oauth-reauth-bind.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync} from "node:fs"; import { join } from "node:path"; -import { OAUTH_PROVIDERS, runLogin } from "../../src/oauth"; +import { OAUTH_PROVIDERS, runLogin, startLoginFlow, getLoginStatus, clearLoginState } from "../../src/oauth"; import { getAccountCredential, getAccountSet, saveCredential } from "../../src/oauth/store"; import type { OAuthController, OAuthCredentials } from "../../src/oauth/types"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -309,3 +309,27 @@ describe("OAuth account-scoped reauth", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + +test("device approval persists the account and clears its hint without callback input", async () => { + const original = OAUTH_PROVIDERS["meta-muse"]!.login; + const approved = Promise.withResolvers(); + const settled = Promise.withResolvers(); + OAUTH_PROVIDERS["meta-muse"]!.login = async ctrl => { + ctrl.onAuth?.({ url: "https://example.test/device", deviceCode: "ABCD-EFGH", instructions: "Approve code" }); + return approved.promise; + }; + try { + await startLoginFlow("meta-muse", undefined, { onSettled: () => { settled.resolve(); } }); + expect(getLoginStatus("meta-muse").done).toBe(false); + approved.resolve({ access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, source: "oauth", email: "device@example.test" }); + await settled.promise; + await new Promise(resolve => setTimeout(resolve, 0)); + expect(getLoginStatus("meta-muse")).toMatchObject({ done: true, loggedIn: true }); + expect(getLoginStatus("meta-muse").deviceCode).toBeUndefined(); + expect(getLoginStatus("meta-muse").url).toBeUndefined(); + expect(getAccountSet("meta-muse")?.accounts).toHaveLength(1); + } finally { + clearLoginState("meta-muse"); + OAUTH_PROVIDERS["meta-muse"]!.login = original; + } +});