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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gui/src/components/login-url-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH
)}
<LoginUrlBlock url={url} />
{hint.instructions && <div className="muted text-label">{hint.instructions}</div>}
{paste && (
{paste && !deviceCode && (
<div className="login-hint-paste">
<div className="muted text-label">{t("prov.pasteRedirectHint")}</div>
<div className="login-hint-paste-row">
Expand Down
15 changes: 12 additions & 3 deletions gui/src/components/use-add-provider-oauth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<LoginHintData & { loggedIn?: boolean; done?: boolean; error?: string }>(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;
Expand All @@ -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]);

Expand Down
27 changes: 27 additions & 0 deletions gui/tests/add-provider-oauth-url-leak.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
17 changes: 16 additions & 1 deletion gui/tests/login-url-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<LanguageProvider><LoginHint hint={{ url: URL_A, deviceCode, instructions: "Approve in browser" }} paste={paste} /></LanguageProvider>);
});
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);
}
});
5 changes: 4 additions & 1 deletion src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 } : {}),
};
}
Expand Down Expand Up @@ -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 });
},
Expand Down
3 changes: 2 additions & 1 deletion src/oauth/login-flow-state.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, { error?: string; done: boolean }>();
export const loginState = new Map<string, { error?: string; done: boolean; hint?: Parameters<NonNullable<OAuthController["onAuth"]>>[0] }>();
export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
export const kiroLoginSettling = new Set<string>();

Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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. |
Expand Down
4 changes: 4 additions & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions tests/oauth/oauth-login-open-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>();
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));
}
});
Loading
Loading