Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two compounding bugs made `claude setup-token` unusable, both measured against 2.1.226 under a pty rather than reasoned about. **The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and slices the *visible* text of that hyperlink to the terminal width: a 346 character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the whole URL in its parameter and 80 characters of it on screen. The transcript scraper picked up the first slice — a URL that parses, points at claude.com, and cannot authorise anything. The ANSI stripper now surfaces the OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over the scraped text. It still goes through `sanitizeRelayUrl` with the ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an OSC 8 parameter is never rendered, which makes it the easier place to hide a hostile host, not a trusted one. The wrapped-display fallback is kept for CLI versions that print a bare URL. **A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting; nothing recognised that, so the exec sat until the 15-minute timeout with the UI still saying "Finishing sign-in". Given the first bug handed the user a truncated URL, an invalid code was the likely first outcome. The streamed output is now scanned for that message, `claude-token-code-rejected` reopens the input with an explanation, and the Enter is sent so the next code has a prompt to land in — bounded by MAX_CODE_ATTEMPTS, after which the flow reports a failure. An undeterminable exec exit status is logged rather than silently read as success. **A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an 80-column fallback splits the ~103 character token across two lines: the parser saw a too-short fragment and failed, while the redactor masked the first line — which carries the `sk-ant-` marker — and printed the second, the tail of a live credential, to the UI in clear. `scan_credential_body` now reassembles a run across hard wraps and both the parser and the redactor use it, so they cannot disagree about where a credential ends. A join only happens across a break at a plausible terminal margin (>= 40 columns) and only for a run not already long enough to be a whole credential — without that second guard a repainting TUI welds one frame's token onto the next frame's first word. The length floor is applied to the reassembled body, so a fragment is still never accepted. Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is handled as the three-byte charset designation it is — it prefixes every repaint frame, and treating it as two bytes emitted a stray `B` that could glue itself onto a token and make the parser refuse it. `submit_claude_token_code`'s single-write behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
import * as commands from "../lib/tauri-commands";
|
|
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
|
import type {
|
|
ClaudeTokenCodeRejectedEvent,
|
|
ClaudeTokenLinkEvent,
|
|
ClaudeTokenOutputEvent,
|
|
ClaudeTokenProgressEvent,
|
|
} from "../lib/types";
|
|
|
|
/**
|
|
* Front-end half of the shared Claude Code token flow.
|
|
*
|
|
* The token itself never crosses the IPC boundary — `has_claude_token` returns
|
|
* a boolean and the streamed output is redacted backend-side. Nothing in here
|
|
* stores, parses, or renders a credential; the transcript is displayed as-is
|
|
* precisely because it has already been scrubbed.
|
|
*/
|
|
|
|
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
|
const PROGRESS_EVENT = "claude-token-progress";
|
|
const OUTPUT_EVENT = "claude-token-output";
|
|
const LINK_EVENT = "claude-token-link";
|
|
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
|
|
|
|
/** Bound on the retained transcript. The tail is the interesting part. */
|
|
const MAX_OUTPUT = 64 * 1024;
|
|
|
|
/** Bound on retained sign-in candidates. The backend already deduplicates
|
|
* consecutive repeats; this stops a container that prints a fresh hyperlink
|
|
* every frame from growing state without limit. */
|
|
const MAX_LINKS = 16;
|
|
|
|
/**
|
|
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
|
* backend writes its errors as complete, actionable sentences ("The container
|
|
* for 'x' is not running. Start it, then run authentication again."). So use
|
|
* them verbatim rather than stringifying an opaque value, and only synthesise
|
|
* a message when the rejection is something else — a thrown `Error`, or an IPC
|
|
* channel that died without one.
|
|
*/
|
|
export function authErrorMessage(e: unknown, fallback: string): string {
|
|
if (typeof e === "string" && e.trim()) return e.trim();
|
|
if (e instanceof Error && e.message.trim()) return e.message.trim();
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Choose one sign-in URL from a list of candidates.
|
|
*
|
|
* **Every candidate is container output, so all of them are
|
|
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
|
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
|
* host browser, which makes this the highest-value URL in the app to spoof: a
|
|
* user who follows it types their real Anthropic credentials into whatever it
|
|
* resolves to. Three rules follow, and none of them are optional:
|
|
*
|
|
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
|
* host allowlist. Only Anthropic's own domains can be a sign-in link;
|
|
* userinfo (`https://claude.ai@evil.tld/...`) and control characters are
|
|
* rejected there.
|
|
* - The **first** surviving candidate wins. The previous rule was
|
|
* longest-wins, which handed the choice to the attacker: pad a hostile URL
|
|
* and it displaces the real one that came before it.
|
|
* - The one exception is a candidate that *extends* the current pick, i.e.
|
|
* starts with it. That is the case longest-wins existed for — a repainting
|
|
* TUI can land a truncated copy of the same link in the transcript before
|
|
* the complete one — and it cannot swap the origin, because a longer string
|
|
* with the same prefix has the same host.
|
|
*/
|
|
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
|
const cleaned = candidates
|
|
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
|
.filter((url): url is string => url !== null);
|
|
|
|
const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
|
|
const pool = oauth.length > 0 ? oauth : cleaned;
|
|
|
|
let best: string | null = null;
|
|
for (const url of pool) {
|
|
if (best === null || url.startsWith(best)) best = url;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
|
|
*
|
|
* **This is the fallback, not the primary route.** The CLI emits the URL as an
|
|
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
|
|
* terminal width — measured at 80 columns, a 346-character URL arrives as five
|
|
* 80-character pieces on five lines. Nothing scraping the visible text can put
|
|
* those back together: the pieces share no prefix, so the "extends the current
|
|
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
|
|
* untrusted stream is exactly the sort of thing the rules above exist to
|
|
* forbid. What comes out is the first 80 characters — a URL that parses, that
|
|
* points at claude.com, and that cannot authorise anything.
|
|
*
|
|
* So the backend lifts the whole URL out of the hyperlink parameter and sends
|
|
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
|
|
* This remains for CLI versions that print a bare URL with no hyperlink at all,
|
|
* where a URL narrow enough not to wrap is recovered correctly.
|
|
*/
|
|
export function extractSignInUrl(text: string): string | null {
|
|
// eslint-disable-next-line no-control-regex
|
|
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
|
if (!matches) return null;
|
|
|
|
// Trailing punctuation belongs to the prose, not the URL.
|
|
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Token presence
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type ClaudeTokenStatus = "checking" | "stored" | "absent" | "unavailable";
|
|
|
|
/** Whether a shared token exists, plus a way to re-check after a change. */
|
|
export function useClaudeTokenStatus() {
|
|
const [status, setStatus] = useState<ClaudeTokenStatus>("checking");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const present = await commands.hasClaudeToken();
|
|
setStatus(present ? "stored" : "absent");
|
|
setError(null);
|
|
} catch (e) {
|
|
setStatus("unavailable");
|
|
setError(
|
|
authErrorMessage(
|
|
e,
|
|
"Could not read the OS keychain, so whether a shared token exists is unknown.",
|
|
),
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
return { status, error, refresh };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Acquisition
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type AcquisitionPhase = "running" | "succeeded" | "failed";
|
|
|
|
export interface ClaudeTokenAcquisition {
|
|
phase: AcquisitionPhase;
|
|
/** Milestone messages from `claude-token-progress`, oldest first. */
|
|
progress: string[];
|
|
/** Redacted transcript from `claude-token-output`. */
|
|
output: string;
|
|
signInUrl: string | null;
|
|
/** Set when the flow ends badly; always a full sentence the user can act on. */
|
|
error: string | null;
|
|
submitting: boolean;
|
|
codeSubmitted: boolean;
|
|
submitError: string | null;
|
|
/**
|
|
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
|
|
* still alive and waiting for another one — a recoverable state, not the end
|
|
* of the flow.
|
|
*/
|
|
codeRejections: number;
|
|
submitCode: (code: string) => Promise<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Runs one `acquire_claude_token` flow for the lifetime of the calling
|
|
* component. Starts on mount, so mount this only when the user has asked for
|
|
* it — the backend allows a single flow at a time.
|
|
*
|
|
* `onSucceeded` fires once, after the token has been stored.
|
|
*/
|
|
export function useClaudeTokenAcquisition(
|
|
projectId: string,
|
|
onSucceeded?: () => void,
|
|
): ClaudeTokenAcquisition {
|
|
const [phase, setPhase] = useState<AcquisitionPhase>("running");
|
|
const [progress, setProgress] = useState<string[]>([]);
|
|
const [output, setOutput] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [codeRejections, setCodeRejections] = useState(0);
|
|
// Candidates from `claude-token-link`, in arrival order. Kept as a list
|
|
// rather than a single value so `pickSignInUrl` applies the same first-wins
|
|
// rule here as it does to the scraped transcript — the CLI reprints the same
|
|
// hyperlink after every retry, and a *different* one arriving later must not
|
|
// be able to displace the one the user was already shown.
|
|
const [links, setLinks] = useState<string[]>([]);
|
|
|
|
// Held in a ref so a fresh callback identity cannot restart the flow.
|
|
const succeededRef = useRef(onSucceeded);
|
|
succeededRef.current = onSucceeded;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const unlisteners: UnlistenFn[] = [];
|
|
|
|
const register = async <T,>(name: string, handle: (payload: T) => void) => {
|
|
const unlisten = await listen<T>(name, (event) => handle(event.payload));
|
|
// Registration is async: if the component went away while we were
|
|
// awaiting, drop the listener now rather than leaking it.
|
|
if (cancelled) {
|
|
unlisten();
|
|
return;
|
|
}
|
|
unlisteners.push(unlisten);
|
|
};
|
|
|
|
void (async () => {
|
|
try {
|
|
await register<ClaudeTokenProgressEvent>(PROGRESS_EVENT, (payload) => {
|
|
if (payload.project_id !== projectId) return;
|
|
setProgress((prev) =>
|
|
prev[prev.length - 1] === payload.message
|
|
? prev
|
|
: [...prev, payload.message],
|
|
);
|
|
});
|
|
await register<ClaudeTokenOutputEvent>(OUTPUT_EVENT, (payload) => {
|
|
if (payload.project_id !== projectId) return;
|
|
setOutput((prev) => {
|
|
const next = prev + payload.chunk;
|
|
return next.length > MAX_OUTPUT
|
|
? next.slice(next.length - MAX_OUTPUT)
|
|
: next;
|
|
});
|
|
});
|
|
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
|
|
if (payload.project_id !== projectId) return;
|
|
setLinks((prev) =>
|
|
prev.includes(payload.url) || prev.length >= MAX_LINKS
|
|
? prev
|
|
: [...prev, payload.url],
|
|
);
|
|
});
|
|
await register<ClaudeTokenCodeRejectedEvent>(
|
|
CODE_REJECTED_EVENT,
|
|
(payload) => {
|
|
if (payload.project_id !== projectId) return;
|
|
// The CLI is alive and back at its prompt, so this is a correction
|
|
// the user can act on — not a failure. Re-open the input and say
|
|
// why, rather than leaving "Finishing sign-in" on screen forever.
|
|
setCodeRejections((n) => n + 1);
|
|
setCodeSubmitted(false);
|
|
setSubmitError(payload.message);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
if (cancelled) return;
|
|
setPhase("failed");
|
|
setError(
|
|
authErrorMessage(
|
|
e,
|
|
"Could not subscribe to the authentication events, so the flow was not started. Restart Triple-C and try again.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (cancelled) return;
|
|
|
|
try {
|
|
await commands.acquireClaudeToken(projectId);
|
|
if (cancelled) return;
|
|
setPhase("succeeded");
|
|
succeededRef.current?.();
|
|
} catch (e) {
|
|
if (cancelled) return;
|
|
setPhase("failed");
|
|
setError(
|
|
authErrorMessage(
|
|
e,
|
|
"`claude setup-token` did not finish. No token was stored — try again.",
|
|
),
|
|
);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
for (const unlisten of unlisteners) {
|
|
try {
|
|
unlisten();
|
|
} catch {
|
|
// Nothing useful to do while tearing down.
|
|
}
|
|
}
|
|
};
|
|
}, [projectId]);
|
|
|
|
const submitCode = useCallback(async (code: string) => {
|
|
const trimmed = code.trim();
|
|
if (!trimmed) {
|
|
setSubmitError("Enter the code shown after signing in.");
|
|
return false;
|
|
}
|
|
setSubmitting(true);
|
|
setSubmitError(null);
|
|
try {
|
|
await commands.submitClaudeTokenCode(trimmed);
|
|
setCodeSubmitted(true);
|
|
return true;
|
|
} catch (e) {
|
|
setSubmitError(
|
|
authErrorMessage(
|
|
e,
|
|
"Could not deliver the code to `claude setup-token`. Copy it again and retry.",
|
|
),
|
|
);
|
|
return false;
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, []);
|
|
|
|
// The hyperlink parameter wins whenever there is one: it is the only place
|
|
// the CLI emits the URL contiguously. Scraping the visible text is the
|
|
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
|
const signInUrl = useMemo(
|
|
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
|
[links, output],
|
|
);
|
|
|
|
return {
|
|
phase,
|
|
progress,
|
|
output,
|
|
signInUrl,
|
|
error,
|
|
submitting,
|
|
codeSubmitted,
|
|
submitError,
|
|
codeRejections,
|
|
submitCode,
|
|
};
|
|
}
|