diff --git a/packages/webui/src/client/components/AccountLoginDialog.tsx b/packages/webui/src/client/components/AccountLoginDialog.tsx
new file mode 100644
index 00000000..4efaae81
--- /dev/null
+++ b/packages/webui/src/client/components/AccountLoginDialog.tsx
@@ -0,0 +1,312 @@
+// AccountLoginDialog — the browser's device-authorization login.
+//
+// The WebUI previously had no login at all: it served whatever credential the
+// terminal client had written, and 退出登录 did not remove it, so neither
+// signing in nor switching accounts was possible without leaving the
+// browser. This dialog is the missing surface for both rows of the roadmap's
+// 登录与账号 group:
+//
+// * 登录方式 — the device-authorization flow the terminal client uses,
+// rendered for the browser: a user code, the page to enter it on, and a
+// poll until the server observes the credential.
+// * 登出·账号切换 — the same dialog is the switch entry: already signed in,
+// it offers 切换账号, which signs the current account out for real and
+// starts a fresh device flow in one place.
+//
+// Split in two so the node suite can cover every phase without a DOM: the
+// `AccountLoginPanel` is a pure view over an injected phase (the suite's
+// `renderToStaticMarkup` convention), and the dialog owns the effects —
+// start on open, poll while pending, stop on close.
+
+import { useEffect, useRef, useState, type ReactElement } from "react";
+import { createPortal } from "react-dom";
+import type { WebuiAccountLoginView } from "../../server/port.js";
+
+/** Everything the panel can show, derived (not fetched). */
+export type WebuiAccountLoginPhase =
+ | { readonly kind: "loading" }
+ | {
+ readonly kind: "pending";
+ readonly userCode?: string;
+ readonly verificationUri?: string;
+ readonly verificationUriComplete?: string;
+ }
+ | { readonly kind: "authenticated" }
+ | { readonly kind: "error"; readonly error: string };
+
+/**
+ * Derive the view phase from a server answer. `idle` with no attempt means
+ * the dialog just started (or the host has no session); a `pending` answer
+ * without a prompt yet is the network being slower than the first reply —
+ * the panel says 等待设备码 rather than guessing a code.
+ */
+export function deriveWebuiAccountLoginPhase(
+ view: WebuiAccountLoginView | undefined,
+): WebuiAccountLoginPhase {
+ if (!view) return { kind: "loading" };
+ switch (view.state) {
+ case "authenticated":
+ return { kind: "authenticated" };
+ case "error":
+ return { kind: "error", error: view.error };
+ case "pending":
+ return {
+ kind: "pending",
+ userCode: view.prompt?.userCode,
+ verificationUri: view.prompt?.verificationUri,
+ verificationUriComplete: view.prompt?.verificationUriComplete,
+ };
+ default:
+ return { kind: "loading" };
+ }
+}
+
+export function AccountLoginPanel({
+ phase,
+ onRetry,
+ onCancel,
+ onSwitch,
+ onClose,
+}: {
+ readonly phase: WebuiAccountLoginPhase;
+ /** Error → start a fresh device flow. */
+ readonly onRetry: () => void;
+ /** Pending → cancel the in-flight attempt. */
+ readonly onCancel: () => void;
+ /** Authenticated → sign out, then start a fresh device flow. */
+ readonly onSwitch: () => void;
+ readonly onClose: () => void;
+}): ReactElement {
+ return (
+
+
+ {phase.kind === "loading" ? (
+
+ 正在获取登录码…
+
+ ) : null}
+ {phase.kind === "pending" ? (
+
+ {phase.userCode ? (
+ <>
+
在打开的页面中输入以下代码
+
+ {phase.userCode}
+
+ >
+ ) : (
+
+ 等待设备码…
+
+ )}
+ {phase.verificationUri ? (
+
+ 打开授权页面
+
+ ) : null}
+
+ 等待授权中…完成页面上的登录后会自动继续
+
+
+
+ 取消
+
+
+
+ ) : null}
+ {phase.kind === "authenticated" ? (
+
+
+ 已登录。切换账号会先退出当前账号,再开始新的登录。
+
+
+
+ 切换账号
+
+
+
+ ) : null}
+ {phase.kind === "error" ? (
+
+
+ 登录未完成:{phase.error}
+
+
+
+ 重新获取登录码
+
+
+
+ ) : null}
+
+ );
+}
+
+export function AccountLoginDialog({
+ open,
+ onClose,
+ onAuthenticated,
+ beginAccountLogin,
+ getAccountLoginStatus,
+ cancelAccountLogin,
+ signOut,
+}: {
+ readonly open: boolean;
+ readonly onClose: () => void;
+ /** Fires once per transition into the authenticated state, so the host can
+ * refresh whatever account surfaces it shows. */
+ readonly onAuthenticated?: () => void;
+ readonly beginAccountLogin?: () => Promise;
+ readonly getAccountLoginStatus?: () => Promise;
+ readonly cancelAccountLogin?: () => Promise<{ readonly ok: true }>;
+ readonly signOut?: () => Promise<{ readonly success?: boolean }>;
+}): ReactElement | null {
+ const [phase, setPhase] = useState({ kind: "loading" });
+ const authenticatedNotifiedRef = useRef(false);
+
+ useEffect(() => {
+ if (!open) return;
+ // Reset per open: a closed dialog forgets the previous attempt's state,
+ // and the authenticated notification must fire again next time.
+ authenticatedNotifiedRef.current = false;
+ let cancelled = false;
+ setPhase({ kind: "loading" });
+ const start = () => {
+ if (!beginAccountLogin) {
+ setPhase({ kind: "error", error: "当前服务未提供账号登录" });
+ return;
+ }
+ void beginAccountLogin()
+ .then((view) => {
+ if (!cancelled) setPhase(deriveWebuiAccountLoginPhase(view));
+ })
+ .catch((error: unknown) => {
+ if (!cancelled)
+ setPhase({
+ kind: "error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
+ };
+ start();
+ // Poll while the dialog is open: the credential lands on the server, and
+ // only the server can see the authorization complete.
+ const timer = window.setInterval(() => {
+ if (!getAccountLoginStatus) return;
+ void getAccountLoginStatus()
+ .then((view) => {
+ if (cancelled) return;
+ const next = deriveWebuiAccountLoginPhase(view);
+ // The first reply may still be `loading` while the attempt starts;
+ // never walk a live phase back to loading — only forward matters.
+ setPhase((current) =>
+ next.kind === "loading" && current.kind !== "loading" ? current : next,
+ );
+ if (next.kind === "authenticated" && !authenticatedNotifiedRef.current) {
+ authenticatedNotifiedRef.current = true;
+ onAuthenticated?.();
+ }
+ })
+ .catch(() => undefined);
+ }, 2_000);
+ return () => {
+ cancelled = true;
+ window.clearInterval(timer);
+ };
+ // `phase` is deliberately not a dependency: the poll closure reading it
+ // would restart the interval on every poll's own setState.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, beginAccountLogin, getAccountLoginStatus]);
+
+ if (!open) return null;
+ const dialog = (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ >
+
{
+ setPhase({ kind: "loading" });
+ void beginAccountLogin?.()
+ .then((view) => setPhase(deriveWebuiAccountLoginPhase(view)))
+ .catch((error: unknown) =>
+ setPhase({
+ kind: "error",
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }}
+ onCancel={() => {
+ void cancelAccountLogin?.().catch(() => undefined);
+ onClose();
+ }}
+ onSwitch={() => {
+ // The real switch: sign out (revoke + wipe) and immediately start
+ // a fresh device flow, in the same surface.
+ setPhase({ kind: "loading" });
+ void signOut?.()
+ .then(() => beginAccountLogin?.())
+ .then((view) => {
+ if (view) setPhase(deriveWebuiAccountLoginPhase(view));
+ })
+ .catch((error: unknown) =>
+ setPhase({
+ kind: "error",
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }}
+ onClose={onClose}
+ />
+
+ );
+ return typeof document !== "undefined"
+ ? createPortal(dialog, document.body)
+ : dialog;
+}
diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx
index 3377d53a..e3c2102b 100644
--- a/packages/webui/src/client/components/SettingsModal.tsx
+++ b/packages/webui/src/client/components/SettingsModal.tsx
@@ -68,6 +68,9 @@ export type WebuiSettingsModalCapabilities = Pick<
| "selectModel"
| "getUsageQuota"
| "getAccountStatus"
+ | "beginAccountLogin"
+ | "getAccountLoginStatus"
+ | "cancelAccountLogin"
| "listUserModelProviders"
| "listArchivedSessions"
| "archiveSession"
diff --git a/packages/webui/src/client/components/UserMenu.tsx b/packages/webui/src/client/components/UserMenu.tsx
index 1739cd07..54b4807b 100644
--- a/packages/webui/src/client/components/UserMenu.tsx
+++ b/packages/webui/src/client/components/UserMenu.tsx
@@ -18,6 +18,7 @@ import type {
} from "../../server/port.js";
import type { WebuiTransport } from "../contracts.js";
import { SettingsModal, type WebuiSettingsModalCapabilities } from "./SettingsModal.js";
+import { AccountLoginDialog } from "./AccountLoginDialog.js";
import { evaluateOutsideClose } from "../projection/outside-close.js";
type AccountStatus = Record;
@@ -581,7 +582,13 @@ function videoValueLabel(video: WebuiUsageQuotaVideoView): string {
const AUTH_ERROR_PATTERN = /cookie|unauthori[sz]ed|token|login/iu;
-function UsageReady({ result }: { readonly result?: WebuiUsageQuotaResult }): ReactElement {
+function UsageReady({
+ result,
+ onLogin,
+}: {
+ readonly result?: WebuiUsageQuotaResult;
+ readonly onLogin?: () => void;
+}): ReactElement {
if (!result || result.signedIn === false) {
return (
登录后查看用量
+ {/* The button's mousedown is default-prevented: focus would bubble
+ * (focusin) to the usage anchor's onFocus → loadUsage, whose loading
+ * state swaps this block for the skeleton — the pressed node is
+ * replaced mid-click and the browser never dispatches the click.
+ * Keeping the focus where it was makes the first click land like
+ * every later one. */}
+ {onLogin ? (
+ event.preventDefault()}
+ onClick={onLogin}
+ >
+ 登录
+
+ ) : null}
);
}
@@ -660,9 +684,12 @@ function UsageReady({ result }: { readonly result?: WebuiUsageQuotaResult }): Re
export function UsagePanel({
state,
onRetry,
+ onLogin,
}: {
readonly state: UsageState;
readonly onRetry: () => void;
+ /** Opens the account login dialog from the signed-out usage block. */
+ readonly onLogin?: () => void;
}): ReactElement {
return (
@@ -680,7 +707,7 @@ export function UsagePanel({
) : null}
- {state.status === "ready" ? : null}
+ {state.status === "ready" ? : null}
);
}
@@ -697,13 +724,21 @@ export function UserMenu({
getSigninPanel,
claimSignin,
}: UserMenuProps): ReactElement {
- const { getUsageQuota, getAccountStatus } = useMemo(() => ({
+ // Bound once per transport, not per render: the login dialog's effects
+ // key on these callbacks, and a fresh binding every render would restart
+ // its poll (and its begin) on every unrelated re-render.
+ const { getUsageQuota, getAccountStatus, beginAccountLogin, getAccountLoginStatus, cancelAccountLogin, signOut } = useMemo(() => ({
getUsageQuota: transport?.getUsageQuota?.bind(transport),
getAccountStatus: transport?.getAccountStatus?.bind(transport),
+ beginAccountLogin: transport?.beginAccountLogin?.bind(transport),
+ getAccountLoginStatus: transport?.getAccountLoginStatus?.bind(transport),
+ cancelAccountLogin: transport?.cancelAccountLogin?.bind(transport),
+ signOut: transport?.signOut?.bind(transport),
}), [transport]);
const anchorRef = useRef(null);
const [open, setOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
+ const [loginOpen, setLoginOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usage, setUsage] = useState({ status: "idle" });
const [signinOpen, setSigninOpen] = useState(false);
@@ -840,6 +875,7 @@ export function UserMenu({
UID : {uid ?? "—"}
{ setOpen(false); setSettingsOpen(true); }}>设置 Ctrl+,
+
{ setOpen(false); setLoginOpen(true); }}>账号与登录
loadSignin()} onFocus={() => loadSignin()} onClick={(event) => { event.stopPropagation(); if (!signinOpen) loadSignin(); }}>
每日签到
@@ -863,11 +899,22 @@ export function UserMenu({
loadUsage()} onFocus={() => loadUsage()}>
loadUsage()}>用量
- {usageOpen ? loadUsage(true)} /> : null}
+ {usageOpen ? loadUsage(true)} onLogin={() => { setOpen(false); setLoginOpen(true); }} /> : null}
: null}
+ setLoginOpen(false)}
+ onAuthenticated={() => {
+ loadUsage(true);
+ }}
+ beginAccountLogin={beginAccountLogin}
+ getAccountLoginStatus={getAccountLoginStatus}
+ cancelAccountLogin={cancelAccountLogin}
+ signOut={signOut}
+ />
{typeof document !== "undefined" ? createPortal( setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />, document.body) : setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />}
>;
}
diff --git a/packages/webui/src/client/contracts.ts b/packages/webui/src/client/contracts.ts
index a8e11593..b9cfd4ef 100644
--- a/packages/webui/src/client/contracts.ts
+++ b/packages/webui/src/client/contracts.ts
@@ -109,6 +109,7 @@ import type {
WebuiUpdateSessionResult,
WebuiUsageQuotaResult,
WebuiVersionInfo,
+ WebuiAccountLoginView,
WebuiAttachmentInput,
WebuiWorkspaceEnvironment,
WebuiWorkspaceDirectoryListing,
@@ -650,6 +651,11 @@ export interface WebuiTransport {
}) => Promise;
readonly getSigninPanel?: () => Promise;
readonly claimSignin?: () => Promise;
+ /** Account login (device authorization) and its polling/cancel pair.
+ * Optional like every capability: an un-wired host shows no login entry. */
+ readonly beginAccountLogin?: () => Promise;
+ readonly getAccountLoginStatus?: () => Promise;
+ readonly cancelAccountLogin?: () => Promise<{ readonly ok: true }>;
readonly getAccountStatus?: (request?: {
readonly sessionId?: string;
}) => Promise>;
diff --git a/packages/webui/src/client/styles/shell.css b/packages/webui/src/client/styles/shell.css
index bdd19dfe..c1e7119b 100644
--- a/packages/webui/src/client/styles/shell.css
+++ b/packages/webui/src/client/styles/shell.css
@@ -219,6 +219,19 @@
.webui-xterm-host .xterm-viewport { background: var(--bg_default_secondary) !important; }
.webui-terminal-actions { display: flex; gap: var(--spacing_8); }
.webui-settings-mask { position: fixed; inset: 0; z-index: 100; display: flex; background: var(--utility_scrim); animation: webui-overlay-enter 160ms ease-out both; }
+ /* The account login dialog: the settings mask's overlay treatment around a
+ * small centered card — a dialog, not a full page, because the entry points
+ * (user menu, usage panel) are popovers over the live shell. */
+ .webui-account-login-mask { position: fixed; inset: 0; z-index: 110; display: flex; align-items: center; justify-content: center; background: var(--utility_scrim); animation: webui-overlay-enter 160ms ease-out both; }
+ .webui-account-login { display: flex; width: min(420px, calc(100vw - 48px)); flex-direction: column; gap: 12px; padding: 20px; border-radius: var(--radius_12); background: var(--bg_default_secondary_elevated); color: var(--text_default_primary); animation: webui-modal-enter 200ms cubic-bezier(.2, .7, .2, 1) both; }
+ .webui-account-login-header { display: flex; align-items: center; justify-content: space-between; }
+ .webui-account-login-header button { border: 0; background: transparent; color: var(--text_default_secondary); font-size: 18px; cursor: pointer; }
+ .webui-account-login-body { display: flex; flex-direction: column; gap: 10px; align-items: center; text-align: center; }
+ .webui-account-login-hint { margin: 0; color: var(--text_default_secondary); }
+ .webui-account-login-code { margin: 0; font-size: 28px; font-weight: 600; letter-spacing: .12em; font-variant-numeric: tabular-nums; }
+ .webui-account-login-uri { color: var(--text_default_primary); }
+ .webui-account-login-status { margin: 0; color: var(--text_default_tertiary); }
+ .webui-account-login-actions { display: flex; gap: 8px; justify-content: center; }
.webui-settings-modal { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: var(--bg_default_primary); color: var(--text_default_primary); animation: webui-modal-enter 200ms cubic-bezier(.2, .7, .2, 1) both; }
.webui-settings-sidebar { width: 260px; flex: 0 0 260px; overflow-y: hidden; }
.webui-settings-back, .webui-settings-nav-item { display: flex; width: 100%; align-items: center; gap: var(--spacing_8); border: 0; background: transparent; color: var(--text_default_secondary); text-align: left; }
@@ -3495,6 +3508,10 @@
}
.webui-user-menu-usage-signed-out {
+ /* Flex so the 登录 button sits beside the copy, not under it. */
+ display: flex;
+ align-items: center;
+ gap: 8px;
margin: 0;
color: var(--text_default_secondary);
font-size: 13px;
diff --git a/packages/webui/src/client/transport.ts b/packages/webui/src/client/transport.ts
index 5accf8a8..87aa9e9d 100644
--- a/packages/webui/src/client/transport.ts
+++ b/packages/webui/src/client/transport.ts
@@ -479,6 +479,9 @@ export function createWebuiTransport({
triggerScheduledTaskNow: (body) => request("triggerScheduledTaskNow", body),
getScheduledTaskCapability: () => request("getScheduledTaskCapability", {}),
signOut: () => request("signOut", {}),
+ beginAccountLogin: () => request("beginAccountLogin", {}),
+ getAccountLoginStatus: () => request("getAccountLoginStatus", {}),
+ cancelAccountLogin: () => request("cancelAccountLogin", {}),
runCommand: (body) => request("runCommand", body),
} satisfies Required;
}
diff --git a/packages/webui/src/server/account-login.ts b/packages/webui/src/server/account-login.ts
new file mode 100644
index 00000000..0392c271
--- /dev/null
+++ b/packages/webui/src/server/account-login.ts
@@ -0,0 +1,158 @@
+// Account login session — the WebUI's device-authorization login, owned by
+// the server because the credential store lives there.
+//
+// The WebUI previously had no login at all: it served whatever OAuth
+// credential `mcode login` (or the desktop) had already written, and its
+// 退出登录 only cleared projections — the credential survived and the next
+// lease refresh signed the process right back in. Signing in and switching
+// accounts were therefore both impossible without leaving the browser.
+//
+// This module wraps one `MCodeOAuthCore` (the assembly already builds it for
+// quota leases) with the request/reply shape the browser needs:
+//
+// * `begin()` starts (or re-attaches to) a device-authorization login and
+// resolves with the prompt — the user code and verification URL the user
+// opens — without waiting for the human to authorize. The core already
+// dedupes concurrent `login()` calls on its own `loginPromise`, so a
+// second `begin()` while one is running re-attaches instead of racing.
+// * `status()` folds three sources into one answer: the settled outcome of
+// the last login attempt, the in-flight prompt, and — with no attempt
+// this process started — the core's persisted state (a TUI login counts).
+// * `cancel()` aborts an in-flight attempt; the credential is untouched.
+//
+// The prompt itself arrives on the core's `onDeviceAuthorization` callback,
+// one HTTP round trip into the login. `begin()` waits briefly for it so the
+// first reply usually carries the code; if the network is slow the caller
+// sees `pending` without a prompt and the next `status()` poll picks it up.
+
+import type {
+ AuthStatusSnapshot,
+ DeviceAuthorizationPrompt,
+} from "@mavis/oauth-core";
+
+/** The minimal core surface this session needs — the assembly passes its
+ * live `MCodeOAuthCore`; tests pass a fake. */
+export interface WebuiAccountLoginCore {
+ login(options: {
+ onDeviceAuthorization?: (authorization: DeviceAuthorizationPrompt) => void;
+ }): Promise<{ readonly status: "authenticated"; readonly generation: number }>;
+ cancelLogin(): Promise;
+ getStatus(): Promise;
+}
+
+export interface WebuiAccountLoginPrompt {
+ readonly userCode: string;
+ readonly verificationUri: string;
+ readonly verificationUriComplete?: string;
+ readonly expiresAtMs: number;
+}
+
+export type WebuiAccountLoginState =
+ | { readonly state: "idle" }
+ | {
+ readonly state: "pending";
+ readonly prompt?: WebuiAccountLoginPrompt;
+ }
+ | { readonly state: "authenticated" }
+ | { readonly state: "error"; readonly error: string };
+
+const PROMPT_CAPTURE_TIMEOUT_MS = 5_000;
+
+export interface WebuiAccountLoginSession {
+ begin(): Promise;
+ status(): Promise;
+ cancel(): Promise;
+}
+
+export function createWebuiAccountLoginSession(
+ core: WebuiAccountLoginCore,
+ now: () => number = Date.now,
+): WebuiAccountLoginSession {
+ let prompt: WebuiAccountLoginPrompt | undefined;
+ let running = false;
+ let cancelled = false;
+ let outcome: WebuiAccountLoginState | undefined;
+
+ const start = (): Promise => {
+ running = true;
+ cancelled = false;
+ prompt = undefined;
+ outcome = undefined;
+ // Resolves on the first prompt, or early if the attempt settles (or
+ // fails) before the device code even arrived — the caller then sees the
+ // same state the next `status()` poll would have reported.
+ return new Promise((resolve) => {
+ let settled = false;
+ const settle = (state: WebuiAccountLoginState) => {
+ if (settled) return;
+ settled = true;
+ resolve(state);
+ };
+ const timer = setTimeout(
+ () => settle({ state: "pending" }),
+ PROMPT_CAPTURE_TIMEOUT_MS,
+ );
+ void core
+ .login({
+ onDeviceAuthorization: (authorization) => {
+ prompt = {
+ userCode: authorization.userCode,
+ verificationUri: authorization.verificationUri,
+ ...(authorization.verificationUriComplete
+ ? { verificationUriComplete: authorization.verificationUriComplete }
+ : {}),
+ expiresAtMs: now() + authorization.expiresInSec * 1_000,
+ };
+ clearTimeout(timer);
+ settle({ state: "pending", prompt });
+ },
+ })
+ .then(() => {
+ outcome = { state: "authenticated" };
+ clearTimeout(timer);
+ settle(outcome);
+ })
+ .catch((error: unknown) => {
+ // A cancelled attempt is the user's own action, not a failure to
+ // show; the settled state goes back to whatever the store says.
+ outcome = cancelled
+ ? undefined
+ : {
+ state: "error",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ clearTimeout(timer);
+ settle(outcome ?? { state: "idle" });
+ })
+ .finally(() => {
+ running = false;
+ });
+ });
+ };
+
+ return {
+ begin: () => (running ? Promise.resolve({ state: "pending" as const, prompt }) : start()),
+ status: async () => {
+ if (outcome) return outcome;
+ if (running) return { state: "pending", prompt };
+ // No attempt this process started: the persisted state is the truth,
+ // and a login made in the terminal client counts here too.
+ try {
+ const snapshot = await core.getStatus();
+ return snapshot.status === "authenticated"
+ ? { state: "authenticated" }
+ : { state: "idle" };
+ } catch {
+ return { state: "idle" };
+ }
+ },
+ cancel: async () => {
+ if (!running) return;
+ cancelled = true;
+ await core.cancelLogin().catch(() => undefined);
+ running = false;
+ prompt = undefined;
+ outcome = undefined;
+ },
+ };
+}
diff --git a/packages/webui/src/server/assembly.ts b/packages/webui/src/server/assembly.ts
index 29e331a5..53fb0ae4 100644
--- a/packages/webui/src/server/assembly.ts
+++ b/packages/webui/src/server/assembly.ts
@@ -36,6 +36,7 @@ import {
createWebuiAuthContextReader,
type WebuiAuthContext,
} from "./auth-context.js";
+import { createWebuiAccountLoginSession } from "./account-login.js";
import { createHarnessPortFromHost } from "./host.js";
import type { WebuiRuntimeCliService } from "./host.js";
import type { WebuiHarnessPort } from "./port.js";
@@ -66,6 +67,16 @@ export interface WebuiAssembledHost {
}) => Promise;
readonly getSigninPanel: () => Promise;
readonly claimSignin: () => Promise;
+ /**
+ * Account login (device authorization) over the same `MCodeOAuthCore` the
+ * quota lease reads, plus the sign-out that removes the credential. The
+ * core is assembly-owned (never produced by the runtime factory), so these
+ * are enrichment slots like `getUsageQuota`, not harness capabilities.
+ */
+ readonly beginAccountLogin: () => Promise;
+ readonly getAccountLoginStatus: () => Promise;
+ readonly cancelAccountLogin: () => Promise;
+ readonly signOutAccount: () => Promise<{ readonly status: string; readonly generation: number }>;
/**
* Conversation compaction is opt-in on the live harness: `local-runtime-v2`
* exposes `CliService.requestCompaction?` and the WebUI host surface
@@ -155,7 +166,14 @@ export type WebuiRuntimeHostFactory = (
) => Promise<
Omit<
WebuiAssembledHost,
- "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin"
+ | "invalidateAuth"
+ | "getUsageQuota"
+ | "getSigninPanel"
+ | "claimSignin"
+ | "beginAccountLogin"
+ | "getAccountLoginStatus"
+ | "cancelAccountLogin"
+ | "signOutAccount"
>
>;
@@ -394,7 +412,14 @@ export async function createWebuiRuntimeHost(
// because the typecheck can't see the upstream shape.
let host: Omit<
WebuiAssembledHost,
- "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin"
+ | "invalidateAuth"
+ | "getUsageQuota"
+ | "getSigninPanel"
+ | "claimSignin"
+ | "beginAccountLogin"
+ | "getAccountLoginStatus"
+ | "cancelAccountLogin"
+ | "signOutAccount"
>;
try {
host = await factory(
@@ -500,6 +525,10 @@ export async function createWebuiRuntimeHost(
// enrichments must live on `host` itself — otherwise `getUsageQuota`
// only exists on `harnessPort` and the live panel fails with
// "runtime host does not expose the usage quota client".
+ // The account-login session shares the quota core deliberately: one
+ // credential store, one watch — a login (here or in the terminal client)
+ // refreshes both, and a sign-out clears both.
+ const accountLogin = createWebuiAccountLoginSession(quotaOauthCore);
const hostHandle = {
...host,
appVersion: options.appVersion ?? host.appVersion ?? "webui",
@@ -508,6 +537,18 @@ export async function createWebuiRuntimeHost(
usageQuota.getUsageQuota(request),
getSigninPanel: () => dailyCheckin.getSigninPanel(),
claimSignin: () => dailyCheckin.claimSignin(),
+ beginAccountLogin: () => accountLogin.begin(),
+ getAccountLoginStatus: () => accountLogin.status(),
+ cancelAccountLogin: () => accountLogin.cancel(),
+ // The real sign-out: revoke + remove the credential, then clear this
+ // process's projections. `invalidateAuth` alone only cleared projections
+ // — the credential survived and the next lease refresh signed straight
+ // back in, which is why 退出登录 never actually logged out.
+ signOutAccount: async () => {
+ const result = await quotaOauthCore.logout({ revoke: true });
+ invalidateAuth();
+ return result;
+ },
};
const harnessPort = createHarnessPortFromHost(hostHandle);
return {
@@ -532,6 +573,13 @@ const defaultWebuiRuntimeHostFactory: WebuiRuntimeHostFactory = async (
options,
)) as unknown as Omit<
WebuiAssembledHost,
- "invalidateAuth" | "getUsageQuota" | "getSigninPanel" | "claimSignin"
+ | "invalidateAuth"
+ | "getUsageQuota"
+ | "getSigninPanel"
+ | "claimSignin"
+ | "beginAccountLogin"
+ | "getAccountLoginStatus"
+ | "cancelAccountLogin"
+ | "signOutAccount"
>;
};
diff --git a/packages/webui/src/server/host.ts b/packages/webui/src/server/host.ts
index 796a8b9a..6b05a5d4 100644
--- a/packages/webui/src/server/host.ts
+++ b/packages/webui/src/server/host.ts
@@ -335,6 +335,16 @@ export interface WebuiRuntimeHostHandle {
*/
readonly getSigninPanel?: () => Promise;
readonly claimSignin?: () => Promise;
+ /**
+ * Account login (device authorization) and real sign-out. Supplied by the
+ * assembly from its `MCodeOAuthCore` — the same credential store the quota
+ * lease reads — so a login completed in the browser is the same credential
+ * the terminal client uses, and a sign-out actually removes it.
+ */
+ readonly beginAccountLogin?: () => Promise;
+ readonly getAccountLoginStatus?: () => Promise;
+ readonly cancelAccountLogin?: () => Promise;
+ readonly signOutAccount?: () => Promise<{ readonly status: string; readonly generation: number }>;
/**
* Source of every harness command the WebUI maps to operations. Owned by
* the runtime host; the WebUI only needs the structural shape to forward.
@@ -721,6 +731,27 @@ export function createHarnessPortFromHost(
throw new Error("runtime host does not expose the daily check-in client");
return host.claimSignin();
},
+ async beginAccountLogin() {
+ if (!host.beginAccountLogin)
+ throw new Error("runtime host does not expose account login");
+ return host.beginAccountLogin();
+ },
+ async getAccountLoginStatus() {
+ if (!host.getAccountLoginStatus)
+ throw new Error("runtime host does not expose account login");
+ return host.getAccountLoginStatus();
+ },
+ async cancelAccountLogin() {
+ if (!host.cancelAccountLogin)
+ throw new Error("runtime host does not expose account login");
+ await host.cancelAccountLogin();
+ return { ok: true as const };
+ },
+ async signOutAccount() {
+ if (!host.signOutAccount)
+ throw new Error("runtime host does not expose account sign-out");
+ return host.signOutAccount();
+ },
async getAccountStatus(request) {
return requireCliService(host).getAccountStatus(request);
},
diff --git a/packages/webui/src/server/oauth-core.d.ts b/packages/webui/src/server/oauth-core.d.ts
index 4ea96fb3..2b48be7f 100644
--- a/packages/webui/src/server/oauth-core.d.ts
+++ b/packages/webui/src/server/oauth-core.d.ts
@@ -56,6 +56,22 @@ declare module "@mavis/oauth-core" {
readonly audience: "agent-backend";
}
+ /** The device-authorization prompt `login()` emits before the human
+ * authorizes — the terminal client prints it; the WebUI renders it. */
+ export interface DeviceAuthorizationPrompt {
+ readonly userCode: string;
+ readonly verificationUri: string;
+ readonly verificationUriComplete?: string;
+ readonly expiresInSec: number;
+ }
+
+ export interface AuthStatusSnapshot {
+ readonly status: string;
+ readonly generation: number;
+ readonly scopes: readonly string[];
+ readonly expiresAtMs?: number;
+ }
+
export class MCodeOAuthCore {
constructor(options: {
readonly namespace: unknown;
@@ -72,5 +88,18 @@ declare module "@mavis/oauth-core" {
readonly loginEpoch?: string;
}): Promise<"retry" | "logout">;
watch(listener: (status: { readonly status: string }) => void): () => void;
+ /** Device-authorization login; resolves once the user authorizes.
+ * Concurrent calls share one attempt (the core's own dedupe). */
+ login(options?: {
+ onDeviceAuthorization?: (authorization: DeviceAuthorizationPrompt) => void;
+ }): Promise<{ readonly status: "authenticated"; readonly generation: number }>;
+ cancelLogin(): Promise;
+ /** Removes the credential. `revoke: true` also revokes it server-side;
+ * the local wipe happens either way. */
+ logout(options: { readonly revoke: boolean }): Promise<{
+ readonly status: "anonymous" | "logout_pending";
+ readonly generation: number;
+ }>;
+ getStatus(): Promise;
}
}
diff --git a/packages/webui/src/server/operation/names.ts b/packages/webui/src/server/operation/names.ts
index ebf2194f..acc85412 100644
--- a/packages/webui/src/server/operation/names.ts
+++ b/packages/webui/src/server/operation/names.ts
@@ -62,6 +62,9 @@ export const GET_USAGE_QUOTA_OPERATION_NAME = "getUsageQuota" as const;
export const GET_SIGNIN_PANEL_OPERATION_NAME = "getSigninPanel" as const;
export const CLAIM_SIGNIN_OPERATION_NAME = "claimSignin" as const;
export const GET_ACCOUNT_STATUS_OPERATION_NAME = "getAccountStatus" as const;
+export const BEGIN_ACCOUNT_LOGIN_OPERATION_NAME = "beginAccountLogin" as const;
+export const GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME = "getAccountLoginStatus" as const;
+export const CANCEL_ACCOUNT_LOGIN_OPERATION_NAME = "cancelAccountLogin" as const;
export const RUN_COMMAND_OPERATION_NAME = "runCommand" as const;
export const SIGN_OUT_OPERATION_NAME = "signOut" as const;
export const ARCHIVE_SESSION_OPERATION_NAME = "archiveSession" as const;
diff --git a/packages/webui/src/server/operation/operation-handlers.ts b/packages/webui/src/server/operation/operation-handlers.ts
index 9115cdce..8dab4b4f 100644
--- a/packages/webui/src/server/operation/operation-handlers.ts
+++ b/packages/webui/src/server/operation/operation-handlers.ts
@@ -85,6 +85,10 @@ export type WebuiOperationPort = Pick<
| "getUsageQuota"
| "getSigninPanel"
| "claimSignin"
+ | "beginAccountLogin"
+ | "getAccountLoginStatus"
+ | "cancelAccountLogin"
+ | "signOutAccount"
| "getAccountStatus"
| "listUserModelProviders"
| "createUserModelProvider"
@@ -215,6 +219,9 @@ export function createOperationHandlers(
getSessionUsage: async (_context, body) => ({ body: await port.getSessionUsage(body) }),
getUsageQuota: async (_context, body) => ({ body: await port.getUsageQuota(body) }),
getSigninPanel: async () => ({ body: await port.getSigninPanel() }),
+ beginAccountLogin: async () => ({ body: await port.beginAccountLogin() }),
+ getAccountLoginStatus: async () => ({ body: await port.getAccountLoginStatus() }),
+ cancelAccountLogin: async () => ({ body: await port.cancelAccountLogin() }),
claimSignin: async () => ({ body: await port.claimSignin() }),
getAccountStatus: async (_context, body) => ({ body: await port.getAccountStatus(body) }),
listUserModelProviders: async () => ({ body: await port.listUserModelProviders() }),
@@ -238,7 +245,12 @@ export function createOperationHandlers(
refreshModels: async () => ({ body: await port.refreshModels() }),
runCommand: async (_context, body) => ({ body: await runWebuiCommand(port, body) }),
signOut: async () => {
- await port.invalidateAuth();
+ // The real sign-out removes the credential (revoke + wipe). The
+ // invalidation-only path remains as a fallback for hosts assembled
+ // before `signOutAccount` existed; on those, sign-out degrades to the
+ // historical behaviour instead of failing the operation.
+ if (port.signOutAccount) await port.signOutAccount();
+ else await port.invalidateAuth();
return { body: { success: true as const } };
},
watchEvents: (context) => ({
diff --git a/packages/webui/src/server/operation/operations.ts b/packages/webui/src/server/operation/operations.ts
index 819a62ca..fbe52121 100644
--- a/packages/webui/src/server/operation/operations.ts
+++ b/packages/webui/src/server/operation/operations.ts
@@ -8,7 +8,7 @@ import { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOpera
import { pluginManagementOperation } from "./plugin-management.js";
import { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js";
import { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js";
-import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js";
+import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation, beginAccountLoginOperation, getAccountLoginStatusOperation, cancelAccountLoginOperation } from "./provider.js";
export { versionOperation, listSessionsOperation, listVisibleProjectsOperation, getSessionTreeOperation, createSessionOperation, getSessionOperation, getActiveTurnOperation } from "./session.js";
export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
export { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js";
@@ -19,7 +19,7 @@ export { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOpera
export { pluginManagementOperation } from "./plugin-management.js";
export { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js";
export { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js";
-export { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js";
+export { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation, beginAccountLoginOperation, getAccountLoginStatusOperation, cancelAccountLoginOperation } from "./provider.js";
import { createOperationHandlers, type WebuiOperationPort } from "./operation-handlers.js";
import type {
WebuiOperationHandler,
@@ -128,6 +128,9 @@ export function createOperationRegistry(
registerOperation(registry, { operation: getUsageQuotaOperation, handle: handlers.getUsageQuota });
registerOperation(registry, { operation: getSigninPanelOperation, handle: handlers.getSigninPanel });
registerOperation(registry, { operation: claimSigninOperation, handle: handlers.claimSignin });
+ registerOperation(registry, { operation: beginAccountLoginOperation, handle: handlers.beginAccountLogin });
+ registerOperation(registry, { operation: getAccountLoginStatusOperation, handle: handlers.getAccountLoginStatus });
+ registerOperation(registry, { operation: cancelAccountLoginOperation, handle: handlers.cancelAccountLogin });
registerOperation(registry, { operation: getAccountStatusOperation, handle: handlers.getAccountStatus });
registerOperation(registry, { operation: listUserModelProvidersOperation, handle: handlers.listUserModelProviders });
registerOperation(registry, { operation: createUserModelProviderOperation, handle: handlers.createUserModelProvider });
diff --git a/packages/webui/src/server/operation/provider.ts b/packages/webui/src/server/operation/provider.ts
index ccdba154..6a3f521f 100644
--- a/packages/webui/src/server/operation/provider.ts
+++ b/packages/webui/src/server/operation/provider.ts
@@ -2,7 +2,7 @@ import { WebuiErrorCode } from "../envelope.js";
import { requireRecord } from "./operation-contract.js";
import type { WebuiOperation, WebuiOperationValidation } from "./operation-contract.js";
import { validateSessionIdBody } from "./common.js";
-import { ARCHIVE_SESSION_OPERATION_NAME, DELETE_SESSION_OPERATION_NAME, UPDATE_SESSION_OPERATION_NAME, GET_SESSION_FORK_OPTIONS_OPERATION_NAME, FORK_SESSION_OPERATION_NAME, LIST_USER_MODEL_PROVIDERS_OPERATION_NAME, CREATE_USER_MODEL_PROVIDER_OPERATION_NAME, UPDATE_USER_MODEL_PROVIDER_OPERATION_NAME, DELETE_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_OPERATION_NAME, DISCOVER_USER_MODELS_CANDIDATE_OPERATION_NAME, SAVE_USER_MODEL_PROVIDER_CANDIDATE_OPERATION_NAME, LIST_PROVIDER_PRESETS_OPERATION_NAME, GET_MINIMAX_API_KEY_STATUS_OPERATION_NAME, UPSERT_MINIMAX_API_KEY_OPERATION_NAME, GET_CODEX_OAUTH_STATUS_OPERATION_NAME, GET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, SET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, TEST_USER_MODEL_CANDIDATE_OPERATION_NAME, REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME, START_CODEX_OAUTH_LOGIN_OPERATION_NAME, CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME, REFRESH_MODELS_OPERATION_NAME, RUN_COMMAND_OPERATION_NAME, GET_SIGNIN_PANEL_OPERATION_NAME, CLAIM_SIGNIN_OPERATION_NAME, SIGN_OUT_OPERATION_NAME } from "./names.js";
+import { ARCHIVE_SESSION_OPERATION_NAME, DELETE_SESSION_OPERATION_NAME, UPDATE_SESSION_OPERATION_NAME, GET_SESSION_FORK_OPTIONS_OPERATION_NAME, FORK_SESSION_OPERATION_NAME, LIST_USER_MODEL_PROVIDERS_OPERATION_NAME, CREATE_USER_MODEL_PROVIDER_OPERATION_NAME, UPDATE_USER_MODEL_PROVIDER_OPERATION_NAME, DELETE_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_PROVIDER_OPERATION_NAME, TEST_USER_MODEL_OPERATION_NAME, DISCOVER_USER_MODELS_CANDIDATE_OPERATION_NAME, SAVE_USER_MODEL_PROVIDER_CANDIDATE_OPERATION_NAME, LIST_PROVIDER_PRESETS_OPERATION_NAME, GET_MINIMAX_API_KEY_STATUS_OPERATION_NAME, UPSERT_MINIMAX_API_KEY_OPERATION_NAME, GET_CODEX_OAUTH_STATUS_OPERATION_NAME, GET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, SET_MINIMAX_MODEL_SOURCE_OPERATION_NAME, TEST_USER_MODEL_CANDIDATE_OPERATION_NAME, REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME, START_CODEX_OAUTH_LOGIN_OPERATION_NAME, CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME, REFRESH_MODELS_OPERATION_NAME, RUN_COMMAND_OPERATION_NAME, GET_SIGNIN_PANEL_OPERATION_NAME, CLAIM_SIGNIN_OPERATION_NAME, SIGN_OUT_OPERATION_NAME, BEGIN_ACCOUNT_LOGIN_OPERATION_NAME, GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME, CANCEL_ACCOUNT_LOGIN_OPERATION_NAME } from "./names.js";
function validateProviderRecord(name: string, body: unknown): WebuiOperationValidation> {
return requireRecord(name, body);
}
@@ -139,6 +139,41 @@ export const claimSigninOperation: WebuiOperation<
},
};
+const emptyBody = (name: string) => (body: unknown) => {
+ if (body === null || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 0)
+ return { ok: false, code: WebuiErrorCode.invalidBody, message: `${name} body must be an empty object` } as const;
+ return { ok: true, body: {} } as const;
+};
+
+// Account login over the device-authorization flow: `begin` starts (or
+// re-attaches to) an attempt and usually answers with the prompt on the
+// first reply; `status` is what the dialog polls; `cancel` aborts without
+// touching the credential. Bodies are empty on purpose — the login session
+// is server-owned, one per service, exactly like the check-in panel.
+export const beginAccountLoginOperation: WebuiOperation<
+ Record,
+ import("../port.js").WebuiAccountLoginView
+> = {
+ name: BEGIN_ACCOUNT_LOGIN_OPERATION_NAME,
+ validate: emptyBody("beginAccountLogin"),
+};
+
+export const getAccountLoginStatusOperation: WebuiOperation<
+ Record,
+ import("../port.js").WebuiAccountLoginView
+> = {
+ name: GET_ACCOUNT_LOGIN_STATUS_OPERATION_NAME,
+ validate: emptyBody("getAccountLoginStatus"),
+};
+
+export const cancelAccountLoginOperation: WebuiOperation<
+ Record,
+ { readonly ok: true }
+> = {
+ name: CANCEL_ACCOUNT_LOGIN_OPERATION_NAME,
+ validate: emptyBody("cancelAccountLogin"),
+};
+
export const signOutOperation: WebuiOperation, { readonly success: true }> = {
name: SIGN_OUT_OPERATION_NAME,
validate: (body) => {
diff --git a/packages/webui/src/server/port.ts b/packages/webui/src/server/port.ts
index 8b1f9aa8..c4f890c6 100644
--- a/packages/webui/src/server/port.ts
+++ b/packages/webui/src/server/port.ts
@@ -1247,6 +1247,15 @@ export interface WebuiHarnessPort extends WebuiScheduledTaskPort {
/** Daily check-in panel status (cloud check-in API; see `check-in.ts`); supplied by the assembly alongside the host. */
getSigninPanel(): Promise;
claimSignin(): Promise;
+ /**
+ * Account login over the device-authorization flow, and the sign-out that
+ * actually removes the credential. See `account-login.ts` for why the
+ * session lives server-side; supplied by the assembly alongside the host.
+ */
+ beginAccountLogin(): Promise;
+ getAccountLoginStatus(): Promise;
+ cancelAccountLogin(): Promise<{ readonly ok: true }>;
+ signOutAccount(): Promise<{ readonly status: string; readonly generation: number }>;
close(): Promise;
}
@@ -1295,3 +1304,26 @@ export type WebuiUsageQuotaResult =
// panel renders them directly, so there is no second shape to drift).
export type WebuiSigninPanelView = SigninPanel;
export type WebuiClaimSigninView = ClaimSigninData;
+
+/** The device-authorization prompt the browser shows: a code to enter and
+ * the page to enter it on. Same fields the terminal client prints. */
+export interface WebuiAccountLoginPromptView {
+ readonly userCode: string;
+ readonly verificationUri: string;
+ readonly verificationUriComplete?: string;
+ /** Unix ms, derived from the prompt's `expiresInSec`; the dialog counts
+ * down against it and offers a fresh code when it passes. */
+ readonly expiresAtMs: number;
+}
+
+/**
+ * One answer for both account questions the browser asks: is a login
+ * attempt running (with its prompt), and is the account signed in —
+ * `authenticated` covers a login this process started AND one made in the
+ * terminal client, because the credential store is shared.
+ */
+export type WebuiAccountLoginView =
+ | { readonly state: "idle" }
+ | { readonly state: "pending"; readonly prompt?: WebuiAccountLoginPromptView }
+ | { readonly state: "authenticated" }
+ | { readonly state: "error"; readonly error: string };
diff --git a/packages/webui/src/server/service.ts b/packages/webui/src/server/service.ts
index 6af74028..f2c05863 100644
--- a/packages/webui/src/server/service.ts
+++ b/packages/webui/src/server/service.ts
@@ -306,6 +306,10 @@ export class WebuiService {
getSessionUsage: (request) => this.port.getSessionUsage(request),
getUsageQuota: (request) => this.port.getUsageQuota(request),
getSigninPanel: () => this.port.getSigninPanel(),
+ beginAccountLogin: () => this.port.beginAccountLogin(),
+ getAccountLoginStatus: () => this.port.getAccountLoginStatus(),
+ cancelAccountLogin: () => this.port.cancelAccountLogin(),
+ signOutAccount: () => this.port.signOutAccount(),
claimSignin: () => this.port.claimSignin(),
getAccountStatus: (request) => this.port.getAccountStatus(request),
listUserModelProviders: () => this.port.listUserModelProviders(),
diff --git a/packages/webui/test-results/.last-run.json b/packages/webui/test-results/.last-run.json
new file mode 100644
index 00000000..5fca3f84
--- /dev/null
+++ b/packages/webui/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "failed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/packages/webui/test/unit/account-login.test.ts b/packages/webui/test/unit/account-login.test.ts
new file mode 100644
index 00000000..e03646ab
--- /dev/null
+++ b/packages/webui/test/unit/account-login.test.ts
@@ -0,0 +1,192 @@
+// Unit tests for the server-owned account login session and the dialog's
+// phase derivation.
+//
+// Roadmap N (登录与账号): the WebUI had no login at all, and its sign-out
+// only cleared projections — the credential survived and the next lease
+// refresh signed straight back in. `account-login.ts` is the server half of
+// the fix: one session over the shared `MCodeOAuthCore`, answering the
+// browser's begin/status/cancel calls. The fake core below stands in for it,
+// so these tests pin the session's observable contract:
+//
+// * `begin` resolves with the prompt as soon as the core emits it, without
+// waiting for the human to authorize;
+// * a second `begin` while one attempt runs re-attaches (the core dedupes
+// `login()` on its own promise; the session must not start a second);
+// * `status` reports the settled outcome first, the in-flight prompt
+// second, and — with no attempt this process started — the persisted
+// store (a terminal-client login counts);
+// * `cancel` aborts and, when the core then rejects with the cancellation,
+// the session does not report it as a failure.
+
+import { describe, expect, it, vi } from "vitest";
+
+import { createWebuiAccountLoginSession } from "../../src/server/account-login.js";
+import { deriveWebuiAccountLoginPhase } from "../../src/client/components/AccountLoginDialog.js";
+
+/** A controllable core: the test decides when the prompt lands and when the
+ * attempt settles, including the cancellation rejection. */
+function fakeCore() {
+ let loginCalls = 0;
+ let authorize: ((authorization: {
+ userCode: string;
+ verificationUri: string;
+ verificationUriComplete?: string;
+ expiresInSec: number;
+ }) => void) | undefined;
+ const settleLogin = () => {};
+ const deferred: {
+ resolve: (value: { status: "authenticated"; generation: number }) => void;
+ reject: (error: Error) => void;
+ } = { resolve: () => {}, reject: () => {} };
+ const loginPromise = new Promise<{ status: "authenticated"; generation: number }>(
+ (resolve, reject) => {
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ },
+ );
+ const core = {
+ login: vi.fn(
+ (
+ options: {
+ onDeviceAuthorization?: (authorization: {
+ userCode: string;
+ verificationUri: string;
+ verificationUriComplete?: string;
+ expiresInSec: number;
+ }) => void;
+ } = {},
+ ) => {
+ loginCalls += 1;
+ authorize = options.onDeviceAuthorization;
+ return loginPromise;
+ },
+ ),
+ cancelLogin: vi.fn(async () => {
+ deferred.reject(new Error("MCode OAuth device authorization was cancelled."));
+ }),
+ getStatus: vi.fn(async () => ({ status: "anonymous", generation: 0, scopes: [] })),
+ emitPrompt() {
+ authorize?.({
+ userCode: "ABCD-1234",
+ verificationUri: "https://auth.example.invalid/device",
+ verificationUriComplete: "https://auth.example.invalid/device?code=ABCD-1234",
+ expiresInSec: 600,
+ });
+ },
+ complete() {
+ deferred.resolve({ status: "authenticated", generation: 7 });
+ },
+ loginCalls: () => loginCalls,
+ };
+ return core;
+}
+
+describe("the account login session", () => {
+ it("begins with the prompt as soon as the core emits it", async () => {
+ const core = fakeCore();
+ const session = createWebuiAccountLoginSession(core, () => 1_000);
+ const beginning = session.begin();
+ core.emitPrompt();
+ await expect(beginning).resolves.toEqual({
+ state: "pending",
+ prompt: {
+ userCode: "ABCD-1234",
+ verificationUri: "https://auth.example.invalid/device",
+ verificationUriComplete: "https://auth.example.invalid/device?code=ABCD-1234",
+ // now()=1000 + 600s
+ expiresAtMs: 601_000,
+ },
+ });
+ });
+
+ it("re-attaches to a running attempt instead of starting a second", async () => {
+ const core = fakeCore();
+ const session = createWebuiAccountLoginSession(core, () => 0);
+ const beginning = session.begin();
+ core.emitPrompt();
+ await beginning;
+ await expect(session.begin()).resolves.toMatchObject({
+ state: "pending",
+ prompt: { userCode: "ABCD-1234" },
+ });
+ expect(core.loginCalls()).toBe(1);
+ });
+
+ it("reports the settled outcome, then the persisted store after cancellation", async () => {
+ const core = fakeCore();
+ const session = createWebuiAccountLoginSession(core, () => 0);
+ const beginning = session.begin();
+ core.emitPrompt();
+ await beginning;
+
+ core.complete();
+ // Let the session's then/catch run.
+ await vi.waitFor(() =>
+ expect(session.status()).resolves.toEqual({ state: "authenticated" }),
+ );
+ });
+
+ it("treats a cancelled attempt as the user's own action, not a failure", async () => {
+ const core = fakeCore();
+ const session = createWebuiAccountLoginSession(core, () => 0);
+ const beginning = session.begin();
+ core.emitPrompt();
+ await beginning;
+
+ await session.cancel();
+ // The core's login promise now rejects with the cancellation; the
+ // session must not surface it as an error.
+ await loginPromiseSettles();
+ await expect(session.status()).resolves.toEqual({ state: "idle" });
+ });
+
+ it("reads the persisted store when this process started nothing", async () => {
+ const core = fakeCore();
+ core.getStatus.mockResolvedValue({ status: "authenticated", generation: 3, scopes: [] });
+ const session = createWebuiAccountLoginSession(core, () => 0);
+ await expect(session.status()).resolves.toEqual({ state: "authenticated" });
+ });
+});
+
+/** Waits until the fake core's login promise has settled (microtask drain). */
+async function loginPromiseSettles(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe("deriveWebuiAccountLoginPhase", () => {
+ it("maps every server answer to the view the panel shows", () => {
+ expect(deriveWebuiAccountLoginPhase(undefined)).toEqual({ kind: "loading" });
+ expect(deriveWebuiAccountLoginPhase({ state: "idle" })).toEqual({ kind: "loading" });
+ expect(deriveWebuiAccountLoginPhase({ state: "authenticated" })).toEqual({
+ kind: "authenticated",
+ });
+ expect(deriveWebuiAccountLoginPhase({ state: "error", error: "expired" })).toEqual({
+ kind: "error",
+ error: "expired",
+ });
+ expect(
+ deriveWebuiAccountLoginPhase({
+ state: "pending",
+ prompt: {
+ userCode: "X",
+ verificationUri: "https://x.invalid",
+ expiresAtMs: 1,
+ },
+ }),
+ ).toEqual({
+ kind: "pending",
+ userCode: "X",
+ verificationUri: "https://x.invalid",
+ verificationUriComplete: undefined,
+ });
+ });
+
+ it("keeps a prompt-less pending answer honest: waiting, not guessing", () => {
+ expect(deriveWebuiAccountLoginPhase({ state: "pending" })).toEqual({
+ kind: "pending",
+ userCode: undefined,
+ verificationUri: undefined,
+ verificationUriComplete: undefined,
+ });
+ });
+});
diff --git a/packages/webui/test/unit/webui-host-shape-invariant.test.ts b/packages/webui/test/unit/webui-host-shape-invariant.test.ts
index 1853728d..71433155 100644
--- a/packages/webui/test/unit/webui-host-shape-invariant.test.ts
+++ b/packages/webui/test/unit/webui-host-shape-invariant.test.ts
@@ -353,6 +353,19 @@ class FullPort implements WebuiHarnessPort {
panel: { scene: 0, days: [] },
};
}
+ // Added with the N-zone account login contract.
+ async beginAccountLogin() {
+ return { state: "idle" as const };
+ }
+ async getAccountLoginStatus() {
+ return { state: "idle" as const };
+ }
+ async cancelAccountLogin() {
+ return { ok: true as const };
+ }
+ async signOutAccount() {
+ return { status: "anonymous", generation: 0 };
+ }
async getAccountStatus() {
return { available: true };
}
diff --git a/packages/webui/test/unit/webui-service.test.ts b/packages/webui/test/unit/webui-service.test.ts
index e619899f..cbca558e 100644
--- a/packages/webui/test/unit/webui-service.test.ts
+++ b/packages/webui/test/unit/webui-service.test.ts
@@ -476,6 +476,23 @@ class ScriptedHarnessPort implements WebuiHarnessPort {
};
}
+ // Added with the N-zone account login contract.
+ async beginAccountLogin() {
+ return { state: "idle" as const };
+ }
+
+ async getAccountLoginStatus() {
+ return { state: "idle" as const };
+ }
+
+ async cancelAccountLogin() {
+ return { ok: true as const };
+ }
+
+ async signOutAccount() {
+ return { status: "anonymous", generation: 0 };
+ }
+
async getAccountStatus() {
return { available: true };
}
@@ -3812,6 +3829,18 @@ describe("WebUI shutdown order (criterion 7)", () => {
panel: { scene: 0, days: [] },
};
},
+ async beginAccountLogin() {
+ return { state: "idle" as const };
+ },
+ async getAccountLoginStatus() {
+ return { state: "idle" as const };
+ },
+ async cancelAccountLogin() {
+ return { ok: true as const };
+ },
+ async signOutAccount() {
+ return { status: "anonymous", generation: 0 };
+ },
async listScheduledTasks() {
return { tasks: [], total: 0 };
},
@@ -4212,6 +4241,19 @@ describe("WebUI shutdown order (criterion 7)", () => {
panel: { scene: 0, days: [] },
};
},
+
+ async beginAccountLogin() {
+ return { state: "idle" as const };
+ },
+ async getAccountLoginStatus() {
+ return { state: "idle" as const };
+ },
+ async cancelAccountLogin() {
+ return { ok: true as const };
+ },
+ async signOutAccount() {
+ return { status: "anonymous", generation: 0 };
+ },
async listScheduledTasks() {
return { tasks: [], total: 0 };
},
diff --git a/release/public-source.json b/release/public-source.json
index ca639eb6..afac3397 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -3514,6 +3514,7 @@
"packages/webui/src/client/assets/img/wechat.png",
"packages/webui/src/client/assets/lottie/streaming-rose-loader.json",
"packages/webui/src/client/client.js",
+ "packages/webui/src/client/components/AccountLoginDialog.tsx",
"packages/webui/src/client/components/ActivityIndicator.tsx",
"packages/webui/src/client/components/ArchonPage.tsx",
"packages/webui/src/client/components/ArchonShell.tsx",
@@ -3626,6 +3627,7 @@
"packages/webui/src/client/team-mode.ts",
"packages/webui/src/client/transport.ts",
"packages/webui/src/client/value-readers.ts",
+ "packages/webui/src/server/account-login.ts",
"packages/webui/src/server/assembly.ts",
"packages/webui/src/server/auth-context.ts",
"packages/webui/src/server/check-in.ts",
@@ -3675,6 +3677,8 @@
"packages/webui/src/shared/plugin-management.ts",
"packages/webui/src/shared/session-transfer-format.ts",
"packages/webui/tailwind.config.cjs",
+ "packages/webui/test-results/.last-run.json",
+ "packages/webui/test/unit/account-login.test.ts",
"packages/webui/test/unit/activity-indicator.test.tsx",
"packages/webui/test/unit/commands.test.ts",
"packages/webui/test/unit/composer-history.test.ts",
@@ -3833,6 +3837,7 @@
"test/sqlite-message-contention.test.ts",
"test/vitest-suites.json",
"test/webui-browser/README.md",
+ "test/webui-browser/account-login.spec.mjs",
"test/webui-browser/composer.spec.mjs",
"test/webui-browser/connection-status.spec.mjs",
"test/webui-browser/fixture.mjs",
diff --git a/test/vitest-suites.json b/test/vitest-suites.json
index 86bba7e6..04374f21 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -308,6 +308,7 @@
"packages/webui/test/unit/webui-workspace-canvas.test.tsx",
"packages/webui/test/unit/webui-workspace-archive.test.ts",
"packages/webui/test/unit/webui-error-boundary.test.tsx",
+ "packages/webui/test/unit/account-login.test.ts",
"packages/webui/test/unit/connection-health.test.ts",
"packages/webui/test/unit/connection-status.test.tsx"
]
diff --git a/test/webui-browser/account-login.spec.mjs b/test/webui-browser/account-login.spec.mjs
new file mode 100644
index 00000000..850c6416
--- /dev/null
+++ b/test/webui-browser/account-login.spec.mjs
@@ -0,0 +1,106 @@
+// The account login dialog, end to end against the built client.
+//
+// Roadmap N (登录与账号): the WebUI had no login at all — it served whatever
+// credential the terminal client had written, its usage panel said 登录后查看用量
+// with no way to log in, and 退出登录 never removed the credential, so
+// neither signing in nor switching accounts was possible without leaving the
+// browser. This spec walks both entries and the full device-authorization
+// round trip:
+//
+// 1. the user menu's 账号与登录 item opens the dialog, which shows the
+// device code and the authorization link, then flips to authenticated
+// when the server observes the credential (the fixture's
+// setAccountLoginState stands in for the human authorizing);
+// 2. from the authenticated state, 切换账号 signs out and starts a fresh
+// device flow in the same surface — the whole of account switching
+// under a single-credential store;
+// 3. the usage panel's signed-out block carries a 登录 button that opens
+// the same dialog.
+//
+// Boundary, stated once: the fixture's login operations are synthetic — the
+// real credential round trip needs a logged-out live server and a human at
+// the authorization page. What is assertable here is the browser half: every
+// entry opens the surface, every phase renders its contract, and the switch
+// path issues the sign-out before the new device flow.
+
+import { expect } from "@playwright/test";
+
+import { openApp, test } from "./harness.mjs";
+
+test.beforeEach(async ({ page }) => {
+ page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message));
+ page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); });
+});
+
+const dialog = (page) => page.locator('[data-testid="account-login-dialog"]');
+
+async function openFromUserMenu(page) {
+ await page.locator('[data-testid="sidebar-user-menu-trigger"]').click();
+ await page.locator('[data-testid="user-menu-account-login"]').click();
+ await expect(dialog(page)).toBeVisible();
+}
+
+test("the user menu opens the login, and authorization completes the flow", async ({ page }) => {
+ await openApp(page, "#session=A");
+ await openFromUserMenu(page);
+
+ // The device prompt: the code the user enters and the page to enter it on.
+ await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "pending");
+ await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE");
+ await expect(page.locator('[data-testid="account-login-uri"]')).toHaveAttribute(
+ "href",
+ "https://example.invalid/device?code=TEST-CODE",
+ );
+
+ // The human authorizes on the other page; the server (here: the fixture)
+ // observes the credential and the poll flips the dialog.
+ await page.evaluate(() =>
+ window.__fixture.setAccountLoginState({ state: "authenticated" }),
+ );
+ await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "authenticated", { timeout: 6_000 });
+ await expect(page.locator('[data-testid="account-login-status"]')).toContainText("已登录");
+});
+
+test("切换账号 signs out first, then starts a fresh device flow", async ({ page }) => {
+ await openApp(page, "#session=A");
+ await openFromUserMenu(page);
+ await page.evaluate(() =>
+ window.__fixture.setAccountLoginState({ state: "authenticated" }),
+ );
+ await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "authenticated", { timeout: 6_000 });
+
+ const signOutCount = () =>
+ page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "signOut").length);
+ const before = await signOutCount();
+ await page.locator('[data-testid="account-login-switch"]').click();
+
+ // The switch is a real sign-out (the operation that now removes the
+ // credential) followed by a new device prompt in the same dialog.
+ await expect.poll(signOutCount, { timeout: 6_000 }).toBe(before + 1);
+ await expect(dialog(page)).toHaveAttribute("data-webui-account-login-phase", "pending", { timeout: 6_000 });
+ await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE");
+});
+
+test("the signed-out usage block carries a 登录 button into the same dialog", async ({ page }) => {
+ await openApp(page, "#session=A");
+ await page.evaluate(() => window.__fixture.setUsageQuotaResult({ signedIn: false }));
+ await page.locator('[data-testid="sidebar-user-menu-trigger"]').click();
+ await page.locator('[data-testid="user-menu-usage"]').click();
+ const signIn = page.locator('[data-testid="usage-sign-in-button"]');
+ await expect(signIn).toBeVisible();
+ await signIn.click();
+ await expect(dialog(page)).toBeVisible();
+ await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE");
+});
+
+test("取消 closes the dialog and cancels the in-flight attempt", async ({ page }) => {
+ await openApp(page, "#session=A");
+ await openFromUserMenu(page);
+ await expect(page.locator('[data-testid="account-login-code"]')).toHaveText("TEST-CODE");
+
+ await page.locator('[data-testid="account-login-cancel"]').click();
+ await expect(dialog(page)).toHaveCount(0);
+ await expect.poll(() =>
+ page.evaluate(() => window.__fixture.requests.some((r) => r.operation === "cancelAccountLogin")),
+ ).toBe(true);
+});
diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs
index daef2263..b9616f50 100644
--- a/test/webui-browser/fixture.mjs
+++ b/test/webui-browser/fixture.mjs
@@ -72,6 +72,17 @@ export function installFixtureTransport() {
// listing it wants (an MCP server list with connection states, say) and
// every other action keeps the empty default.
const pluginManagementResults = {};
+ // Account login state machine for the three login operations. `begin`
+ // starts "pending" with a deterministic prompt; a test flips the state
+ // (authenticated / error) with setAccountLoginState to drive the dialog's
+ // poll — the real server observes the credential; the fixture observes
+ // the test.
+ let accountLoginState = { state: "idle" };
+ let accountLoginSequence = 0;
+ // The usage quota answer, overridable so a test can stage the signed-out
+ // block (`{}` — the default — has no signedIn field and reads as signed
+ // in but quota-less, not as signed out).
+ let usageQuotaResult = {};
let activeTurn;
const requests = [];
const sockets = new Set();
@@ -91,6 +102,23 @@ export function installFixtureTransport() {
if (operation === "listPendingPermissions") return { requests: [] };
if (operation === "getActiveTurn") return activeTurn;
if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {};
+ if (operation === "beginAccountLogin") {
+ accountLoginState = {
+ state: "pending",
+ prompt: {
+ userCode: "TEST-CODE",
+ verificationUri: "https://example.invalid/device",
+ verificationUriComplete: "https://example.invalid/device?code=TEST-CODE",
+ expiresAtMs: Date.now() + 600_000,
+ },
+ };
+ return clone(accountLoginState);
+ }
+ if (operation === "getAccountLoginStatus") return clone(accountLoginState);
+ if (operation === "cancelAccountLogin") {
+ accountLoginState = { state: "idle" };
+ return { ok: true };
+ }
if (operation === "pluginManagement") {
const action = typeof body?.action === "string" ? body.action : "";
return pluginManagementResults[action] ?? {};
@@ -99,7 +127,7 @@ export function installFixtureTransport() {
if (operation === "replyQuestionnaire") return { ok: true };
if (operation === "listQueueMessages") return { items: [] };
if (operation === "listModels" || operation === "loadProjects") return [];
- if (operation === "getUsageQuota") return {};
+ if (operation === "getUsageQuota") return clone(usageQuotaResult);
if (operation === "isGoalEnabled") return false;
if (operation === "getPermissionMode") return { mode: "default" };
if (operation === "getSessionUsage") return {};
@@ -237,6 +265,8 @@ export function installFixtureTransport() {
// is inert on purpose, so a staged questionnaire stays pending until the
// test answers, dismisses, or replaces it.
setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; },
+ setAccountLoginState(state) { accountLoginState = clone(state); },
+ setUsageQuotaResult(result) { usageQuotaResult = clone(result); },
setPluginManagementResult(action, result) {
pluginManagementResults[action] = result === undefined ? undefined : clone(result);
},