From 2314a9e8dd66fae833131d0d60dfc43418ce75e4 Mon Sep 17 00:00:00 2001 From: Archaon Date: Mon, 8 Jun 2026 18:23:38 +0700 Subject: [PATCH] Harden Panda Claude auth: exchange retry, chat-time re-login, token hygiene Builds on the macOS keychain auth detection already committed in 2b08b21 ("support keychain claude login"). - Security (L1): app_panda_login no longer returns the ccr- proxy key to the renderer (PandaLoginResult { ok }); the key is persisted Rust-side only and the frontend re-reads settings instead of round-tripping the token through JS. - Exchange retry (#2): exchange_code_for_key retries transient failures (transport error / HTTP 5xx / 429) with exponential backoff (300ms, 600ms); the documented terminal 400 codes are surfaced immediately and never retried (the code is single-use). - Chat-time re-login (#1): on the proxy path, a turn that fails with an auth error (revoked/expired key -> BE 401) now emits ChatEvent::AuthExpired instead of a generic error. The chat store raises a needsPandaReauth flag and a new PandaReauthBanner offers a one-click "Sign in again" wired to the sign-in flow. Includes looks_like_auth_failure detection (gated to use_panda_cloud) + tests. Tests: Rust 164 passed, JS 420 passed. Co-Authored-By: Claude Opus 4.8 --- desktop/src-tauri/src/commands/app.rs | 71 +++++++++++------ .../src-tauri/src/commands/claude_driver.rs | 52 +++++++++++- desktop/src-tauri/src/ipc/types.rs | 8 ++ .../client/components/chat/ChatSidebar.jsx | 6 +- .../components/chat/PandaReauthBanner.jsx | 79 +++++++++++++++++++ .../chat/__tests__/chatReducer.test.js | 31 ++++++++ viewer/src/client/lib/transport.ts | 5 +- viewer/src/client/store/chat.js | 52 +++++++++++- 8 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 viewer/src/client/components/chat/PandaReauthBanner.jsx diff --git a/desktop/src-tauri/src/commands/app.rs b/desktop/src-tauri/src/commands/app.rs index 126a9d0..cb24353 100644 --- a/desktop/src-tauri/src/commands/app.rs +++ b/desktop/src-tauri/src/commands/app.rs @@ -988,35 +988,60 @@ struct PandaExchangeResponse { /// POST the one-time `code` + PKCE `verifier` to the Panda exchange endpoint and /// return `(key, base_url)`. Maps the documented 400 error codes to friendly /// copy. +/// +/// Retries only on **transient** failures — a transport error (no response) or +/// an HTTP 5xx/429 — with a short exponential backoff. Terminal responses (the +/// documented 4xx codes like `code_expired` / `invalid_or_used_code`) are NOT +/// retried: the `code` is single-use, so re-sending it after the server has +/// already judged it is pointless and could only ever fail the same way. The +/// same code is reused across transient retries because a 5xx/transport error +/// means the server never consumed it. async fn exchange_code_for_key(code: &str, verifier: &str) -> IpcResult<(String, String)> { + const MAX_ATTEMPTS: u32 = 3; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::limited(3)) .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| IpcError::new("PANDA_EXCHANGE_FAILED", e.to_string()))?; - let resp = client - .post(format!("{PANDA_API_URL}/api/auth/exchange")) - .json(&serde_json::json!({ "code": code, "code_verifier": verifier })) - .send() - .await - .map_err(|e| { - IpcError::new( - "PANDA_EXCHANGE_FAILED", - format!("could not reach Panda sign-in: {e}"), - ) - })?; - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(IpcError::new("PANDA_EXCHANGE_FAILED", map_exchange_error(&body))); - } - let parsed: PandaExchangeResponse = serde_json::from_str(&body).map_err(|e| { - IpcError::new( - "PANDA_EXCHANGE_FAILED", - format!("unexpected sign-in response: {e}"), - ) - })?; - Ok((parsed.key, parsed.base_url)) + let body = serde_json::json!({ "code": code, "code_verifier": verifier }); + let url = format!("{PANDA_API_URL}/api/auth/exchange"); + + let mut attempt = 0; + loop { + attempt += 1; + let transient_err: String = match client.post(&url).json(&body).send().await { + Ok(resp) => { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.is_success() { + let parsed: PandaExchangeResponse = + serde_json::from_str(&text).map_err(|e| { + IpcError::new( + "PANDA_EXCHANGE_FAILED", + format!("unexpected sign-in response: {e}"), + ) + })?; + return Ok((parsed.key, parsed.base_url)); + } + // 5xx / 429 → transient; any other non-2xx (e.g. the 400 codes) + // is terminal and surfaced immediately. + let transient = status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS; + if !transient { + return Err(IpcError::new("PANDA_EXCHANGE_FAILED", map_exchange_error(&text))); + } + format!("Panda sign-in service returned HTTP {status}") + } + Err(e) => format!("could not reach Panda sign-in: {e}"), + }; + + if attempt >= MAX_ATTEMPTS { + return Err(IpcError::new("PANDA_EXCHANGE_FAILED", transient_err)); + } + // Exponential backoff: 300ms, then 600ms. + let delay = std::time::Duration::from_millis(300 * 2u64.pow(attempt - 1)); + tokio::time::sleep(delay).await; + } } /// Friendly copy for the documented exchange error codes. The body looks like diff --git a/desktop/src-tauri/src/commands/claude_driver.rs b/desktop/src-tauri/src/commands/claude_driver.rs index e4351c0..71efee8 100644 --- a/desktop/src-tauri/src/commands/claude_driver.rs +++ b/desktop/src-tauri/src/commands/claude_driver.rs @@ -336,6 +336,25 @@ pub fn build_env(cfg: &ClaudeRunConfig) -> Vec<(String, String)> { env } +/// Heuristic: does this `claude` stderr look like an API authentication +/// failure? Used only on the Panda proxy path to distinguish a revoked/expired +/// key (BE returns 401, Anthropic-style `authentication_error` body) from other +/// silent failures, so the UI can offer a re-login. Matched case-insensitively +/// against the substrings Anthropic/the proxy emit; the proxy mode gating keeps +/// false positives from mislabelling a non-auth failure. +pub fn looks_like_auth_failure(stderr: &str) -> bool { + let s = stderr.to_ascii_lowercase(); + s.contains("authentication_error") + || s.contains("invalid api key") + || s.contains("invalid x-api-key") + || s.contains("invalid bearer token") + || s.contains("permission_error") + || s.contains("401 unauthorized") + || s.contains("status 401") + || s.contains("http 401") + || s.contains("oauth token has expired") +} + /// Has Claude Code already persisted a session JSONL for this UUID? /// /// Claude Code stores sessions at @@ -1185,10 +1204,19 @@ where .unwrap_or_else(|| { format!("claude exited without output ({status:?})") }); - on_event(ChatEvent::Error { - turn_id: turn_id.to_string(), - message: format!("claude produced no response: {detail}"), - }); + // On the Panda proxy path, a revoked/expired key surfaces as an auth + // error here (the BE returns 401). Emit a dedicated event the chat UI + // turns into a "Sign in again" action instead of a cryptic message. + if cfg.use_panda_cloud && looks_like_auth_failure(&detail) { + on_event(ChatEvent::AuthExpired { + turn_id: turn_id.to_string(), + }); + } else { + on_event(ChatEvent::Error { + turn_id: turn_id.to_string(), + message: format!("claude produced no response: {detail}"), + }); + } } // Post-turn workspace diff. Emit artifact_changed for everything @@ -1744,6 +1772,22 @@ mod tests { ); } + #[test] + fn looks_like_auth_failure_flags_proxy_401() { + // Anthropic-style 401 body the proxy returns for a revoked key. + assert!(looks_like_auth_failure( + "API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"invalid x-api-key\"}}" + )); + assert!(looks_like_auth_failure("Error: oauth token has expired")); + assert!(looks_like_auth_failure("request failed: HTTP 401")); + // Non-auth failures must NOT be flagged (they get the generic error). + assert!(!looks_like_auth_failure( + "Session ID already in use: 1234" + )); + assert!(!looks_like_auth_failure("spawn node ENOENT")); + assert!(!looks_like_auth_failure("overloaded_error: 529")); + } + #[test] fn build_env_default_disables_autoupdater_only() { let cfg = ClaudeRunConfig { diff --git a/desktop/src-tauri/src/ipc/types.rs b/desktop/src-tauri/src/ipc/types.rs index c44252b..0fc378e 100644 --- a/desktop/src-tauri/src/ipc/types.rs +++ b/desktop/src-tauri/src/ipc/types.rs @@ -303,6 +303,14 @@ pub enum ChatEvent { turn_id: String, message: String, }, + /// The Panda proxy rejected the turn's auth (revoked/expired `ccr-` key → + /// the BE returns 401). Emitted instead of a generic `Error` when + /// `use_panda_cloud` is on and the failure looks like an auth error, so the + /// chat UI can offer a "Sign in again" action rather than a cryptic message. + /// Ends the turn like `Error` does. + AuthExpired { + turn_id: String, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/viewer/src/client/components/chat/ChatSidebar.jsx b/viewer/src/client/components/chat/ChatSidebar.jsx index 3788bb4..2a3691c 100644 --- a/viewer/src/client/components/chat/ChatSidebar.jsx +++ b/viewer/src/client/components/chat/ChatSidebar.jsx @@ -7,6 +7,7 @@ import ChatHistory from "./ChatHistory"; import ChatInput from "./ChatInput"; // import ActionButtons from "./ActionButtons"; import AuthModeControl from "./AuthModeControl"; +import PandaReauthBanner from "./PandaReauthBanner"; import { MessageSquare } from "lucide-react"; const SIDEBAR_WIDTH = 440; @@ -67,6 +68,7 @@ export default function ChatSidebar({ className, }) { const lastError = useChatStore((state) => state.lastError); + const needsPandaReauth = useChatStore((state) => state.needsPandaReauth); const history = useChatStore((state) => state.history); const projectId = useChatStore((state) => state.currentProjectId); const currentProjectName = useProjectsStore((state) => { @@ -215,7 +217,9 @@ export default function ChatSidebar({ )} - {lastError ? ( + + + {lastError && !needsPandaReauth ? (
s.needsPandaReauth); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(""); + const flowRef = useRef(null); + + const signInAgain = useCallback(() => { + if (busy) return; + setBusy(true); + setError(""); + setProgress(null); + const flow = buildPandaLoginFlow({ + runInstall: () => transport.app_panda_login(), + subscribe: (handler) => transport.onPandaLoginProgress(handler), + onChange: ({ progress: p }) => setProgress(p), + onComplete: () => {}, + }); + flowRef.current = flow; + void flow.start().then(() => { + if (flow.state === "done") { + clearPandaReauth(); + } else { + setError(describePandaLoginProgress(flow.progress)); + } + setBusy(false); + setProgress(null); + }); + }, [busy]); + + if (!needsReauth) return null; + + return ( +
+
+ + {error || PANDA_REAUTH_MESSAGE} +
+
+ +
+
+ ); +} diff --git a/viewer/src/client/components/chat/__tests__/chatReducer.test.js b/viewer/src/client/components/chat/__tests__/chatReducer.test.js index 6e19f7c..b026cca 100644 --- a/viewer/src/client/components/chat/__tests__/chatReducer.test.js +++ b/viewer/src/client/components/chat/__tests__/chatReducer.test.js @@ -13,6 +13,7 @@ import { selectLatestGcode3mf, selectLatestStl, INITIAL_CHAT_STATE, + PANDA_REAUTH_MESSAGE, } from "../../../store/chat.js"; const FIXED_NOW = 1_700_000_000_000; @@ -210,6 +211,36 @@ test("error event flips turn status to error and records lastError", () => { assert.equal(errorBlocks[0].message, "sandbox timeout"); }); +test("auth_expired ends the turn and raises the re-auth flag", () => { + const events = [ + { kind: "turn_start", turnId: "t-9" }, + { kind: "auth_expired", turnId: "t-9" }, + ]; + const state = applyEvents(INITIAL_CHAT_STATE, events); + assert.equal(state.turnInProgress, false); + assert.equal(state.needsPandaReauth, true); + assert.equal(state.lastError, PANDA_REAUTH_MESSAGE); + assert.equal(state.history[0].status, "error"); +}); + +test("a fresh turn_start clears the re-auth flag, and clear_panda_reauth resets it", () => { + let state = applyEvents(INITIAL_CHAT_STATE, [ + { kind: "turn_start", turnId: "t-a" }, + { kind: "auth_expired", turnId: "t-a" }, + ]); + assert.equal(state.needsPandaReauth, true); + // Retrying (new turn) optimistically clears it. + state = chatReducer(state, { + type: "chat_event", + event: { kind: "turn_start", turnId: "t-b" }, + }, FIXED_NOW); + assert.equal(state.needsPandaReauth, false); + // The explicit clear action is idempotent. + state = { ...state, needsPandaReauth: true }; + state = chatReducer(state, { type: "clear_panda_reauth" }); + assert.equal(state.needsPandaReauth, false); +}); + test("tool_use_end without a running start is still recorded for observability", () => { const events = [ { kind: "turn_start", turnId: "t-4" }, diff --git a/viewer/src/client/lib/transport.ts b/viewer/src/client/lib/transport.ts index 534d793..11db631 100644 --- a/viewer/src/client/lib/transport.ts +++ b/viewer/src/client/lib/transport.ts @@ -137,7 +137,10 @@ export type ChatEvent = | { kind: "tool_use_end"; turnId: string; tool: string; ok: boolean } | { kind: "artifact_changed"; turnId: string; file: string; reason: "new" | "modified" } | { kind: "turn_end"; turnId: string } - | { kind: "error"; turnId: string; message: string }; + | { kind: "error"; turnId: string; message: string } + // Panda proxy auth was rejected (revoked/expired key → BE 401). The chat UI + // surfaces a "Sign in again" action. Ends the turn like `error`. + | { kind: "auth_expired"; turnId: string }; // Slicer --------------------------------------------------------------------- diff --git a/viewer/src/client/store/chat.js b/viewer/src/client/store/chat.js index d6209d5..2cebeac 100644 --- a/viewer/src/client/store/chat.js +++ b/viewer/src/client/store/chat.js @@ -77,6 +77,11 @@ export const INITIAL_CHAT_STATE = Object.freeze({ // transport) plus a local objectUrl (for an instant composer thumbnail). pendingAttachments: [], lastError: "", + // Set when a turn fails because the Panda proxy rejected auth (revoked/expired + // key → BE 401, surfaced as an `auth_expired` chat event). Drives the + // "Sign in again" banner; cleared on a successful re-login or the next + // turn_start. App-wide (not per-session) since the proxy key is global. + needsPandaReauth: false, // Project-relative path of the part the user currently has selected in the // workspace (the breadcrumb / Models rail). Drives the Slice button target // so "slice" acts on the viewed part, not just the most recent artifact. @@ -111,6 +116,11 @@ export const INITIAL_CHAT_STATE = Object.freeze({ lastSlice: { gcodeFile: "", gcode3mfFile: "" }, }); +// User-facing copy when the Panda proxy key is revoked/expired (the BE 401 → +// `auth_expired` event). Shown in the chat error block and the re-auth banner. +export const PANDA_REAUTH_MESSAGE = + "Your Panda sign-in expired or was revoked. Sign in again to keep chatting."; + // --------------------------------------------------------------------------- // Reducer — pure; the only place state evolves // --------------------------------------------------------------------------- @@ -341,6 +351,20 @@ function applyChatEventToSession(session, event, now) { (turn) => ({ ...appendError(turn, event.message), endedAt: now }), ), }; + case "auth_expired": + // Same turn lifecycle as `error`, with fixed copy. The top-level + // `needsPandaReauth` flag (set in chatReducer) drives the action banner. + return { + ...session, + currentTurnId: session.currentTurnId === turnId ? "" : session.currentTurnId, + turnInProgress: false, + lastError: PANDA_REAUTH_MESSAGE, + history: updateAssistantTurn( + ensureAssistantTurn(session.history, turnId, now), + turnId, + (turn) => ({ ...appendError(turn, PANDA_REAUTH_MESSAGE), endedAt: now }), + ), + }; default: return session; } @@ -453,11 +477,25 @@ export function chatReducer(state, action, now = Date.now()) { let turnOwners = state.turnOwners; if (event.kind === "turn_start" && !knownOwner && ownerProject) { turnOwners = { ...turnOwners, [turnId]: ownerProject }; - } else if (event.kind === "turn_end" || event.kind === "error") { + } else if ( + event.kind === "turn_end" || + event.kind === "error" || + event.kind === "auth_expired" + ) { const { [turnId]: _drop, ...rest } = turnOwners; turnOwners = rest; } + // The proxy key is app-wide, so a Panda auth rejection raises the re-auth + // flag regardless of which project's turn hit it; a fresh turn_start + // clears it (the user re-signed-in or is retrying). + const reauthPatch = + event.kind === "auth_expired" + ? { needsPandaReauth: true } + : event.kind === "turn_start" + ? { needsPandaReauth: false } + : {}; + // Owned by (or just started in) the project on screen → advance the // visible session. A new turn supersedes a stale toolbar slice — clear // it so a chat-produced gcode (arriving as an artifact) wins. @@ -466,6 +504,7 @@ export function chatReducer(state, action, now = Date.now()) { ...state, ...applyChatEventToSession(sessionSlice(state), event, now), turnOwners, + ...reauthPatch, ...(event.kind === "turn_start" ? { lastSlice: INITIAL_CHAT_STATE.lastSlice } : {}), @@ -478,11 +517,12 @@ export function chatReducer(state, action, now = Date.now()) { // event — its result is persisted and reloaded on return. const stash = state.sessions[ownerProject]; if (!stash) { - return { ...state, turnOwners }; + return { ...state, turnOwners, ...reauthPatch }; } return { ...state, turnOwners, + ...reauthPatch, sessions: { ...state.sessions, [ownerProject]: applyChatEventToSession(stash, event, now), @@ -531,6 +571,9 @@ export function chatReducer(state, action, now = Date.now()) { } case "set_error": return { ...state, lastError: action.message }; + case "clear_panda_reauth": + if (!state.needsPandaReauth) return state; + return { ...state, needsPandaReauth: false }; case "reset": return INITIAL_CHAT_STATE; default: @@ -874,6 +917,11 @@ export function consumePendingAttachments() { dispatch({ type: "consume_pending_attachments" }); } +/** Clear the "Sign in again" banner after a successful Panda re-login. */ +export function clearPandaReauth() { + dispatch({ type: "clear_panda_reauth" }); +} + export function resetChatStore() { detachChatEventStream(); setState(INITIAL_CHAT_STATE);