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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 48 additions & 23 deletions desktop/src-tauri/src/commands/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 48 additions & 4 deletions desktop/src-tauri/src/commands/claude_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/ipc/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
6 changes: 5 additions & 1 deletion viewer/src/client/components/chat/ChatSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -215,7 +217,9 @@ export default function ChatSidebar({
)}
</div>

{lastError ? (
<PandaReauthBanner />

{lastError && !needsPandaReauth ? (
<div
data-slot="chat-error-banner"
className="border-t border-destructive/40 bg-destructive/10 px-3 py-1 text-xs text-destructive"
Expand Down
79 changes: 79 additions & 0 deletions viewer/src/client/components/chat/PandaReauthBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { useCallback, useRef, useState } from "react";
import { Loader2, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { transport } from "@/lib/transport.ts";
import { clearPandaReauth, PANDA_REAUTH_MESSAGE, useChatStore } from "@/store/chat";
import {
buildPandaLoginFlow,
describePandaLoginProgress,
} from "@/components/onboarding/onboardingHelpers.js";

/**
* Shown when a chat turn fails because the Panda proxy rejected auth
* (revoked/expired key → BE 401 → `auth_expired` event sets `needsPandaReauth`).
* Offers a one-click re-login that re-runs the browser sign-in; on success it
* clears the banner and the user can resend their message.
*/
export default function PandaReauthBanner() {
const needsReauth = useChatStore((s) => 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 (
<div
data-slot="panda-reauth-banner"
className="flex flex-col gap-2 border-t border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs"
>
<div className="flex items-start gap-2 text-amber-700 dark:text-amber-400">
<ShieldAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{error || PANDA_REAUTH_MESSAGE}</span>
</div>
<div>
<Button
size="sm"
onClick={() => signInAgain()}
disabled={busy}
data-testid="panda-reauth-signin"
>
{busy ? (
<>
<Loader2 className="mr-2 size-3.5 animate-spin" />
{progress ? describePandaLoginProgress(progress) : "Signing in…"}
</>
) : (
"Sign in again"
)}
</Button>
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions viewer/src/client/components/chat/__tests__/chatReducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" },
Expand Down
5 changes: 4 additions & 1 deletion viewer/src/client/lib/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------------

Expand Down
Loading
Loading