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 &&
{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;
+ }
+});