chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
1 parent
719370c8f7
commit
6081e3e325
1743 files changed
+205459
-131704
No files matched your search
@@ -1,8 +1,10 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
|
||||
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
const nativeFetch = globalThis.fetch;
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -61,6 +63,7 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
if (event.type === "auth_url") authUrl = event.url;
|
||||
},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") return "browser";
|
||||
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
const url = new URL(authUrl);
|
||||
const state = url.searchParams.get("state");
|
||||
@@ -75,6 +78,70 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers browser login first and uses the selected Anthropic copy code flow", async () => {
|
||||
const selectPrompts: Array<{
|
||||
message: string;
|
||||
options: readonly { id: string; label: string }[];
|
||||
}> = [];
|
||||
let authUrl = "";
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
||||
const body = getJsonBody(init);
|
||||
expect(body.grant_type).toBe("authorization_code");
|
||||
expect(body.code).toBe("copied-code");
|
||||
expect(body.state).toBe(new URL(authUrl).searchParams.get("state"));
|
||||
expect(body.redirect_uri).toBe("https://platform.claude.com/oauth/code/callback");
|
||||
return jsonResponse({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentials = await anthropicOAuth.login({
|
||||
signal: neverAbortedSignal,
|
||||
notify: (event) => {
|
||||
if (event.type === "auth_url") authUrl = event.url;
|
||||
},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
selectPrompts.push(prompt);
|
||||
return "copy_code";
|
||||
}
|
||||
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
return `copied-code#${new URL(authUrl).searchParams.get("state")}`;
|
||||
},
|
||||
});
|
||||
|
||||
expect(credentials.access).toBe("access-token");
|
||||
expect(credentials.refresh).toBe("refresh-token");
|
||||
expect(new URL(authUrl).searchParams.get("redirect_uri")).toBe("https://platform.claude.com/oauth/code/callback");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(selectPrompts).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
message: "Select Anthropic login method:",
|
||||
options: [
|
||||
{ id: "browser", label: "Browser login (default)" },
|
||||
{ id: "copy_code", label: "Copy code login (headless)" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels when Anthropic login method selection is cancelled", async () => {
|
||||
await expect(
|
||||
anthropicOAuth.login({
|
||||
signal: neverAbortedSignal,
|
||||
prompt: async () => {
|
||||
throw new Error("Login cancelled");
|
||||
},
|
||||
notify: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Login cancelled");
|
||||
});
|
||||
|
||||
it("omits scope from refresh token requests", async () => {
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
||||
@@ -126,6 +193,7 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
notify: (event) => events.push(event),
|
||||
prompt: async (prompt) => {
|
||||
prompts.push(prompt);
|
||||
if (prompt.type === "select") return "browser";
|
||||
if (prompt.type === "manual_code") {
|
||||
manualSignal = prompt.signal;
|
||||
return "the-code";
|
||||
@@ -141,4 +209,91 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
// the prompt's signal is aborted once login settles, so UIs can dismiss it
|
||||
expect(manualSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("completes login through the browser callback and shows the sign-in page", async () => {
|
||||
const login = await loginThroughBrowserCallback();
|
||||
|
||||
expect(login.credential.access).toBe("access");
|
||||
expect(login.exchangedCode).toBe("browser-code");
|
||||
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
|
||||
expect(login.pageStatus).toBe(200);
|
||||
expect(login.pageText).toContain("Signed in to Anthropic.");
|
||||
});
|
||||
|
||||
// #10571
|
||||
it("falls back to a free callback port when the preferred port cannot be bound", async () => {
|
||||
const blocker = createServer();
|
||||
await listen(blocker, 53692);
|
||||
try {
|
||||
const login = await loginThroughBrowserCallback();
|
||||
const redirectUri = new URL(login.redirectUri);
|
||||
|
||||
expect(redirectUri.hostname).toBe("localhost");
|
||||
expect(redirectUri.pathname).toBe("/callback");
|
||||
expect(redirectUri.port).not.toBe("53692");
|
||||
expect(login.credential.access).toBe("access");
|
||||
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
|
||||
expect(login.pageStatus).toBe(200);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function listen(server: Server, port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loginThroughBrowserCallback() {
|
||||
let exchangedCode: string | undefined;
|
||||
let exchangedRedirectUri: string | undefined;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") return nativeFetch(input as string, init);
|
||||
const body = getJsonBody(init);
|
||||
exchangedCode = body.code;
|
||||
exchangedRedirectUri = body.redirect_uri;
|
||||
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
|
||||
}),
|
||||
);
|
||||
|
||||
let redirectUri = "";
|
||||
let callbackPage: Promise<Response> | undefined;
|
||||
const credential = await anthropicOAuth.login({
|
||||
signal: neverAbortedSignal,
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
const params = new URL(event.url).searchParams;
|
||||
redirectUri = params.get("redirect_uri") ?? "";
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.hostname = "127.0.0.1";
|
||||
callbackUrl.search = new URLSearchParams({
|
||||
code: "browser-code",
|
||||
state: params.get("state") ?? "",
|
||||
}).toString();
|
||||
callbackPage = nativeFetch(callbackUrl);
|
||||
},
|
||||
prompt: (prompt) =>
|
||||
prompt.type === "select"
|
||||
? Promise.resolve("browser")
|
||||
: new Promise((_, reject) => {
|
||||
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
||||
}),
|
||||
});
|
||||
const page = await callbackPage;
|
||||
return {
|
||||
credential,
|
||||
redirectUri,
|
||||
exchangedCode,
|
||||
exchangedRedirectUri,
|
||||
pageStatus: page?.status,
|
||||
pageText: await page?.text(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user