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.
|
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.
|
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
|
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
|
token requires a Claude subscription; without one, `setup-token` finishes without printing a token
|
||||||
and nothing is stored.
|
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
|
- **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.
|
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
|
- **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
|
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
|
- **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
|
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
|
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") {
|
function emitOutput(chunk: string, projectId = "p1") {
|
||||||
act(() => {
|
act(() => {
|
||||||
handlers.get("claude-token-output")?.({
|
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(
|
function renderModal(
|
||||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||||
) {
|
) {
|
||||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
|||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
await flowStarted();
|
await flowStarted();
|
||||||
unmount();
|
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 }> = {
|
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||||
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
||||||
finishing: { tone: "busy", label: "Finishing 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" },
|
succeeded: { tone: "ok", label: "Token stored" },
|
||||||
failed: { tone: "error", label: "Authentication failed" },
|
failed: { tone: "error", label: "Authentication failed" },
|
||||||
};
|
};
|
||||||
@@ -88,7 +91,9 @@ export default function ClaudeAuthModal({
|
|||||||
? PHASE_STATUS.failed
|
? PHASE_STATUS.failed
|
||||||
: flow.codeSubmitted
|
: flow.codeSubmitted
|
||||||
? PHASE_STATUS.finishing
|
? PHASE_STATUS.finishing
|
||||||
: PHASE_STATUS.waiting;
|
: flow.codeRejections > 0
|
||||||
|
? PHASE_STATUS.rejected
|
||||||
|
: PHASE_STATUS.waiting;
|
||||||
|
|
||||||
// Split for display only. `flow.signInUrl` has already passed the host
|
// Split for display only. `flow.signInUrl` has already passed the host
|
||||||
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
import {
|
||||||
|
authErrorMessage,
|
||||||
|
extractSignInUrl,
|
||||||
|
pickSignInUrl,
|
||||||
|
} from "./useClaudeAuth";
|
||||||
|
|
||||||
describe("extractSignInUrl", () => {
|
describe("extractSignInUrl", () => {
|
||||||
it("finds the authorize URL in realistic setup-token output", () => {
|
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";
|
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
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", () => {
|
describe("authErrorMessage", () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||||
import type {
|
import type {
|
||||||
|
ClaudeTokenCodeRejectedEvent,
|
||||||
|
ClaudeTokenLinkEvent,
|
||||||
ClaudeTokenOutputEvent,
|
ClaudeTokenOutputEvent,
|
||||||
ClaudeTokenProgressEvent,
|
ClaudeTokenProgressEvent,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
@@ -19,10 +21,17 @@ import type {
|
|||||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||||
const PROGRESS_EVENT = "claude-token-progress";
|
const PROGRESS_EVENT = "claude-token-progress";
|
||||||
const OUTPUT_EVENT = "claude-token-output";
|
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. */
|
/** Bound on the retained transcript. The tail is the interesting part. */
|
||||||
const MAX_OUTPUT = 64 * 1024;
|
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
|
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
||||||
* backend writes its errors as complete, actionable sentences ("The container
|
* 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
|
* **Every candidate is container output, so all of them are
|
||||||
* attacker-controlled if the sandboxed agent misbehaves.** It is then rendered
|
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||||
* under a heading that says "Sign in with Anthropic" and handed to the host
|
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||||
* browser, which makes this the highest-value URL in the app to spoof: a user
|
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||||
* who follows it types their real Anthropic credentials into whatever it
|
* user who follows it types their real Anthropic credentials into whatever it
|
||||||
* resolves to. Three rules follow, and none of them are optional:
|
* resolves to. Three rules follow, and none of them are optional:
|
||||||
*
|
*
|
||||||
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
* - 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
|
* the complete one — and it cannot swap the origin, because a longer string
|
||||||
* with the same prefix has the same host.
|
* with the same prefix has the same host.
|
||||||
*/
|
*/
|
||||||
export function extractSignInUrl(text: string): string | null {
|
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||||
// eslint-disable-next-line no-control-regex
|
const cleaned = candidates
|
||||||
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(/[.,;:!?)\]}>'"]+$/, ""))
|
|
||||||
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||||
.filter((url): url is string => url !== null);
|
.filter((url): url is string => url !== null);
|
||||||
|
|
||||||
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
|
|||||||
return best;
|
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
|
// Token presence
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
|||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
codeSubmitted: boolean;
|
codeSubmitted: boolean;
|
||||||
submitError: string | null;
|
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>;
|
submitCode: (code: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
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.
|
// Held in a ref so a fresh callback identity cannot restart the flow.
|
||||||
const succeededRef = useRef(onSucceeded);
|
const succeededRef = useRef(onSucceeded);
|
||||||
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
|||||||
: next;
|
: 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) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPhase("failed");
|
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 {
|
return {
|
||||||
phase,
|
phase,
|
||||||
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
|||||||
submitting,
|
submitting,
|
||||||
codeSubmitted,
|
codeSubmitted,
|
||||||
submitError,
|
submitError,
|
||||||
|
codeRejections,
|
||||||
submitCode,
|
submitCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,26 @@ export interface ClaudeTokenOutputEvent {
|
|||||||
chunk: string;
|
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 ───────────────────────────────────────────
|
// ── Container base-image migration ───────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||||
|
|||||||
Reference in New Issue
Block a user