diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 2ff9ab531..612a93cb8 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -22,14 +22,15 @@ const AUTH_COMMAND_USAGE: Record = { continue: `${APP_NAME} auth --continue [payload]`, }; +const AUTH_COMMAND_NAME: Record = { + check: "auth check", + api_key: "auth print-api-key", + bearer_token: "auth print-bearer-token", + continue: "auth --continue", +}; + export function getAuthCommandName(kind: AuthCommandKind): string { - return kind === "check" - ? "auth check" - : kind === "api_key" - ? "auth print-api-key" - : kind === "bearer_token" - ? "auth print-bearer-token" - : "auth --continue"; + return AUTH_COMMAND_NAME[kind]; } export function getAuthCommandUsage(kind: AuthCommandKind): string { diff --git a/packages/coding-agent/src/cli/auth-continue.ts b/packages/coding-agent/src/cli/auth-continue.ts index 75ba4155d..8147f5f8b 100644 --- a/packages/coding-agent/src/cli/auth-continue.ts +++ b/packages/coding-agent/src/cli/auth-continue.ts @@ -2,12 +2,12 @@ import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import { stdin as input, stdout as output } from "node:process"; import { createInterface, type Interface } from "node:readline/promises"; -import type { AuthEvent, AuthPrompt, AuthResult } from "@earendil-works/pi-ai"; -import type { McpOAuthState } from "@earendil-works/pi-mcp/oauth"; +import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { APP_NAME } from "../config.ts"; import { ModelRuntime } from "../core/model-runtime.ts"; import { openBrowser } from "../utils/open-browser.ts"; +import { getAuthCredential } from "./auth-command.ts"; export class AuthContinueError extends Error {} @@ -211,29 +211,10 @@ async function readPayloadFromUser(): Promise { } } -function normalizeServiceUrl(value: string): string { - const url = new URL(value); - url.pathname = url.pathname.replace(/\/$/u, ""); - url.search = ""; - url.hash = ""; - return url.href; -} - -function continuationEndpoint( - payload: AuthContinuationPayload, - kind: "claim" | "complete", - serviceUrl: string, -): string { - const url = new URL(`/v1/auth/continuations/${encodeURIComponent(payload.continuationId)}/${kind}`, serviceUrl); - return url.href; -} - -function credentialFromAuth(auth: AuthResult | undefined): string | undefined { - if (auth?.auth.apiKey) return auth.auth.apiKey; - const authorization = Object.entries(auth?.auth.headers ?? {}).find( - ([name]) => name.toLowerCase() === "authorization", - )?.[1]; - return typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; +/** The endpoint path is absolute, so only the origin of `serviceUrl` matters. */ +function continuationEndpoint(payload: AuthContinuationPayload, kind: "claim" | "complete"): string { + return new URL(`/v1/auth/continuations/${encodeURIComponent(payload.continuationId)}/${kind}`, payload.serviceUrl) + .href; } function printAuthEvent(event: AuthEvent): void { @@ -270,7 +251,7 @@ async function getContinuationServiceToken(providerId: string, signal: AbortSign const runtime = await ModelRuntime.create({ allowModelNetwork: false, signal }); let token: string | undefined; try { - token = credentialFromAuth(await runtime.getAuth(providerId, { minOAuthValidityMs: 5 * 60_000, signal })); + token = getAuthCredential(await runtime.getAuth(providerId, { minOAuthValidityMs: 5 * 60_000, signal })); } catch (error) { if (signal.aborted) throw error; console.log( @@ -296,7 +277,7 @@ async function getContinuationServiceToken(providerId: string, signal: AbortSign } finally { rl.close(); } - token = credentialFromAuth(await runtime.getAuth(providerId, { minOAuthValidityMs: 5 * 60_000, signal })); + token = getAuthCredential(await runtime.getAuth(providerId, { minOAuthValidityMs: 5 * 60_000, signal })); if (!token) throw new AuthContinueError("Sign-in did not produce a bearer token"); return token; } @@ -331,16 +312,14 @@ async function postContinuation( async function completeMcpContinuation( payload: AuthContinuationPayload, token: string, - serviceUrl: string, body: JsonObject, signal: AbortSignal, ): Promise { const uploadSignal = AbortSignal.any([signal, AbortSignal.timeout(30_000)]); await postContinuation( - continuationEndpoint(payload, "complete", serviceUrl), + continuationEndpoint(payload, "complete"), token, { - continuationId: payload.continuationId, secret: payload.secret, mcp: { serverUrl: payload.mcp.serverUrl, @@ -356,7 +335,6 @@ async function completeMcpContinuation( async function runMcpBearerContinuation( payload: BearerAuthContinuationPayload, token: string, - serviceUrl: string, signal: AbortSignal, ): Promise { const rl = createInterface({ input, output }); @@ -369,7 +347,7 @@ async function runMcpBearerContinuation( } finally { rl.close(); } - await completeMcpContinuation(payload, token, serviceUrl, { bearerToken }, signal); + await completeMcpContinuation(payload, token, { bearerToken }, signal); openBrowser(payload.returnUrl); console.log(chalk.green("MCP bearer token uploaded.")); } @@ -377,7 +355,6 @@ async function runMcpBearerContinuation( async function runMcpOAuthContinuation( payload: OAuthAuthContinuationPayload, token: string, - serviceUrl: string, signal: AbortSignal, ): Promise { const [{ MemoryOAuthStateStore }, { signInMcpServer, McpSignInCancelledError }] = await Promise.all([ @@ -385,28 +362,20 @@ async function runMcpOAuthContinuation( import("../extensions/mcp/oauth.ts"), ]); const store = new MemoryOAuthStateStore(); - let state: McpOAuthState | undefined; - const captureStore = { - load: () => store.load(), - save: (next: McpOAuthState) => { - state = structuredClone(next); - store.save(next); - }, - }; let uploaded = false; let browserResponseSent = false; const upload = async (): Promise => { - state ??= store.load(); + const state = store.load(); if (!state?.tokens) throw new AuthContinueError("MCP sign-in did not produce OAuth tokens"); console.log(chalk.dim("Uploading MCP authentication to continuation service...")); - await completeMcpContinuation(payload, token, serviceUrl, { authState: state }, signal); + await completeMcpContinuation(payload, token, { authState: state }, signal); uploaded = true; }; const rl = createInterface({ input, output }); try { const result = await signInMcpServer({ serverUrl: payload.mcp.serverUrl, - store: captureStore, + store, settings: { scope: payload.mcp.oauth?.scope, clientName: payload.mcp.oauth?.clientName ?? APP_NAME, @@ -449,36 +418,25 @@ async function runMcpOAuthContinuation( console.log(chalk.green("MCP authentication uploaded.")); } -async function runMcpContinuation( - payload: AuthContinuationPayload, - token: string, - serviceUrl: string, - signal: AbortSignal, -): Promise { +async function runMcpContinuation(payload: AuthContinuationPayload, token: string, signal: AbortSignal): Promise { if (isBearerContinuation(payload)) { - await runMcpBearerContinuation(payload, token, serviceUrl, signal); + await runMcpBearerContinuation(payload, token, signal); return; } if (isOAuthContinuation(payload)) { - await runMcpOAuthContinuation(payload, token, serviceUrl, signal); + await runMcpOAuthContinuation(payload, token, signal); return; } - await completeMcpContinuation(payload, token, serviceUrl, {}, signal); + await completeMcpContinuation(payload, token, {}, signal); openBrowser(payload.returnUrl); console.log(chalk.green("MCP connection saved.")); } export async function runAuthContinuation(payload: AuthContinuationPayload): Promise { - const serviceUrl = normalizeServiceUrl(payload.serviceUrl); const signal = AbortSignal.timeout(15 * 60_000); const token = await getContinuationServiceToken(payload.providerId, signal); - await postContinuation( - continuationEndpoint(payload, "claim", serviceUrl), - token, - { continuationId: payload.continuationId, secret: payload.secret }, - signal, - ); - await runMcpContinuation(payload, token, serviceUrl, signal); + await postContinuation(continuationEndpoint(payload, "claim"), token, { secret: payload.secret }, signal); + await runMcpContinuation(payload, token, signal); } export async function runAuthContinueCommand(args: string[]): Promise { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cbc2270fb..b67adb33d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -26,7 +26,7 @@ import { printAuthCommandHelp, validateAuthCommandArgs, } from "./cli/auth-command.ts"; -import { AuthContinueError, runAuthContinueCommand } from "./cli/auth-continue.ts"; +import { runAuthContinueCommand } from "./cli/auth-continue.ts"; import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; @@ -207,12 +207,10 @@ async function runAuthCommand(args: string[]): Promise { process.stdout.write(`${output}\n`); process.exitCode = result.status === "ready" ? 0 : result.status === "not_ready" ? 1 : 2; } catch (error) { - const message = - error instanceof AuthCommandError || error instanceof AuthContinueError || command.kind === "continue" - ? error instanceof Error - ? error.message - : String(error) - : "Failed to resolve credential"; + // Continuation errors come from the user's payload or the continuation service, so show them as is. + const showError = error instanceof AuthCommandError || command.kind === "continue"; + const detail = error instanceof Error ? error.message : String(error); + const message = showError ? detail : "Failed to resolve credential"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = command.kind === "check" ? 2 : 1; }