From ca04db5a82bf9274eeb8baab38fca47d8578f08b Mon Sep 17 00:00:00 2001 From: J Date: Thu, 4 Jun 2026 12:47:16 +0800 Subject: [PATCH] fix(desktop): surface expired login instead of silent "Starting" daemon (MUL-2973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the local daemon's cached PAT is expired/revoked, the daemon 401s during startup and exits before it serves /health. The desktop polled /health forever and kept reporting "starting", so the runtime sat at "Starting…" with no hint that re-login was the fix (GitHub #3512). Detect this in the layer that owns the daemon's credential: when a start fails to reach "running", probe the token against GET /api/me. A 401 (or missing token) surfaces a new "auth_expired" daemon state; a 2xx means the token is fine (non-auth failure) and a network error stays inconclusive — so a network blip is never misclassified as expired login. The desktop then shows a "Sign-in expired · Sign in again" prompt on the runtimes card and a banner in Daemon settings. The action drops the stale cached PAT, re-mints a fresh one from the current session, and restarts the daemon; if minting also 401s (the session token is dead) it falls back to the standard re-login flow. No daemon/CLI behavior change. Co-authored-by: multica-agent --- .../src/main/daemon-auth-probe.test.ts | 34 ++++++++ apps/desktop/src/main/daemon-auth-probe.ts | 40 ++++++++++ apps/desktop/src/main/daemon-manager.ts | 80 +++++++++++++++++++ apps/desktop/src/preload/index.d.ts | 9 ++- apps/desktop/src/preload/index.ts | 9 ++- .../src/components/daemon-runtime-card.tsx | 28 +++++++ .../src/components/daemon-settings-tab.tsx | 33 ++++++++ .../src/platform/daemon-ipc-bridge.ts | 15 +++- .../src/platform/daemon-reauth.test.ts | 69 ++++++++++++++++ .../renderer/src/platform/daemon-reauth.ts | 33 ++++++++ apps/desktop/src/shared/daemon-types.ts | 10 ++- .../views/platform/use-local-daemon-status.ts | 9 ++- 12 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/main/daemon-auth-probe.test.ts create mode 100644 apps/desktop/src/main/daemon-auth-probe.ts create mode 100644 apps/desktop/src/renderer/src/platform/daemon-reauth.test.ts create mode 100644 apps/desktop/src/renderer/src/platform/daemon-reauth.ts diff --git a/apps/desktop/src/main/daemon-auth-probe.test.ts b/apps/desktop/src/main/daemon-auth-probe.test.ts new file mode 100644 index 000000000..a89424bac --- /dev/null +++ b/apps/desktop/src/main/daemon-auth-probe.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { classifyAuthProbe } from "./daemon-auth-probe"; + +describe("classifyAuthProbe", () => { + it("treats a 401 as expired login", () => { + expect(classifyAuthProbe({ status: 401 })).toBe("auth_expired"); + }); + + it("treats a missing token as expired login", () => { + expect(classifyAuthProbe({ noToken: true })).toBe("auth_expired"); + }); + + it("treats a 2xx as a valid token (failure is non-auth)", () => { + expect(classifyAuthProbe({ status: 200 })).toBe("ok"); + expect(classifyAuthProbe({ status: 204 })).toBe("ok"); + }); + + // The headline guard: a network failure must never be reported as an auth + // problem — the daemon is just as unreachable for non-auth reasons. + it("does NOT classify a network error as expired login", () => { + expect(classifyAuthProbe({ networkError: true })).toBe("unknown"); + }); + + it("leaves 5xx and other statuses inconclusive", () => { + expect(classifyAuthProbe({ status: 500 })).toBe("unknown"); + expect(classifyAuthProbe({ status: 503 })).toBe("unknown"); + expect(classifyAuthProbe({ status: 403 })).toBe("unknown"); + }); + + it("is inconclusive when nothing is known", () => { + expect(classifyAuthProbe({})).toBe("unknown"); + }); +}); diff --git a/apps/desktop/src/main/daemon-auth-probe.ts b/apps/desktop/src/main/daemon-auth-probe.ts new file mode 100644 index 000000000..b5b322916 --- /dev/null +++ b/apps/desktop/src/main/daemon-auth-probe.ts @@ -0,0 +1,40 @@ +/** + * Pure classification for the daemon auth probe. Kept free of Electron imports + * so it can be unit-tested in jsdom. + * + * When the local daemon fails to reach "running" shortly after a start, the + * main process probes the daemon's token against the backend (GET /api/me) to + * tell "the daemon can't authenticate" apart from "the daemon is slow / the + * network is down / it crashed for another reason". Misclassifying a network + * blip as an auth failure would be worse than the original silent-Starting bug, + * so the rules below are deliberately conservative: only an explicit 401 (or a + * missing credential) is treated as auth-expired. + */ + +export interface AuthProbeOutcome { + /** HTTP status code returned by the probe request, if one completed. */ + status?: number; + /** The daemon profile has no token at all — there is nothing to validate. */ + noToken?: boolean; + /** The probe request threw (timeout, connection refused, DNS, TLS). */ + networkError?: boolean; +} + +export type AuthProbeResult = "auth_expired" | "ok" | "unknown"; + +export function classifyAuthProbe(outcome: AuthProbeOutcome): AuthProbeResult { + // No credential to validate → the user must sign in. + if (outcome.noToken) return "auth_expired"; + // Couldn't reach the server → this is a network problem, not an auth one. + // Stay "unknown" so the caller keeps showing "starting"/"stopped" instead of + // wrongly prompting for re-login. + if (outcome.networkError) return "unknown"; + // The server explicitly rejected the token. + if (outcome.status === 401) return "auth_expired"; + // The token is accepted — the daemon is failing for some other reason. + if (outcome.status !== undefined && outcome.status >= 200 && outcome.status < 300) { + return "ok"; + } + // 5xx and everything else are inconclusive about the token's validity. + return "unknown"; +} diff --git a/apps/desktop/src/main/daemon-manager.ts b/apps/desktop/src/main/daemon-manager.ts index 44d124b3e..0e5f3f990 100644 --- a/apps/desktop/src/main/daemon-manager.ts +++ b/apps/desktop/src/main/daemon-manager.ts @@ -19,12 +19,18 @@ import { homedir, hostname } from "os"; import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types"; import { ensureManagedCli, managedCliPath } from "./cli-bootstrap"; import { decideVersionAction } from "./version-decision"; +import { classifyAuthProbe, type AuthProbeResult } from "./daemon-auth-probe"; const DEFAULT_HEALTH_PORT = 19514; const POLL_INTERVAL_MS = 5_000; const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json"); const LOG_TAIL_RETRY_MS = 2_000; const LOG_TAIL_MAX_RETRIES = 5; +// How long a start may sit in "starting" (with no /health) before we probe the +// token to find out whether login expired. The daemon's own startup can legitimately +// take a while (it renews the PAT and lists workspaces before serving /health), so we +// wait past the common case to avoid probing healthy-but-slow starts. +const AUTH_PROBE_GRACE_MS = 10_000; const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false }; @@ -48,6 +54,15 @@ let pendingVersionRestart = false; let targetApiBaseUrl: string | null = null; let activeProfile: ActiveProfile | null = null; +// Auth-probe state for the current start attempt. When a start fails to reach +// "running", we probe the daemon's token once (after AUTH_PROBE_GRACE_MS) to +// decide whether the cause is an expired/invalid login. `authExpired` is sticky +// until the next start attempt or a successful /health, so the UI keeps showing +// the re-login prompt instead of flapping back to "starting". See #3512. +let startingSince: number | null = null; +let authProbeDone = false; +let authExpired = false; + // Serialize all writes to any profile config file. Multiple paths // (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers) // may try to write concurrently; chaining them avoids interleaved writes @@ -161,6 +176,36 @@ async function fetchHealthAtPort( } } +/** + * Validates the daemon profile's token against the backend to find out whether + * a stuck start is an auth problem. Hits the same endpoint `multica auth status` + * uses (GET /api/me) with the exact token the daemon loads from config.json, so + * the verdict matches what the daemon itself would get from the server. + * + * Only the HTTP status is inspected (never the body) so a future change to the + * /api/me response shape can't break this — a 401 means the token is rejected, + * a 2xx means it's fine, and a thrown request means the network is the problem, + * not auth. See classifyAuthProbe for the full rule set. + */ +async function probeTokenValidity(profile: string): Promise { + if (!targetApiBaseUrl) return "unknown"; + const cfg = await readProfileConfig(profile); + const token = typeof cfg.token === "string" ? cfg.token : ""; + if (!token) return classifyAuthProbe({ noToken: true }); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4_000); + const res = await fetch(`${targetApiBaseUrl.replace(/\/+$/, "")}/api/me`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + clearTimeout(timeout); + return classifyAuthProbe({ status: res.status }); + } catch { + return classifyAuthProbe({ networkError: true }); + } +} + // Desktop owns a dedicated CLI profile named after the target API host, so it // never reads or writes the user's hand-configured profiles. Profile dir: // ~/.multica/profiles/desktop-/ @@ -249,12 +294,40 @@ async function fetchHealth(): Promise { const data = await fetchHealthAtPort(active.port); if (!data || data.status !== "running") { + // A start that never reaches "running" is the symptom; an expired/invalid + // login is the most common cause and the one with no other signal (the + // daemon exits before it can serve /health, so we can't read the reason + // from it). Probe the token once per attempt, after a grace period, to + // surface a re-login prompt instead of spinning on "starting" forever. + if ( + currentState === "starting" && + !authExpired && + !authProbeDone && + startingSince !== null && + Date.now() - startingSince >= AUTH_PROBE_GRACE_MS + ) { + authProbeDone = true; + if ((await probeTokenValidity(active.name)) === "auth_expired") { + authExpired = true; + } + } + // Sticky: once login is known-expired, keep reporting it (even after + // currentState flips away from "starting") until the next start attempt or + // a successful /health clears the flag. + if (authExpired) { + return { state: "auth_expired", profile: active.name }; + } return { state: currentState === "starting" ? "starting" : "stopped", profile: active.name, }; } + // A live, authenticated daemon clears any prior auth-failure verdict so the + // re-login prompt disappears once the user reconnects. + authExpired = false; + startingSince = null; + // Safety: if we have a target URL and the daemon on our port reports a // different server_url, it's not "our" daemon — drop it and re-resolve. if ( @@ -657,6 +730,10 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> { } currentState = "starting"; + // Begin a fresh auth-probe window for this attempt. + startingSince = Date.now(); + authProbeDone = false; + authExpired = false; sendStatus({ state: "starting" }); const args = ["daemon", "start", ...profileArgs(active)]; @@ -689,6 +766,9 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> { const active = await ensureActiveProfile(); currentState = "stopping"; + // An explicit stop is a clean reset — drop any pending auth-failure verdict. + authExpired = false; + startingSince = null; sendStatus({ state: "stopping" }); const args = ["daemon", "stop", ...profileArgs(active)]; diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index b10831c30..c6491c5d3 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -74,7 +74,14 @@ interface DesktopAPI { } interface DaemonStatus { - state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found"; + state: + | "running" + | "stopped" + | "starting" + | "stopping" + | "installing_cli" + | "cli_not_found" + | "auth_expired"; pid?: number; uptime?: string; daemonId?: string; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 30c48faa7..992718f25 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -165,7 +165,14 @@ const desktopAPI = { }; interface DaemonStatus { - state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found"; + state: + | "running" + | "stopped" + | "starting" + | "stopping" + | "installing_cli" + | "cli_not_found" + | "auth_expired"; pid?: number; uptime?: string; daemonId?: string; diff --git a/apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx b/apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx index 464a9df32..9794d947d 100644 --- a/apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx +++ b/apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx @@ -6,6 +6,7 @@ import { RotateCw, Activity, ScrollText, + LogIn, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; @@ -22,6 +23,7 @@ import { } from "@multica/ui/components/ui/dialog"; import { toast } from "sonner"; import { DaemonPanel } from "./daemon-panel"; +import { reauthenticateDaemon } from "../platform/daemon-reauth"; import type { DaemonStatus } from "../../../shared/daemon-types"; import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types"; @@ -115,9 +117,18 @@ export function DaemonRuntimeActions() { } }, []); + const handleReauth = useCallback(async () => { + setActionLoading(true); + await reauthenticateDaemon(); + // onStatusChange resets actionLoading on the next status push; reset here + // too in case reauth logged out (unmount) or produced no status change. + setActionLoading(false); + }, []); + const isRunning = status.state === "running"; const isStopped = status.state === "stopped"; const isCliMissing = status.state === "cli_not_found"; + const isAuthExpired = status.state === "auth_expired"; const isTransitioning = status.state === "starting" || status.state === "stopping"; const isInstalling = status.state === "installing_cli"; @@ -175,6 +186,23 @@ export function DaemonRuntimeActions() { )} + {isAuthExpired && ( + <> + + + Sign-in expired + + + + )} + {(isTransitioning || isInstalling) && ( + + )} +
({ + mockGetState: vi.fn(), + logout: vi.fn(), +})); + +vi.mock("@multica/core/auth", () => ({ + useAuthStore: { getState: mockGetState }, +})); + +import { reauthenticateDaemon } from "./daemon-reauth"; + +const daemonAPI = { + clearToken: vi.fn(), + syncToken: vi.fn(), + restart: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + daemonAPI.clearToken.mockResolvedValue(undefined); + daemonAPI.syncToken.mockResolvedValue(undefined); + daemonAPI.restart.mockResolvedValue({ success: true }); + (window as unknown as { daemonAPI: typeof daemonAPI }).daemonAPI = daemonAPI; + mockGetState.mockReturnValue({ user: { id: "user-1" }, logout }); +}); + +describe("reauthenticateDaemon", () => { + it("re-mints a fresh PAT and restarts the daemon when signed in", async () => { + localStorage.setItem("multica_token", "jwt-abc"); + + await reauthenticateDaemon(); + + expect(daemonAPI.clearToken).toHaveBeenCalledOnce(); + expect(daemonAPI.syncToken).toHaveBeenCalledWith("jwt-abc", "user-1"); + expect(daemonAPI.restart).toHaveBeenCalledOnce(); + expect(logout).not.toHaveBeenCalled(); + }); + + it("falls back to full logout when minting fails (session token is dead)", async () => { + localStorage.setItem("multica_token", "jwt-abc"); + daemonAPI.syncToken.mockRejectedValueOnce(new Error("mint PAT failed: 401")); + + await reauthenticateDaemon(); + + expect(logout).toHaveBeenCalledOnce(); + expect(daemonAPI.restart).not.toHaveBeenCalled(); + }); + + it("logs out without touching the daemon when there is no session token", async () => { + await reauthenticateDaemon(); + + expect(logout).toHaveBeenCalledOnce(); + expect(daemonAPI.clearToken).not.toHaveBeenCalled(); + expect(daemonAPI.syncToken).not.toHaveBeenCalled(); + }); + + it("logs out when there is no signed-in user", async () => { + localStorage.setItem("multica_token", "jwt-abc"); + mockGetState.mockReturnValue({ user: null, logout }); + + await reauthenticateDaemon(); + + expect(logout).toHaveBeenCalledOnce(); + expect(daemonAPI.clearToken).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/src/platform/daemon-reauth.ts b/apps/desktop/src/renderer/src/platform/daemon-reauth.ts new file mode 100644 index 000000000..a267c1b33 --- /dev/null +++ b/apps/desktop/src/renderer/src/platform/daemon-reauth.ts @@ -0,0 +1,33 @@ +import { useAuthStore } from "@multica/core/auth"; + +/** + * Re-establish the local daemon's credentials after it failed to authenticate + * (daemon state "auth_expired", surfaced by daemon-manager's token probe — see + * #3512). + * + * The desktop owns the daemon's PAT: it mints one from the user's session token + * and caches it per profile. A stale/revoked cached PAT is the common cause of + * the failure (and merely restarting the app reuses the same bad PAT), so we + * drop the cached token and mint a fresh one from the current session, then + * restart the daemon so it loads the new credential. + * + * If minting fails the session token itself is dead — fall back to the standard + * re-login flow (the same `logout()` the API client uses on a 401), which lands + * the user on the login page and re-mints a PAT on the next sign-in. + */ +export async function reauthenticateDaemon(): Promise { + const user = useAuthStore.getState().user; + const token = localStorage.getItem("multica_token"); + if (!user || !token) { + useAuthStore.getState().logout(); + return; + } + try { + await window.daemonAPI.clearToken(); + await window.daemonAPI.syncToken(token, user.id); + await window.daemonAPI.restart(); + } catch { + // Session token is also invalid (mint returned 401) — full re-login. + useAuthStore.getState().logout(); + } +} diff --git a/apps/desktop/src/shared/daemon-types.ts b/apps/desktop/src/shared/daemon-types.ts index 8005d437c..ed7f2b4fd 100644 --- a/apps/desktop/src/shared/daemon-types.ts +++ b/apps/desktop/src/shared/daemon-types.ts @@ -4,7 +4,11 @@ export type DaemonState = | "starting" | "stopping" | "installing_cli" - | "cli_not_found"; + | "cli_not_found" + // The daemon can't start because the server rejected its credentials (the + // cached PAT expired / was revoked, or the session token is dead). Without + // this, an auth failure silently sticks at "starting" forever — see #3512. + | "auth_expired"; export interface DaemonStatus { state: DaemonState; @@ -32,6 +36,7 @@ export const DAEMON_STATE_COLORS: Record = { stopping: "bg-amber-500 animate-pulse", installing_cli: "bg-sky-500 animate-pulse", cli_not_found: "bg-red-500", + auth_expired: "bg-red-500", }; export const DAEMON_STATE_LABELS: Record = { @@ -41,6 +46,7 @@ export const DAEMON_STATE_LABELS: Record = { stopping: "Stopping…", installing_cli: "Setting up…", cli_not_found: "Setup Failed", + auth_expired: "Sign-in required", }; export function formatUptime(uptime?: string): string { @@ -81,5 +87,7 @@ export function daemonStateDescription(state: DaemonState, runtimeCount: number) return "Setting up the runtime for the first time. Only happens once."; case "cli_not_found": return "Setup failed · couldn't download the runtime. Check your network."; + case "auth_expired": + return "Sign-in expired · sign in again to bring this device back online."; } } diff --git a/packages/views/platform/use-local-daemon-status.ts b/packages/views/platform/use-local-daemon-status.ts index ba2392b5b..bb90af860 100644 --- a/packages/views/platform/use-local-daemon-status.ts +++ b/packages/views/platform/use-local-daemon-status.ts @@ -11,7 +11,14 @@ export interface LocalDaemonStatus { } interface DaemonStatusLike { - state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found"; + state: + | "running" + | "stopped" + | "starting" + | "stopping" + | "installing_cli" + | "cli_not_found" + | "auth_expired"; daemonId?: string; deviceName?: string; }