Merge pull request #16: Fix shared Claude auth — whole sign-in URL, recoverable rejected code
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m40s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
This commit was merged in pull request #16.
This commit is contained in:
@@ -509,6 +509,10 @@ This lives in the sidebar under **Settings → Claude Authentication**.
|
||||
code to copy — this flow finishes on an Anthropic-hosted page, not a local callback.
|
||||
4. Paste the code back into Triple-C. The token is captured and written straight to the keychain.
|
||||
|
||||
The code is long and easy to truncate. If Anthropic refuses it, the dialog says so and lets you
|
||||
paste another one without restarting the sign-in — the CLI is still waiting. After a few refusals
|
||||
the flow gives up and reports it rather than sitting there.
|
||||
|
||||
Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived
|
||||
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||
and nothing is stored.
|
||||
|
||||
+23
-1
@@ -245,9 +245,31 @@ minutes.
|
||||
|
||||
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
|
||||
frontend, never written to a log, and no command accepts or returns it.
|
||||
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
|
||||
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, 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. Scraping the visible text yields a
|
||||
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
|
||||
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
|
||||
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
|
||||
parameter is container output that is never rendered, which makes it the *easier* place to hide a
|
||||
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
|
||||
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
|
||||
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
|
||||
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
|
||||
The streamed output is scanned for that, `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. Without this the exec sat until the
|
||||
15-minute timeout with the UI still saying "Finishing sign-in".
|
||||
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
|
||||
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
|
||||
could still grow into a secret across a chunk boundary.
|
||||
could still grow into a secret across a chunk boundary. A credential split across a hard line
|
||||
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
|
||||
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
|
||||
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
|
||||
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
|
||||
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
|
||||
token onto the next frame's first word.
|
||||
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
|
||||
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
|
||||
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Every event the hook subscribes to, so the unmount test counts the right
|
||||
* number of teardowns instead of a magic number that drifts. */
|
||||
const EVENT_NAMES = [
|
||||
"claude-token-progress",
|
||||
"claude-token-output",
|
||||
"claude-token-link",
|
||||
"claude-token-code-rejected",
|
||||
];
|
||||
|
||||
/** The sign-in URL at its real length (346 characters, measured against
|
||||
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
|
||||
* transcript ever contains. */
|
||||
const FULL_URL =
|
||||
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||
const TRUNCATED_URL = FULL_URL.slice(0, 80);
|
||||
|
||||
function emitOutput(chunk: string, projectId = "p1") {
|
||||
act(() => {
|
||||
handlers.get("claude-token-output")?.({
|
||||
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
|
||||
});
|
||||
}
|
||||
|
||||
function emitLink(url: string, projectId = "p1") {
|
||||
act(() => {
|
||||
handlers.get("claude-token-link")?.({
|
||||
payload: { project_id: projectId, url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function emitCodeRejected(message: string, attemptsRemaining: number) {
|
||||
act(() => {
|
||||
handlers.get("claude-token-code-rejected")?.({
|
||||
payload: {
|
||||
project_id: "p1",
|
||||
message,
|
||||
attempts_remaining: attemptsRemaining,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||
) {
|
||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
||||
const { unmount } = renderModal();
|
||||
await flowStarted();
|
||||
unmount();
|
||||
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
||||
);
|
||||
});
|
||||
|
||||
// ── The hyperlink target, not the wrapped display text ────────────────
|
||||
//
|
||||
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
||||
// the terminal width, so the transcript holds five 80-character pieces of a
|
||||
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
||||
// parameter and sends it on `claude-token-link`.
|
||||
|
||||
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
// What the transcript holds: the first slice only.
|
||||
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
||||
// What the hyperlink parameter holds: all of it.
|
||||
emitLink(FULL_URL);
|
||||
|
||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
});
|
||||
|
||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a hyperlink belonging to a different project", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink(FULL_URL, "p2");
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── A refused code is recoverable, not a hang ─────────────────────────
|
||||
|
||||
it("reports a rejected code and lets another one be submitted", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
const input = screen.getByLabelText("Authentication code");
|
||||
fireEvent.change(input, { target: { value: "truncated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
||||
);
|
||||
// Before the rejection arrives the UI claims the sign-in is completing.
|
||||
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
||||
|
||||
emitCodeRejected(
|
||||
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
||||
2,
|
||||
);
|
||||
|
||||
// Reported, not waited out — and the flow is still live.
|
||||
await screen.findByText(/That code was rejected/);
|
||||
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
||||
|
||||
// A second code goes through without restarting the whole flow.
|
||||
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
||||
);
|
||||
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ends with a reported failure when the retries run out", async () => {
|
||||
acquireClaudeToken.mockRejectedValue(
|
||||
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const banner = await screen.findByTestId("claude-auth-error");
|
||||
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Props {
|
||||
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
||||
finishing: { tone: "busy", label: "Finishing sign-in" },
|
||||
// The CLI refused a code and is back at its prompt. Distinct from "failed":
|
||||
// the flow is still live and another code will be accepted.
|
||||
rejected: { tone: "error", label: "Code rejected — try again" },
|
||||
succeeded: { tone: "ok", label: "Token stored" },
|
||||
failed: { tone: "error", label: "Authentication failed" },
|
||||
};
|
||||
@@ -88,6 +91,8 @@ export default function ClaudeAuthModal({
|
||||
? PHASE_STATUS.failed
|
||||
: flow.codeSubmitted
|
||||
? PHASE_STATUS.finishing
|
||||
: flow.codeRejections > 0
|
||||
? PHASE_STATUS.rejected
|
||||
: PHASE_STATUS.waiting;
|
||||
|
||||
// Split for display only. `flow.signInUrl` has already passed the host
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
||||
import {
|
||||
authErrorMessage,
|
||||
extractSignInUrl,
|
||||
pickSignInUrl,
|
||||
} from "./useClaudeAuth";
|
||||
|
||||
describe("extractSignInUrl", () => {
|
||||
it("finds the authorize URL in realistic setup-token output", () => {
|
||||
@@ -79,6 +83,64 @@ describe("extractSignInUrl", () => {
|
||||
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||
});
|
||||
|
||||
// ── Why the scraper is only the fallback ─────────────────────────────────
|
||||
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
||||
// *visible* text of it to the terminal width, so the transcript holds five
|
||||
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
||||
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
||||
// anything.
|
||||
|
||||
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
||||
const slices = [
|
||||
FULL_URL.slice(0, 80),
|
||||
FULL_URL.slice(80, 160),
|
||||
FULL_URL.slice(160, 240),
|
||||
FULL_URL.slice(240, 320),
|
||||
FULL_URL.slice(320),
|
||||
];
|
||||
const scraped = extractSignInUrl(slices.join("\n"));
|
||||
|
||||
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
||||
// the "extends the current pick" rule cannot join them, and guessing at
|
||||
// line joins on an untrusted stream is not on the table.
|
||||
expect(scraped).toBe(slices[0]);
|
||||
expect(scraped).not.toBe(FULL_URL);
|
||||
|
||||
// The hyperlink parameter carries the whole thing, and that is what the
|
||||
// hook prefers.
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
});
|
||||
|
||||
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
||||
* 2.1.226). */
|
||||
const FULL_URL =
|
||||
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||
|
||||
describe("pickSignInUrl", () => {
|
||||
it("keeps a 346-character authorize URL intact", () => {
|
||||
expect(FULL_URL).toHaveLength(346);
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
|
||||
it("applies the same host allowlist to a hyperlink target", () => {
|
||||
// An OSC 8 parameter is container output like anything else, and it is
|
||||
// never displayed — so it is the *easier* place to hide a hostile host.
|
||||
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
||||
expect(
|
||||
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
||||
).toBeNull();
|
||||
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
||||
expect(pickSignInUrl([])).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let a later hyperlink displace the one already shown", () => {
|
||||
const real = `${FULL_URL}`;
|
||||
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
||||
expect(pickSignInUrl([real, spoof])).toBe(real);
|
||||
expect(pickSignInUrl([spoof, real])).toBe(real);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authErrorMessage", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
@@ -19,10 +21,17 @@ import type {
|
||||
/** 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
|
||||
@@ -38,13 +47,13 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
||||
* Choose one sign-in URL from a list of candidates.
|
||||
*
|
||||
* **The transcript is container output, so every candidate here is
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** It is then 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
|
||||
* **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
|
||||
@@ -60,14 +69,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
* the complete one — and it cannot swap the origin, because a longer string
|
||||
* with the same prefix has the same host.
|
||||
*/
|
||||
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;
|
||||
|
||||
const cleaned = matches
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
||||
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);
|
||||
|
||||
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
||||
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);
|
||||
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
||||
: 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");
|
||||
@@ -261,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
||||
// 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,
|
||||
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
||||
submitting,
|
||||
codeSubmitted,
|
||||
submitError,
|
||||
codeRejections,
|
||||
submitCode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,6 +502,26 @@ export interface ClaudeTokenOutputEvent {
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-link`: a sign-in URL taken from an OSC 8
|
||||
* hyperlink parameter, which is the only place the CLI emits it whole — the
|
||||
* visible text is sliced to the terminal width. **Untrusted**: it is container
|
||||
* output, so it goes through `sanitizeRelayUrl` with the
|
||||
* `ANTHROPIC_SIGN_IN_HOSTS` allowlist before it is shown or opened. */
|
||||
export interface ClaudeTokenLinkEvent {
|
||||
project_id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Payload of `claude-token-code-rejected`: `claude setup-token` refused the
|
||||
* submitted code and is parked waiting for another one. The flow is still
|
||||
* alive, so this is recoverable — `attempts_remaining` is how many more codes
|
||||
* the backend will pass on before giving up. */
|
||||
export interface ClaudeTokenCodeRejectedEvent {
|
||||
project_id: string;
|
||||
message: string;
|
||||
attempts_remaining: number;
|
||||
}
|
||||
|
||||
// ── Container base-image migration ───────────────────────────────────────────
|
||||
//
|
||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||
|
||||
Reference in New Issue
Block a user