From f44dcdd79178dbffe2232b0508813f4bc64e261a Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 09:37:23 +0200 Subject: [PATCH] Claude sign-in: one click to sign in again when it ran out When Claude Code's sign-in runs out, `claude auth status` still says "logged in", so Settings stayed green and the chat only offered a retry that failed the same way. A run that ends over the sign-in now marks Claude as signed out (kept across restarts) until a run gets an answer, a sign-in finishes, or Claude Code writes new credentials. - A banner above every page says Claude is signed out, with Sign in again and the steps right there; it goes once Claude works again. - The failed chat turn signs in inline and continues by itself. - From a phone (Godmode Cloud, remote access) the steps are open the sign-in page and paste the code; only the computer's owner may do it through the cloud. - One notification per episode; the doctor's Claude login row turns red. - A refused Anthropic API key points to Integrations instead. --- apps/desktop/src/components/chat/turn-end.tsx | 91 ++++++++++- .../src/components/layout/app-shell.tsx | 2 + .../components/layout/claude-signed-out.tsx | 91 +++++++++++ .../components/onboarding/claude-sign-in.tsx | 152 +++++++++++++++++ .../onboarding/doctor-checklist.tsx | 100 ++---------- apps/desktop/src/lib/queryKeys.ts | 1 + apps/desktop/src/lib/realtime.ts | 5 + packages/core/src/cloud/scope.ts | 11 +- packages/core/src/runner/runner.ts | 26 ++- packages/core/src/server/routes/system.ts | 7 +- packages/core/src/server/routes/vault.ts | 3 + packages/core/src/services/claudeLogin.ts | 154 ++++++++++++++++-- packages/core/src/services/doctor.ts | 6 + packages/core/test/claude-login.test.ts | 83 +++++++++- packages/core/test/cloud-scope.test.ts | 14 ++ packages/core/test/runner.test.ts | 13 ++ packages/shared/src/events.ts | 3 + packages/shared/src/models.ts | 7 + 18 files changed, 651 insertions(+), 118 deletions(-) create mode 100644 apps/desktop/src/components/layout/claude-signed-out.tsx create mode 100644 apps/desktop/src/components/onboarding/claude-sign-in.tsx diff --git a/apps/desktop/src/components/chat/turn-end.tsx b/apps/desktop/src/components/chat/turn-end.tsx index 6be4e363..60bb2a5b 100644 --- a/apps/desktop/src/components/chat/turn-end.tsx +++ b/apps/desktop/src/components/chat/turn-end.tsx @@ -1,14 +1,15 @@ -import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { Link } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import type { Agent, ConversationWithMessages, Message, RetryMode } from "@godmode/shared"; +import type { Agent, ConversationWithMessages, Message, RetryMode, Run } from "@godmode/shared"; import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared"; -import { ArrowUpRight, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react"; +import { ArrowUpRight, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useStartAgentChat } from "@/components/agents/agent-actions"; import { useRunRoutine } from "@/components/agents/routine-item"; +import { ClaudeSignInSteps, maySignIn, useClaudeLogin } from "@/components/onboarding/claude-sign-in"; import { api, ApiRequestError, errorMessage, isLicenseRequired } from "@/lib/api"; import { useRoutines, useTasks } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; @@ -18,8 +19,6 @@ const PLATFORM_ORIGINS = new Set(["slack", "telegram", "teams"]); /** Where the fix for a turn that can't just be tried again lives. */ function fixFor(end: RunEnd, agentName: string): { text: string; to?: string; label?: string } { switch (end.kind) { - case "auth": - return { text: "Sign Claude in again, then try again.", to: "/settings/system", label: "Open System" }; case "cli": return { text: "Install Claude Code, then try again.", to: "/settings/system", label: "Open System" }; case "vm": @@ -141,6 +140,8 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver ); + if (end?.kind === "auth") return retry.mutate()} />; + if (end && needsFix(end)) { const fix = fixFor(end, name); return ( @@ -178,6 +179,86 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver ); } +/** + * Claude Code is signed out: sign in again from the chat (or the banner), and the turn goes on by itself once Claude + * works again while the chat is open. + */ +function SignInAgain({ run, name, action, onSignedIn }: { run: Run; name: string; action: (variant: "outline" | "ghost") => ReactNode; onSignedIn: () => void }) { + const continued = useRef(null); + const goOn = useCallback(() => { + if (continued.current === run.id) return; + continued.current = run.id; + onSignedIn(); + }, [run.id, onSignedIn]); + const login = useClaudeLogin({ onSignedIn: goOn }); + const { status, state, start } = login; + // Cleared some other way while the chat is open (a sign-in in a terminal, another run that got an answer). + const wasOut = useRef(false); + useEffect(() => { + if (status?.signedOut) wasOut.current = true; + else if (wasOut.current && status?.signedOut === null) { + wasOut.current = false; + goOn(); + } + }, [status?.signedOut, goOn]); + // A core that knows the state says it works again (older ones leave it out). + const worksAgain = status?.signedOut === null && state !== "failed"; + + if (status?.signedOut?.apiKey) + return ( + + Claude refused the Anthropic API key. Check it, then try again. + + + {action("ghost")} + + + ); + if (state === "waiting" && !login.mine) + return ( + + + Signing in to Claude… {name} picks up where it stopped once you're done. + + ); + if (state === "waiting") + return ( +
+ +

{name} goes on by itself once you're signed in.

+
+ ); + if (worksAgain) + return ( + + {action("outline")} + Claude works again. {name} picks up where it stopped. + + ); + if (!maySignIn) + return ( + + Claude is signed out on this computer. Once its owner signs in again, try again. + {action("ghost")} + + ); + return ( + + {state === "failed" && status?.message ? `The sign-in didn't finish: ${status.message}` : `Claude is signed out. Sign in again and ${name} goes on by itself.`} + + + {action("ghost")} + + + ); +} + function Row({ children }: { children: ReactNode }) { return (
diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 820eaead..1998bde4 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -63,6 +63,7 @@ import { CommandPalette } from "@/components/layout/command-palette"; import { UpdateButton } from "@/components/layout/update-button"; import { ClaudeUpdateButton } from "@/components/layout/claude-update-button"; import { MacPermissionsButton } from "@/components/permissions/mac-permissions"; +import { ClaudeSignedOutBanner } from "@/components/layout/claude-signed-out"; import { DeviceIndicator, PrimaryOfflineBanner } from "@/components/layout/device-status"; import { LidModeButton } from "@/components/layout/lid-mode"; import { ThemeToggle } from "@/components/layout/theme-toggle"; @@ -319,6 +320,7 @@ export function AppShell({ children }: { children: ReactNode }) { {cloudContext && boot && } +
{children} diff --git a/apps/desktop/src/components/layout/claude-signed-out.tsx b/apps/desktop/src/components/layout/claude-signed-out.tsx new file mode 100644 index 00000000..10c89bd0 --- /dev/null +++ b/apps/desktop/src/components/layout/claude-signed-out.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { Link, useLocation } from "react-router"; +import { AnimatePresence, motion } from "motion/react"; +import { KeyRound, LogIn } from "lucide-react"; +import { ClaudeSignInSteps, maySignIn, signsInFromAfar, useClaudeLogin } from "@/components/onboarding/claude-sign-in"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +/** + * Above every page while runs find Claude Code signed out: one click signs it in again, right here. Gone by itself once + * it works (a sign-in, or a run that got an answer). + */ +export function ClaudeSignedOutBanner() { + const { pathname } = useLocation(); + const login = useClaudeLogin(); + const { status, state, start } = login; + const out = status?.signedOut ?? null; + // Settings → System shows the same sign-in in its checklist. + const shown = !!out && !pathname.startsWith("/settings/system"); + // A sign-in started elsewhere (the chat, before a reload) shows its steps here on request. + const [expanded, setExpanded] = useState(false); + useEffect(() => { + if (state !== "waiting") setExpanded(false); + }, [state]); + + return ( + + {shown && ( + +
+
+
+ {out.apiKey ? : state === "waiting" ? : } +

+ {out.apiKey ? ( + <> + Claude refused your Anthropic API key. + Your agents can't work until it works again. + + ) : state === "waiting" ? ( + <> + Signing in to Claude… + {!login.mine && !signsInFromAfar && Finish it in your browser.} + + ) : state === "failed" && status?.message ? ( + <> + The sign-in didn't finish. + {status.message} + + ) : ( + <> + Claude is signed out. + + {maySignIn ? " Your agents can't work until you sign in again." : " Your agents can't work until the owner of this computer signs in again."} + + + )} +

+ {out.apiKey ? ( + + ) : !maySignIn ? null : state !== "waiting" ? ( + + ) : ( + !login.mine && + !expanded && ( + + ) + )} +
+ {!out.apiKey && state === "waiting" && (login.mine || expanded) && } +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/onboarding/claude-sign-in.tsx b/apps/desktop/src/components/onboarding/claude-sign-in.tsx new file mode 100644 index 00000000..858085cf --- /dev/null +++ b/apps/desktop/src/components/onboarding/claude-sign-in.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import type { ClaudeLoginStatus } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { api, errorMessage } from "@/lib/api"; +import { cloudContext, isTauri } from "@/lib/core"; +import { openExternal } from "@/lib/desktop"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** The dashboard runs away from the computer (a phone, Godmode Cloud): the browser the sign-in opens there is out of reach. */ +export const signsInFromAfar = !!cloudContext || (!isTauri && typeof window !== "undefined" && !LOCAL_HOSTS.has(window.location.hostname)); + +/** Who may sign Claude in: on Godmode Cloud only the computer's owner. */ +export const maySignIn = !cloudContext || cloudContext.role === "owner"; + +/** + * Claude Code's sign-in on this computer: its state (also whether runs found it signed out) and the steps to sign in. + * `onSignedIn` runs when a sign-in finishes while the component is on screen, wherever it was started. + */ +export function useClaudeLogin({ onSignedIn }: { onSignedIn?: () => void } = {}) { + const qc = useQueryClient(); + const { data } = useQuery({ + queryKey: qk.claudeLogin, + queryFn: api.doctor.claudeLogin, + refetchInterval: (q) => { + const s = q.state.data as ClaudeLoginStatus | undefined; + return s?.state === "waiting" ? 1500 : s?.signedOut ? 30_000 : false; + }, + }); + // A reply may arrive after the event it raced with: it doesn't take away the sign-in link the event brought. + const set = (s: ClaudeLoginStatus) => + qc.setQueryData(qk.claudeLogin, (old) => (old?.state === "waiting" && s.state === "waiting" && old.url && !s.url ? { ...s, url: old.url } : s)); + // The sign-in this component started (the others only mention it). + const [mine, setMine] = useState(false); + const start = useMutation({ + mutationFn: api.doctor.startClaudeLogin, + onMutate: () => setMine(true), + onSuccess: (s) => { + set(s); + if (s.state !== "waiting") setMine(false); + }, + onError: (e) => { + setMine(false); + toast.error("Could not start the sign-in", { description: errorMessage(e) }); + }, + }); + const cancel = useMutation({ mutationFn: api.doctor.cancelClaudeLogin, onSuccess: set }); + // What the code did arrives as an event (or the next poll); the reply is only "still waiting". + const submit = useMutation({ + mutationFn: (code: string) => api.doctor.submitClaudeLoginCode(code), + onError: (e) => toast.error("Could not send the code", { description: errorMessage(e) }), + }); + + const state = data?.state ?? "idle"; + const prev = useRef(state); + useEffect(() => { + if (prev.current === "waiting" && state !== "waiting") { + if (state === "done") { + toast.success("Signed in to Claude", { id: "claude-signed-in", description: "Your agents can work again." }); + onSignedIn?.(); + } + setMine(false); + } + prev.current = state; + }, [state, onSignedIn]); + + return { status: data, state, mine: mine && state === "waiting", start, cancel, submit }; +} + +export type ClaudeLogin = ReturnType; + +/** While the sign-in runs: finish it in the browser, or (no browser window, a phone) open the page and paste its code. */ +export function ClaudeSignInSteps({ login, className }: { login: ClaudeLogin; className?: string }) { + const [code, setCode] = useState(""); + const [manual, setManual] = useState(signsInFromAfar); + const url = login.status?.url ?? null; + const send = () => { + if (!code.trim()) return; + login.submit.mutate(code.trim(), { onSuccess: () => setCode("") }); + }; + const openPage = () => { + if (!url) return; + setManual(true); + void openExternal(url); + }; + + return ( +
+
+ + {signsInFromAfar ? "Sign in on this device" : "Finish signing in in your browser"} + +
+ {signsInFromAfar ? ( +
    +
  1. + +
  2. +
  3. + Sign in with your Claude account and click Authorize. +
  4. +
  5. Paste the code the page shows:
  6. +
+ ) : ( +

+ Sign in with your Claude account and click Authorize. This updates by itself. + {url && !manual && ( + <> + {" "} + + + )} +

+ )} + {manual && ( +
{ + e.preventDefault(); + send(); + }} + > + setCode(e.target.value)} + placeholder="Paste the code" + aria-label="Code from the Claude sign-in page" + className="h-8 bg-card font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> + +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/onboarding/doctor-checklist.tsx b/apps/desktop/src/components/onboarding/doctor-checklist.tsx index a5c0d44b..073ecb10 100644 --- a/apps/desktop/src/components/onboarding/doctor-checklist.tsx +++ b/apps/desktop/src/components/onboarding/doctor-checklist.tsx @@ -1,18 +1,17 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; -import { ChevronDown, CircleAlert, CircleCheck, CircleX, Download, ExternalLink, LogIn, RefreshCw, Terminal } from "lucide-react"; -import type { ClaudeLoginStatus, DependencyId, DependencyStatus, DoctorReport } from "@godmode/shared"; +import { ChevronDown, CircleAlert, CircleCheck, CircleX, Download, LogIn, RefreshCw, Terminal } from "lucide-react"; +import type { DependencyId, DependencyStatus, DoctorReport } from "@godmode/shared"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { api, errorMessage } from "@/lib/api"; -import { openExternal } from "@/lib/desktop"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { ClaudeSignInSteps, maySignIn, signsInFromAfar, useClaudeLogin } from "./claude-sign-in"; /** "How to fix" fallbacks when the core doesn't send an install hint. */ const FIX_FALLBACK: Partial> = { @@ -242,43 +241,16 @@ function DependencyRow({ /** Signs Claude Code in from Godmode: the browser opens, the person approves, the row turns green by itself. */ function ClaudeSignIn({ onDone, auto = false, onAuto }: { onDone: () => void; auto?: boolean; onAuto?: () => void }) { - const qc = useQueryClient(); - const key = ["doctor", "claude-login"]; - const [code, setCode] = useState(""); - const { data } = useQuery({ - queryKey: key, - queryFn: api.doctor.claudeLogin, - refetchInterval: (q) => ((q.state.data as ClaudeLoginStatus | undefined)?.state === "waiting" ? 1500 : false), - }); - const set = (s: ClaudeLoginStatus) => qc.setQueryData(key, s); - const start = useMutation({ mutationFn: api.doctor.startClaudeLogin, onSuccess: set, onError: (e) => toast.error("Could not start the sign-in", { description: errorMessage(e) }) }); - const cancel = useMutation({ mutationFn: api.doctor.cancelClaudeLogin, onSuccess: set }); - const submit = useMutation({ - mutationFn: () => api.doctor.submitClaudeLoginCode(code), - onSuccess: (s) => { - set(s); - setCode(""); - }, - onError: (e) => toast.error("Could not send the code", { description: errorMessage(e) }), - }); - - const state = data?.state ?? "idle"; + const login = useClaudeLogin({ onSignedIn: onDone }); + const { status: data, state, start } = login; const autoStarted = useRef(false); useEffect(() => { if (!auto || !data || autoStarted.current) return; autoStarted.current = true; onAuto?.(); - if (data.state === "idle") start.mutate(); + if (data.state === "idle" && maySignIn) start.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [auto, data]); - const prev = useRef(state); - useEffect(() => { - if (prev.current === "waiting" && state === "done") { - toast.success("Signed in to Claude"); - onDone(); - } - prev.current = state; - }, [state, onDone]); if (state === "done") return ( @@ -287,58 +259,20 @@ function ClaudeSignIn({ onDone, auto = false, onAuto }: { onDone: () => void; au

); - if (state !== "waiting") - return ( -
- {state === "failed" && data?.message &&

{data.message}

} -
- - Opens your browser. Works with a Claude Pro or Max plan. -
-
- ); + if (state === "waiting") return ; + + if (!maySignIn) return

Only the owner of this computer can sign Claude in.

; return ( -
-
- Waiting for you to sign in… +
+ {state === "failed" && data?.message &&

{data.message}

} +
+ + {signsInFromAfar ? "Opens the sign-in page here." : "Opens your browser."} Works with a Claude Pro or Max plan.
-
    -
  1. In the browser window that just opened, sign in with your Claude account.
  2. -
  3. - Click Authorize. -
  4. -
  5. Come back here. This turns green by itself.
  6. -
- {data?.url && ( -
{ - e.preventDefault(); - if (code.trim()) submit.mutate(); - }} - > -

- No browser window?{" "} - {" "} - and paste the code it shows at the end: -

-
- setCode(e.target.value)} placeholder="Paste the code" className="h-8 font-mono text-xs" autoComplete="off" spellCheck={false} /> - -
-
- )} -
); } diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts index a78dbe55..d84ca9b2 100644 --- a/apps/desktop/src/lib/queryKeys.ts +++ b/apps/desktop/src/lib/queryKeys.ts @@ -13,6 +13,7 @@ export const qk = { logEntries: (level: string, search: string) => ["logs", "entries", level, search], doctor: ["doctor"] as unknown[], claudeUpdate: ["doctor", "claude-update"] as unknown[], + claudeLogin: ["doctor", "claude-login"] as unknown[], permissions: ["doctor", "permissions"] as unknown[], toolUpdates: ["doctor", "updates"] as unknown[], maintenance: ["doctor", "maintenance"] as unknown[], diff --git a/apps/desktop/src/lib/realtime.ts b/apps/desktop/src/lib/realtime.ts index 2e3abbb2..fcbb2e54 100644 --- a/apps/desktop/src/lib/realtime.ts +++ b/apps/desktop/src/lib/realtime.ts @@ -245,6 +245,11 @@ function handle(qc: QueryClient, event: ServerEvent) { qc.invalidateQueries({ queryKey: qk.spend }); refreshSoon(qc, qk.bootstrap); break; + case "claude.login": + qc.setQueryData(qk.claudeLogin, event.status); + // The checklist's "Claude login" row follows it. + qc.invalidateQueries({ queryKey: qk.doctor, exact: true }); + break; case "message.created": case "message.updated": upsertMessage(qc, event.message); diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index ac845183..baf79baf 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -5,7 +5,7 @@ * * - "allowed": owners and operators. Viewers only read (GET/HEAD, and POST /api/conversations/:id/files). * - "secrets": only while `settings.cloud.allowSecrets` is on, and never for viewers. - * - "owner": reads for the computer's owner only (cloud status and billing). + * - "owner": the computer's owner only (cloud status and billing, signing Claude Code in again). * - refused: linking, sign-in, phone management, local-only actions and machine settings stay on the computer. * * Sign-in routes and phone pairing never reach `requireAuth`, so `hostGuard` refuses them with `cloudPathRefusal`. @@ -22,7 +22,7 @@ const LINK_ONLY = "Linking and access switches are managed on the computer itsel const PHONES_ONLY = "Phones are paired and managed in Godmode on the computer itself."; const SIGN_IN = "Signing in to this computer's own dashboard isn't available through Godmode Cloud."; const SETTINGS_ONLY = "These settings can only be changed in Godmode on the computer itself."; -const OWNER_ONLY = "Only the owner of this computer can see this."; +const OWNER_ONLY = "Only the owner of this computer can do this."; const VIEWER = "You can look, but not change anything on this computer."; const VIEWER_HIDDEN = "Viewers can't see this computer's screen or secrets."; export const SECRETS_OFF = "Turned off for cloud access. Allow it on the computer under Settings → Cloud."; @@ -65,10 +65,11 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["GET", "/api/models", A], ["POST", "/api/doctor/install", A], ["GET|POST", "/api/doctor/claude-update", A], - // Signing Claude Code in picks the account every agent on the computer runs on. + // Signing Claude Code in picks the account every agent on the computer runs on: only its owner does that from afar + // (the sign-in page opens on the phone and its code comes back here). ["GET", "/api/doctor/claude-login", A], - ["POST|DELETE", "/api/doctor/claude-login", refused(COMPUTER_ONLY)], - ["POST", "/api/doctor/claude-login/code", refused(COMPUTER_ONLY)], + ["POST|DELETE", "/api/doctor/claude-login", "owner"], + ["POST", "/api/doctor/claude-login/code", "owner"], // Settings → System: reading the state is fine from anywhere; changing macOS permissions or the machine's tools // happens on the computer itself. ["GET", "/api/doctor/permissions", A], diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index b2109df2..ae3de52c 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -71,6 +71,7 @@ import { HttpError, awakeTimeout, badRequest, conflict, hostnameOf, newId, notFo import { isUnlocked, redact, redactionEpoch } from "../vault/vault"; import { hasCardsForAgent } from "../vault/cards"; import { commitAgentRepo, ensureAgentRepo, getAgent, listAgents, peersFor, setAgentFailedRun, setAgentStatus, teamOf, touchAgentRun } from "../agents/service"; +import { noteClaudeSignedOut, noteClaudeWorks } from "../services/claudeLogin"; import { isDirectory, workingDirectoryProblem } from "../services/folders"; import { prepareSources, type RunSource } from "../services/workspaceSources"; import { projectOfChat } from "../services/projects"; @@ -187,7 +188,8 @@ const PAUSE_GRACE_MS = 8000; /** Continuations a Stop hook (`/goal`) forces in a row without a tool call before Godmode ends the run. */ const GOAL_IDLE_LIMIT = 2; const SESSION_MISSING = /no conversation found|session(?: id)? [^\n]{0,80}not found|could not find session|no such session/i; -const AUTH_PROBLEM = /not logged in|please run \/login|invalid api key|authentication_error|oauth (?:token|session) (?:has )?expired|credit balance is too low/i; +const AUTH_PROBLEM = /not logged in\b|please run \/login|invalid api key|authentication_error|oauth (?:token|session) (?:has )?expired|credit balance is too low/i; +const CLAUDE_SIGNED_OUT = "Claude Code is not signed in (or the API key is invalid). Sign in to Claude again, or check the Anthropic API key in Integrations → API keys."; /** Built-in Claude Code tools of a dream run: reading and editing the memory files, nothing else. */ const DREAM_TOOLS = "Read,Write,Edit,Glob,Grep"; /** @@ -1853,6 +1855,12 @@ function recapPrefix(job: Job): string { return `\nThe previous Claude session of this conversation could not be restored. Recap of the most recent messages:\n\n${lines.join("\n\n")}\n\n\n`; } +/** Claude Code itself ended the run over its sign-in (not a tool's output or the agent's own words about a website). */ +function claudeSaidSignedOut(job: Job, attempt: Attempt): boolean { + const final = job.acc.final; + return AUTH_PROBLEM.test([final?.errors.join("\n") ?? "", final?.isError ? final.text : "", attempt.stderr].join("\n")); +} + function describeFailure(job: Job, attempt: Attempt): string { const final = job.acc.final; const stderr = attempt.stderr.trim(); @@ -1860,9 +1868,7 @@ function describeFailure(job: Job, attempt: Attempt): string { if (AUTH_PROBLEM.test(haystack) && job.model && modelProvider(job.model) === "zai") { return `Z.ai rejected the request for ${job.model} (API key invalid, or no balance or plan). Check the Z.ai API key under Integrations → API keys.`; } - if (AUTH_PROBLEM.test(haystack)) { - return "Claude Code is not signed in (or the API key is invalid). Run `claude` in a terminal and log in, or add an Anthropic API key in Settings."; - } + if (AUTH_PROBLEM.test(haystack)) return CLAUDE_SIGNED_OUT; if (final) { if (final.errors.length) return final.errors.join("; "); switch (final.subtype) { @@ -2421,7 +2427,10 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise log.warn("could not mark Claude Code signed out", err)); + } + return { status: "failed", error }; } finally { try { await logSink.end(); diff --git a/packages/core/src/server/routes/system.ts b/packages/core/src/server/routes/system.ts index 91c7d750..2db05dfb 100644 --- a/packages/core/src/server/routes/system.ts +++ b/packages/core/src/server/routes/system.ts @@ -12,7 +12,7 @@ import { listNotifications, markRead, clearNotifications, unreadCount } from ".. import { audit, listAudit } from "../../services/audit"; import { listAttention } from "../../services/attention"; import { runDoctor, installDependency } from "../../services/doctor"; -import { cancelClaudeLogin, claudeLoginStatus, startClaudeLogin, submitClaudeLoginCode } from "../../services/claudeLogin"; +import { cancelClaudeLogin, claudeLoginStatus, recheckClaudeSignedOut, startClaudeLogin, submitClaudeLoginCode } from "../../services/claudeLogin"; import { claudeUpdateStatus, updateClaude } from "../../services/claudeUpdate"; import { PERMISSION_IDS, PRIVACY_IDS, checkPermissions, fixPermission, openPrivacySettings, resetPrivacy } from "../../services/permissions"; import { TOOL_IDS, checkUpdates } from "../../services/updates"; @@ -104,7 +104,10 @@ export function registerSystemRoutes(app: Hono) { disableIdleTimeout(c); return c.json(await inTurn(() => installDependency(id as never))); }); - app.get("/api/doctor/claude-login", (c) => c.json(claudeLoginStatus())); + app.get("/api/doctor/claude-login", async (c) => { + await recheckClaudeSignedOut(); + return c.json(claudeLoginStatus()); + }); app.post("/api/doctor/claude-login", (c) => c.json(startClaudeLogin())); app.post("/api/doctor/claude-login/code", async (c) => { const { code } = await body(c, z.object({ code: z.string().min(1).max(2000) })); diff --git a/packages/core/src/server/routes/vault.ts b/packages/core/src/server/routes/vault.ts index 44cbf3d2..262e1378 100644 --- a/packages/core/src/server/routes/vault.ts +++ b/packages/core/src/server/routes/vault.ts @@ -19,6 +19,7 @@ import { clientIp as relayedClientIp, isRelayed, rateLimitLogin, resetLoginAttem import { issueGrant, requireGrant, verifyVaultPassphrase } from "../grants"; import { body, z } from "../validate"; import { badRequest, HttpError } from "../../util"; +import { noteAnthropicKeyChanged } from "../../services/claudeLogin"; /** App-level secrets the UI always lists (set or not). */ const WELL_KNOWN_SECRETS = ["anthropic_api_key", "zai_api_key", "openai_api_key", "elevenlabs_api_key", "composio_api_key", "browser_use_api_key", ...CAPTCHA_SECRETS]; @@ -221,6 +222,7 @@ export function registerVaultRoutes(app: Hono): void { const { value } = await body(c, z.object({ value: z.string().max(16_384) })); const trimmed = value.trim(); vault.setAppSecret(key, trimmed || null); + if (key === "anthropic_api_key") noteAnthropicKeyChanged(); audit("user", trimmed ? "secret.set" : "secret.delete", key); return c.json({ ok: true as const }); }); @@ -228,6 +230,7 @@ export function registerVaultRoutes(app: Hono): void { app.delete("/api/vault/secrets/:key", (c) => { const key = secretKeyParam(c); vault.setAppSecret(key, null); + if (key === "anthropic_api_key") noteAnthropicKeyChanged(); audit("user", "secret.delete", key); return c.json({ ok: true as const }); }); diff --git a/packages/core/src/services/claudeLogin.ts b/packages/core/src/services/claudeLogin.ts index 9f330bfc..34e5da66 100644 --- a/packages/core/src/services/claudeLogin.ts +++ b/packages/core/src/services/claudeLogin.ts @@ -1,12 +1,21 @@ /** * One-click Claude sign-in: runs `claude auth login` in the background. The CLI opens the browser and finishes by - * itself once the person approves; when the browser can't open (server, Docker) the UI shows the sign-in link and - * takes the code the page shows. + * itself once the person approves; when the browser can't open (server, Docker, a phone) the UI shows the sign-in link + * and takes the code the page shows. + * + * A run that finds Claude Code signed out marks it here: `claude auth status` still says "logged in" when the stored + * sign-in ran out, so the mark is what the doctor, the banner and the chats go by until a sign-in or a run that works. */ +import { statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { ClaudeLoginStatus } from "@godmode/shared"; +import { deleteMeta, getMeta, setMeta } from "../db"; +import { bus } from "../events/bus"; import { logger } from "../log"; -import { childEnv } from "../util"; -import { resetDoctorCache, resolveClaudeBinary, stripAnsi, toolPath } from "./doctor"; +import { childEnv, now, parseJson } from "../util"; +import { resetDoctorCache, resolveClaudeBinary, runCommand, stripAnsi, toolPath } from "./doctor"; +import { notify } from "./notifications"; const log = logger("claude-login"); @@ -19,23 +28,131 @@ interface Session { output: string; } +interface SignedOut { + at: string; + apiKey: boolean; + /** Claude Code's stored credentials when the run found it signed out; a sign-in in a terminal changes them. */ + stamp: string; +} + +const SIGNED_OUT_META = "claude.signed_out"; +const RECHECK_MS = 10_000; + let session: Session | null = null; let status: ClaudeLoginStatus = { state: "idle", url: null, message: null }; let endedAt = 0; +let signedOut: SignedOut | null | undefined; +let signedInAt: string | null = null; +let recheckedAt = 0; +/** Counts the moments Claude was seen working: a failure that started reporting before one of them is old news. */ +let works = 0; +/** performance.now() of the last sign-in (by Godmode, or new credentials found). */ +let signedInPerf = Number.NEGATIVE_INFINITY; export function claudeLoginStatus(): ClaudeLoginStatus { // A finished sign-in is only news for a minute; after that a new "not logged in" (logout) starts fresh. if (!session && status.state !== "idle" && Date.now() - endedAt > 60_000) status = { state: "idle", url: null, message: null }; - return status; + const out = claudeSignedOut(); + return { ...status, signedOut: out && { at: out.at, apiKey: out.apiKey }, signedInAt }; +} + +/** Set while runs find Claude Code signed out (kept across restarts). */ +export function claudeSignedOut(): SignedOut | null { + if (signedOut === undefined) { + try { + signedOut = parseJson(getMeta(SIGNED_OUT_META), null); + } catch { + return null; + } + } + return signedOut; +} + +function changed() { + bus.emit({ type: "claude.login", status: claudeLoginStatus() }); +} + +function setSignedOut(next: SignedOut | null) { + signedOut = next; + try { + if (next) setMeta(SIGNED_OUT_META, JSON.stringify(next)); + else deleteMeta(SIGNED_OUT_META); + } catch (err) { + log.warn("could not store the Claude sign-in state", err); + } + resetDoctorCache(); + changed(); +} + +/** Changes whenever Claude Code writes its credentials (a sign-in, or a refresh that worked). */ +async function credentialsStamp(): Promise { + const parts: string[] = []; + try { + parts.push(String(statSync(join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), ".credentials.json")).mtimeMs)); + } catch { + /* none on disk */ + } + if (process.platform === "darwin") { + const res = await runCommand(["/usr/bin/security", "find-generic-password", "-s", "Claude Code-credentials"], { timeoutMs: 10_000 }).catch(() => null); + const mdat = res?.code === 0 ? res.stdout.match(/"mdat"=\S+/)?.[0] : undefined; + if (mdat) parts.push(mdat); + } + return parts.join("|"); +} + +/** + * A run ended because Claude Code is signed out (or its API key was refused): say so until it works again. + * `apiKey`: the run had an Anthropic API key · `since`: performance.now() when its Claude Code started. + */ +export async function noteClaudeSignedOut({ apiKey, since }: { apiKey: boolean; since?: number }): Promise { + if (claudeSignedOut() || session) return; + // It started before the last sign-in: it still had the old one. + if (since !== undefined && since < signedInPerf) return; + const seen = works; + const stamp = await credentialsStamp(); + if (claudeSignedOut() || session || works !== seen) return; + log.warn(`a run found Claude Code signed out${apiKey ? " (Anthropic API key refused)" : ""}`); + setSignedOut({ at: now(), apiKey, stamp }); + if (apiKey) notify("warning", "Claude refused the API key", "Your agents can't work until the Anthropic API key works again.", "/integrations?tab=api-keys"); + else notify("warning", "Claude is signed out", "Your agents can't work until you sign in to Claude again. It's one click in Godmode.", "/settings/system"); +} + +/** Claude answered a run: whatever found it signed out is over. */ +export function noteClaudeWorks(): void { + works++; + if (claudeSignedOut()) setSignedOut(null); +} + +/** The Anthropic API key was changed or removed: a refusal of the old one is no news any more. */ +export function noteAnthropicKeyChanged(): void { + if (claudeSignedOut()?.apiKey) setSignedOut(null); +} + +function signedIn() { + works++; + signedInPerf = performance.now(); +} + +/** Clears the mark when Claude Code's credentials changed since (signed in from a terminal, or refreshed by itself). */ +export async function recheckClaudeSignedOut(): Promise { + const out = claudeSignedOut(); + if (!out || session || Date.now() - recheckedAt < RECHECK_MS) return; + recheckedAt = Date.now(); + const stamp = await credentialsStamp(); + if (!stamp || stamp === out.stamp || claudeSignedOut() !== out) return; + log.info("Claude Code's credentials changed since a run found it signed out"); + signedIn(); + setSignedOut(null); } export function startClaudeLogin(): ClaudeLoginStatus { - if (session) return status; + if (session) return claudeLoginStatus(); const bin = resolveClaudeBinary(); if (!bin) { endedAt = Date.now(); status = { state: "failed", url: null, message: "Claude Code is not installed yet. Install it first, then sign in." }; - return status; + changed(); + return claudeLoginStatus(); } let proc: ReturnType; try { @@ -49,7 +166,8 @@ export function startClaudeLogin(): ClaudeLoginStatus { } catch (err) { endedAt = Date.now(); status = { state: "failed", url: null, message: err instanceof Error ? err.message : String(err) }; - return status; + changed(); + return claudeLoginStatus(); } const current: Session = { proc, @@ -59,6 +177,7 @@ export function startClaudeLogin(): ClaudeLoginStatus { session = current; status = { state: "waiting", url: null, message: null }; log.info("claude auth login started"); + changed(); const read = async (stream: ReadableStream) => { const decoder = new TextDecoder(); @@ -70,7 +189,10 @@ export function startClaudeLogin(): ClaudeLoginStatus { current.output = (current.output + decoder.decode(value, { stream: true })).slice(-20_000); if (session === current && !status.url) { const url = stripAnsi(current.output).match(URL_RE)?.[0]; - if (url) status = { ...status, url }; + if (url) { + status = { ...status, url }; + changed(); + } } } } catch { @@ -80,7 +202,7 @@ export function startClaudeLogin(): ClaudeLoginStatus { void Promise.all([read(proc.stdout as ReadableStream), read(proc.stderr as ReadableStream)]).then(async () => { finish(current, await proc.exited.catch(() => null)); }); - return status; + return claudeLoginStatus(); } function finish(s: Session, code: number | null, message?: string) { @@ -97,6 +219,10 @@ function finish(s: Session, code: number | null, message?: string) { if (code === 0 && !message) { log.info("claude auth login succeeded"); status = { state: "done", url: null, message: null }; + signedInAt = now(); + signedIn(); + if (claudeSignedOut()) setSignedOut(null); + else changed(); return; } const tail = stripAnsi(s.output) @@ -107,21 +233,23 @@ function finish(s: Session, code: number | null, message?: string) { .join(" "); log.warn(`claude auth login ended (${code ?? "killed"})`); status = { state: "failed", url: null, message: message ?? (tail || "The sign-in did not finish. Try again.") }; + changed(); } /** The code the sign-in page shows when the browser could not hand the login back by itself. */ export function submitClaudeLoginCode(code: string): ClaudeLoginStatus { const s = session; const value = code.trim(); - if (!s || !value) return status; + if (!s || !value) return claudeLoginStatus(); const stdin = s.proc.stdin as { write(data: string): unknown; flush?(): unknown } | undefined; stdin?.write(`${value}\n`); stdin?.flush?.(); - return status; + return claudeLoginStatus(); } export function cancelClaudeLogin(): ClaudeLoginStatus { if (session) finish(session, null, "Cancelled."); status = { state: "idle", url: null, message: null }; - return status; + changed(); + return claudeLoginStatus(); } diff --git a/packages/core/src/services/doctor.ts b/packages/core/src/services/doctor.ts index 80869ecc..7c385be9 100644 --- a/packages/core/src/services/doctor.ts +++ b/packages/core/src/services/doctor.ts @@ -11,6 +11,7 @@ import { delimiter, dirname, join } from "node:path"; import type { DependencyId, DependencyStatus, DoctorReport } from "@godmode/shared"; import { getSettings } from "./settings"; import { hasAppSecret } from "../vault/vault"; +import { claudeSignedOut, recheckClaudeSignedOut } from "./claudeLogin"; import { logger } from "../log"; import { childEnv, which } from "../util"; import { findChrome } from "../browser/chrome"; @@ -251,6 +252,11 @@ function hasStoredAnthropicKey(): boolean { async function checkClaudeAuth(claudePath: string | null): Promise { const base = { id: "claude-auth" as const, name: "Claude login", version: null, path: null }; + // `auth status` still says "logged in" when the stored sign-in ran out; a run that found it signed out knows better. + await recheckClaudeSignedOut(); + const out = claudeSignedOut(); + if (out?.apiKey) return { ...base, ok: false, detail: "Claude refused the Anthropic API key. Check it in Integrations → API keys." }; + if (out) return { ...base, ok: false, detail: "The sign-in ran out. Sign in to Claude again." }; const apiKey = hasStoredAnthropicKey() || !!process.env.ANTHROPIC_API_KEY; if (claudePath) { const res = await runCommand([claudePath, "auth", "status", "--json"], { timeoutMs: 20_000, env: childEnv({ PATH: toolPath() }) }); diff --git a/packages/core/test/claude-login.test.ts b/packages/core/test/claude-login.test.ts index 4be5b4e2..e797e100 100644 --- a/packages/core/test/claude-login.test.ts +++ b/packages/core/test/claude-login.test.ts @@ -1,14 +1,25 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../src/config"; -import { closeDb, openDb } from "../src/db"; +import { all, closeDb, getMeta, openDb } from "../src/db"; import { setLogLevel } from "../src/log"; import { resetSettingsCache, updateSettings } from "../src/services/settings"; -import { cancelClaudeLogin, claudeLoginStatus, startClaudeLogin, submitClaudeLoginCode } from "../src/services/claudeLogin"; +import { + cancelClaudeLogin, + claudeLoginStatus, + noteAnthropicKeyChanged, + noteClaudeSignedOut, + noteClaudeWorks, + recheckClaudeSignedOut, + startClaudeLogin, + submitClaudeLoginCode, +} from "../src/services/claudeLogin"; let dataDir: string; +let configDir: string; +const savedConfigDir = process.env.CLAUDE_CONFIG_DIR; const FAKE_CLAUDE = `#!/bin/sh echo "Opening browser to sign in…" @@ -38,9 +49,15 @@ beforeAll(() => { writeFileSync(bin, FAKE_CLAUDE); chmodSync(bin, 0o755); updateSettings({ runner: { claudePath: bin } } as never); + configDir = join(dataDir, "claude-config"); + mkdirSync(configDir); + writeFileSync(join(configDir, ".credentials.json"), "{}"); + process.env.CLAUDE_CONFIG_DIR = configDir; }); afterAll(() => { + if (savedConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = savedConfigDir; cancelClaudeLogin(); closeDb(); resetSettingsCache(); @@ -55,7 +72,8 @@ describe.skipIf(process.platform === "win32")("claude login", () => { expect(startClaudeLogin().state).toBe("waiting"); submitClaudeLoginCode("good-code"); await until(() => claudeLoginStatus().state !== "waiting"); - expect(claudeLoginStatus()).toEqual({ state: "done", url: null, message: null }); + expect(claudeLoginStatus()).toMatchObject({ state: "done", url: null, message: null, signedOut: null }); + expect(claudeLoginStatus().signedInAt).toBeString(); }); test("a wrong code fails with the CLI's message", async () => { @@ -73,4 +91,61 @@ describe.skipIf(process.platform === "win32")("claude login", () => { expect(cancelClaudeLogin().state).toBe("idle"); expect(claudeLoginStatus().state).toBe("idle"); }); + + test("a run that finds Claude signed out says so until a run works", async () => { + await noteClaudeSignedOut({ apiKey: false }); + const out = claudeLoginStatus().signedOut; + expect(out).toMatchObject({ apiKey: false }); + expect(JSON.parse(getMeta("claude.signed_out")!)).toMatchObject({ at: out!.at, apiKey: false }); + await noteClaudeSignedOut({ apiKey: false }); + expect(claudeLoginStatus().signedOut?.at).toBe(out!.at); + expect(all("SELECT id FROM notifications WHERE title = 'Claude is signed out'")).toHaveLength(1); + noteClaudeWorks(); + expect(claudeLoginStatus().signedOut).toBeNull(); + expect(getMeta("claude.signed_out")).toBeNull(); + }); + + test("signing in again ends it", async () => { + await noteClaudeSignedOut({ apiKey: false }); + expect(claudeLoginStatus().signedOut).not.toBeNull(); + startClaudeLogin(); + await until(() => !!claudeLoginStatus().url); + submitClaudeLoginCode("good-code"); + await until(() => claudeLoginStatus().state !== "waiting"); + expect(claudeLoginStatus()).toMatchObject({ state: "done", signedOut: null }); + }); + + test("new credentials (a sign-in in a terminal) end it", async () => { + await noteClaudeSignedOut({ apiKey: false }); + await recheckClaudeSignedOut(); + expect(claudeLoginStatus().signedOut).not.toBeNull(); + const later = new Date(Date.now() + 60_000); + utimesSync(join(configDir, ".credentials.json"), later, later); + await recheckClaudeSignedOut(); + expect(claudeLoginStatus().signedOut).not.toBeNull(); + await Bun.sleep(10_050); + await recheckClaudeSignedOut(); + expect(claudeLoginStatus().signedOut).toBeNull(); + }, 20_000); + + test("a failure from before the last sign-in is old news", async () => { + const before = performance.now(); + startClaudeLogin(); + await until(() => !!claudeLoginStatus().url); + submitClaudeLoginCode("good-code"); + await until(() => claudeLoginStatus().state !== "waiting"); + await noteClaudeSignedOut({ apiKey: false, since: before }); + expect(claudeLoginStatus().signedOut).toBeNull(); + await noteClaudeSignedOut({ apiKey: false, since: performance.now() }); + expect(claudeLoginStatus().signedOut).not.toBeNull(); + noteClaudeWorks(); + }); + + test("a refused API key: a new key ends it, signing in doesn't apply", async () => { + await noteClaudeSignedOut({ apiKey: true }); + expect(claudeLoginStatus().signedOut?.apiKey).toBe(true); + expect(all("SELECT id FROM notifications WHERE title = 'Claude refused the API key'")).toHaveLength(1); + noteAnthropicKeyChanged(); + expect(claudeLoginStatus().signedOut).toBeNull(); + }); }); diff --git a/packages/core/test/cloud-scope.test.ts b/packages/core/test/cloud-scope.test.ts index be885900..360243d9 100644 --- a/packages/core/test/cloud-scope.test.ts +++ b/packages/core/test/cloud-scope.test.ts @@ -79,6 +79,20 @@ describe("cloud route classification", () => { } }); + test("only the owner signs Claude Code in again from afar", async () => { + const body = async () => null; + for (const [method, path] of [ + ["POST", "/api/doctor/claude-login"], + ["DELETE", "/api/doctor/claude-login"], + ["POST", "/api/doctor/claude-login/code"], + ] as const) { + expect(await cloudRefusal(method, path, "owner", body)).toBeNull(); + expect(await cloudRefusal(method, path, "operator", body)).not.toBeNull(); + expect(await cloudRefusal(method, path, "viewer", body)).not.toBeNull(); + } + expect(await cloudRefusal("GET", "/api/doctor/claude-login", "viewer", body)).toBeNull(); + }); + test("an unclassified route is refused", async () => { expect(isCloudRouteClassified("GET", "/api/brand-new")).toBe(false); expect(await cloudRefusal("GET", "/api/brand-new", "owner", async () => null)).toBe( diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index b10c91ef..b8af10d6 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -9,6 +9,7 @@ import { all, get, insert, run as sql } from "../src/db"; import { getSettings, updateSettings } from "../src/services/settings"; import { config } from "../src/config"; import { listMissingLogins } from "../src/services/missingLogins"; +import { claudeLoginStatus } from "../src/services/claudeLogin"; import { createConversation, deleteConversation, @@ -341,6 +342,18 @@ describe("runner end-to-end with fake claude", () => { expect(finished.error).toStartWith("Claude Code is not signed in"); await new Promise((r) => setTimeout(r, 200)); expect(listMissingLogins({}).filter((m) => m.runId === run.id)).toHaveLength(0); + await until(() => !!claudeLoginStatus().signedOut); + expect(claudeLoginStatus().signedOut?.apiKey).toBe(false); + expect(all<{ title: string }>("SELECT title FROM notifications WHERE title = 'Claude is signed out'")).toHaveLength(1); + + // A second failure says nothing new; the next answer from Claude ends it. + const again = await startChat({ agentId: agent.id, content: "AUTH_EXPIRED" }); + expect((await waitForRun(again.run.id, 20_000)).status).toBe("failed"); + await new Promise((r) => setTimeout(r, 200)); + expect(all<{ title: string }>("SELECT title FROM notifications WHERE title = 'Claude is signed out'")).toHaveLength(1); + const ok = await startChat({ agentId: agent.id, content: "hello" }); + expect((await waitForRun(ok.run.id, 20_000)).status).toBe("succeeded"); + expect(claudeLoginStatus().signedOut).toBeNull(); }); test("runs reach the MCP gateway; an explicit report disables the heuristic", async () => { diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 74c89d6f..6dc1d518 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -10,6 +10,7 @@ import type { AppNotification, AutomationEvent, BrowserProfile, + ClaudeLoginStatus, Conversation, ID, Message, @@ -78,6 +79,8 @@ export type ServerEvent = | { type: "question.updated"; question: AgentQuestion } | { type: "notification"; notification: AppNotification } | { type: "vault.status"; status: VaultStatus } + /** Claude Code's sign-in changed: a sign-in started or ended, or a run found it signed out. */ + | { type: "claude.login"; status: ClaudeLoginStatus } | { type: "browser.updated"; profile: BrowserProfile } | { type: "browser.frame"; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 21fc9575..8dfcbaa4 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1632,6 +1632,13 @@ export interface ClaudeLoginStatus { /** Sign-in link for when the browser didn't open by itself (the page then shows a code to paste). */ url: string | null; message: string | null; + /** + * A run found Claude Code signed out (its sign-in ran out, or Claude refused the API key) and nothing has worked since. + * `apiKey`: the runs use an Anthropic API key, so signing in again doesn't help. Older cores leave it out. + */ + signedOut?: { at: ISODate; apiKey: boolean } | null; + /** When Godmode last signed Claude Code in (since it started). */ + signedInAt?: ISODate | null; } export interface DoctorReport {