diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index de6bd9b0f..7a51caad1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711)) - Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545)) - `pi.registerMarkdownTransformer()` takes an optional `{ messageTypes }` that selects the Markdown surfaces a transformer runs on. Besides user, assistant, and thinking Markdown, transformers can now opt into `custom` (custom messages and skill blocks), `summary` (branch and compaction summaries), and `changelog` (What's New, `/changelog`, the Extension Updates notice, and the Update Available note). Without options a transformer runs on user, assistant, and thinking Markdown as before. The built-in Mermaid renderer now also draws diagrams in expanded branch and compaction summaries +- `pi auth login ` logs in to a provider from the shell without starting a session, for example `pi auth login radius`. It signs in with an account in the browser and exits; `--api-key` saves an API key instead, and `--no-browser` uses device code or copy-code sign-in instead. Logging in to Radius also configures the Radius MCP server. `pi auth logout ` removes the stored credentials, like `/logout` ### Changed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index 8861d22ad..216289ac9 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -12,6 +12,8 @@ pi uninstall [options] pi update [target] [options] pi list pi config [options] +pi auth login [--api-key] [--no-browser] +pi auth logout pi auth [options] pi mcp [options] ``` @@ -302,11 +304,22 @@ export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}" ## Credential commands +`pi auth login ` logs in to a provider in the terminal and exits, without starting a session. It signs in with an account and opens the browser, like `/login `. Providers without account sign-in ask for an API key. Logging in to Radius also adds the Radius MCP server to `~/.pi/agent/mcp.json` when no global server uses that login yet. It requires an interactive terminal. + +| Option | Description | +|---|---| +| `--api-key` | Save an API key instead of signing in with an account; the key is read without echoing it | +| `--no-browser` | Do not open a browser; use the provider's other sign-in method, such as a device code, for example over SSH. Asks when there are several. Not allowed with `--api-key` | + +`pi auth logout ` removes the credentials that `pi auth login` or `/login` stored, like `/logout`. Environment variables and `models.json` config are unchanged. It exits with status `0` when nothing is stored, so it is safe to repeat. + ```sh +pi auth login radius +pi auth logout radius pi auth check --provider openai --json ``` -Authentication commands require `--provider ` or `--model `. See [Providers](providers.md) for supported methods. +The other authentication commands require `--provider ` or `--model `. See [Providers](providers.md) for supported methods. | Command | Description | |---|---| diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 4bcd28dc2..8de8441c4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -293,7 +293,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) - ${APP_NAME} auth Print credentials or check provider readiness + ${APP_NAME} auth Log in or out, print credentials, or check provider readiness ${APP_NAME} mcp Manage MCP servers ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth/mcp diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 9ee804602..687e5dbd5 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -38,11 +38,35 @@ export function isAuthCommandHelp(args: string[]): boolean { export function printAuthCommandHelp(): void { console.log(`Usage: + pi auth login [--api-key] [--no-browser] + pi auth logout pi auth print-api-key [--provider ] [--model ] pi auth print-bearer-token [--provider ] [--model ] [--min-expiry ] pi auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] -Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`); +Log in to and out of model providers, print their credentials for external clients, or check that they are ready. +Credentials are stored in ~/.pi/agent/auth.json. + +Commands: + login Sign in with an account in the browser, or save an API key, then exit + logout Remove the stored credentials; environment variables and models.json stay + print-api-key Print the resolved API key + print-bearer-token Print a resolved OAuth bearer token (refreshes if expired) + check Print ready, not_ready, or invalid; exit with 0, 1, or 2 + +Options for print-api-key, print-bearer-token, and check (at least one is required): + --provider Resolve credentials for a provider + --model Resolve credentials from a model + +Options for login: + --api-key Save an API key instead of signing in with an account + --no-browser Do not open a browser; use another sign-in method, e.g. device code + +Other options: + --min-expiry Minimum remaining token lifetime for print-bearer-token, e.g. 30m or 1h + --json Print the check result as JSON + --credentials Emit the credential from check, or include it in JSON output + --no-refresh Do not refresh expired OAuth credentials during check`); } export function parseAuthCommand(args: string[]): AuthCommand | undefined { @@ -58,7 +82,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined { : undefined; if (!kind) { throw new AuthCommandError( - `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth login", "${APP_NAME} auth logout", "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, ); } diff --git a/packages/coding-agent/src/cli/auth-login.ts b/packages/coding-agent/src/cli/auth-login.ts new file mode 100644 index 000000000..d0729be5d --- /dev/null +++ b/packages/coding-agent/src/cli/auth-login.ts @@ -0,0 +1,277 @@ +import type { AuthInteraction, AuthPrompt, AuthType, Provider } from "@earendil-works/pi-ai"; +import { replaceControlCharacters } from "@earendil-works/pi-tui"; +import { APP_NAME, getAuthPath } from "../config.ts"; +import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts"; +import { findMatchingProviders } from "../core/provider-matching.ts"; +import { RADIUS_PROVIDER_ID } from "../core/radius.ts"; +import { planRadiusMcpServer } from "../core/radius-mcp.ts"; +import { addMcpServerConfig } from "../extensions/mcp/config.ts"; +import { displayUrl, webUrl } from "../utils/web-url.ts"; + +import type { LoginTerminal } from "./auth-terminal.ts"; + +/** Dependencies of `runAuthLogin`, injected so tests can script the terminal. */ +export interface AuthLoginOptions { + modelRuntime: ModelRuntime; + terminal: LoginTerminal; + agentDir: string; + cwd: string; + getDeviceId: () => string; + /** Aborts the login, e.g. on Ctrl+C. */ + signal: AbortSignal; +} + +const AUTH_LOGIN_USAGE = `${APP_NAME} auth login [--api-key] [--no-browser]`; + +/** Parsed `pi auth login` arguments. */ +interface LoginRequest { + providerRef: string; + /** Log in with an API key instead of an account. */ + apiKey: boolean; + /** Open the browser and take the browser sign-in method without asking. */ + browser: boolean; +} + +/** Run `pi auth login ` in the terminal and return the process exit code. */ +export async function runAuthLogin(args: string[], options: AuthLoginOptions): Promise { + const { modelRuntime, terminal, signal } = options; + const parsed = parseLoginRequest(args); + if (parsed.type === "invalid") { + terminal.error(parsed.message); + return 1; + } + const { request } = parsed; + const { providerRef } = request; + + const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0]; + if (!provider) { + const loginProviders = modelRuntime + .getProviders() + .filter((candidate) => candidate.auth.oauth || candidate.auth.apiKey?.login) + .map((candidate) => candidate.id) + .sort(); + terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`); + terminal.error(`Providers: ${loginProviders.join(", ")}`); + return 1; + } + if (!terminal.interactive) { + terminal.error(`"${AUTH_LOGIN_USAGE}" requires an interactive terminal`); + return 1; + } + + const authType = selectAuthType(provider, request.apiKey); + if (authType.type === "unavailable") { + terminal.error(authType.message); + return 1; + } + try { + await modelRuntime.login( + provider.id, + authType.type, + createLoginInteraction(provider.id, authType.type, request, terminal, signal), + { getDeviceId: options.getDeviceId }, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CredentialSynchronizationError) { + terminal.error(`Logged in to ${provider.name}, but local model state could not be synchronized: ${message}`); + } else if (signal.aborted || message === "Login cancelled") { + terminal.error("Login cancelled."); + } else { + terminal.error(`Failed to log in to ${provider.name}: ${message}`); + } + return 1; + } + + terminal.write( + `${authType.type === "oauth" ? `Logged in to ${provider.name}` : `Saved API key for ${provider.name}`}. Credentials saved to ${getAuthPath()}`, + ); + await refreshProviderCatalog(provider, modelRuntime, terminal, signal); + if (provider.id === RADIUS_PROVIDER_ID) configureRadiusMcpServer(provider, options); + return 0; +} + +function parseLoginRequest( + args: string[], +): { type: "valid"; request: LoginRequest } | { type: "invalid"; message: string } { + const usage = { type: "invalid", message: `Usage: ${AUTH_LOGIN_USAGE}` } as const; + const request: LoginRequest = { providerRef: "", apiKey: false, browser: true }; + for (const arg of args) { + if (arg === "--api-key") request.apiKey = true; + else if (arg === "--no-browser") request.browser = false; + else if (arg.startsWith("-") || request.providerRef) return usage; + else request.providerRef = arg; + } + if (!request.providerRef) return usage; + if (request.apiKey && !request.browser) { + return { type: "invalid", message: "--no-browser only applies to account sign-in; omit it with --api-key." }; + } + return { type: "valid", request }; +} + +/** + * Account login unless `apiKey` asks for an API key. Providers without account login use their API key login, since + * that is the only way in. + */ +function selectAuthType( + provider: Provider, + apiKey: boolean, +): { type: AuthType } | { type: "unavailable"; message: string } { + const hasApiKeyLogin = provider.auth.apiKey?.login !== undefined; + if (!apiKey && provider.auth.oauth) return { type: "oauth" }; + if (hasApiKeyLogin) return { type: "api_key" }; + if (apiKey && provider.auth.oauth) { + return { type: "unavailable", message: `${provider.name} has no API key login. Omit --api-key to sign in.` }; + } + const method = provider.auth.apiKey?.name ?? "ambient credentials"; + return { type: "unavailable", message: `${provider.name} is configured outside ${APP_NAME} (${method}).` }; +} + +/** Adapt provider prompts and events to the command's selected sign-in method. */ +function createLoginInteraction( + providerId: string, + authType: AuthType, + request: LoginRequest, + terminal: LoginTerminal, + signal: AbortSignal, +): AuthInteraction { + // Account logins list the browser sign-in first, as /login; API key setup asks every selection. + const methodChoice = authType === "api_key" ? "ask" : request.browser ? "browser" : "other"; + let skippedMethods: string[] = []; + + async function selectMethod(prompt: Extract, promptSignal: AbortSignal) { + const [first, ...others] = prompt.options; + if (methodChoice === "ask" || !first) { + return selectInTerminal(prompt.message, prompt.options, terminal, promptSignal); + } + if (methodChoice === "browser") { + skippedMethods = others.map((option) => option.label); + return first.id; + } + const [onlyOther, ...moreOthers] = others; + // A provider whose only method is the browser sign-in still gets it, without opening the browser. + if (!onlyOther) return first.id; + if (moreOthers.length > 0) return selectInTerminal(prompt.message, others, terminal, promptSignal); + terminal.write(`${prompt.message} ${onlyOther.label}`); + return onlyOther.id; + } + + return { + signal, + prompt(prompt) { + // The browser callback can finish a step before the pasted code, cancelling just that prompt. + const promptSignal = prompt.signal ? AbortSignal.any([signal, prompt.signal]) : signal; + if (prompt.type === "select") return selectMethod(prompt, promptSignal); + const placeholder = prompt.placeholder ? ` (${prompt.placeholder})` : ""; + const message = `${prompt.message}${placeholder}: `; + return prompt.type === "secret" + ? terminal.secret(message, promptSignal) + : terminal.question(message, promptSignal); + }, + notify(event) { + if (event.type === "auth_url") { + terminal.write(`Open this URL to sign in:\n ${displayUrl(event.url)}`); + if (event.instructions) terminal.write(event.instructions); + const target = webUrl(event.url); + if (target !== undefined && request.browser) terminal.openUrl(target); + // Providers without a method choice have nothing else to offer, so they get no hint. + const rerun = `"${APP_NAME} auth login ${providerId} --no-browser"`; + const [onlyMethod, ...moreMethods] = skippedMethods; + if (onlyMethod !== undefined && moreMethods.length === 0) { + terminal.write(`To use "${onlyMethod}" instead, run ${rerun}`); + } else if (onlyMethod !== undefined) { + const labels = skippedMethods.map((label) => `"${label}"`).join(", "); + terminal.write(`Other sign-in methods: ${labels}. Run ${rerun} to choose one.`); + } + } else if (event.type === "device_code") { + terminal.write(`Open ${displayUrl(event.verificationUri)} and enter code: ${event.userCode}`); + const target = webUrl(event.verificationUri); + if (target !== undefined && request.browser && event.openBrowserOnConfirm !== false) { + terminal.openUrl(target); + } + terminal.write("Waiting for authentication..."); + } else if (event.type === "info") { + terminal.write(event.message); + for (const link of event.links ?? []) { + terminal.write(` ${link.label ? `${link.label}: ` : ""}${displayUrl(link.url)}`); + } + } else { + terminal.write(event.message); + } + }, + }; +} + +async function selectInTerminal( + message: string, + options: readonly { id: string; label: string; description?: string }[], + terminal: LoginTerminal, + signal: AbortSignal, +): Promise { + terminal.write(message); + options.forEach((option, index) => { + terminal.write(` ${index + 1}. ${option.label}${option.description ? ` - ${option.description}` : ""}`); + }); + while (true) { + const answer = (await terminal.question(`Select 1-${options.length}: `, signal)).trim(); + const option = /^\d+$/u.test(answer) ? options[Number(answer) - 1] : undefined; + if (option) return option.id; + terminal.write(`Enter a number from 1 to ${options.length}.`); + } +} + +/** Fetch the provider's model catalog now, since dynamic catalogs stay empty until the first authenticated refresh. */ +async function refreshProviderCatalog( + provider: Provider, + modelRuntime: ModelRuntime, + terminal: LoginTerminal, + signal: AbortSignal, +) { + // The login is saved by now, so Ctrl+C only skips the refresh. + const cancelled = () => { + terminal.write(`Skipped the ${provider.name} model catalog refresh; ${APP_NAME} refreshes it on next start.`); + }; + try { + const result = await modelRuntime.refresh({ + providers: [provider.id], + signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]), + }); + if (signal.aborted) { + cancelled(); + } else if (result.aborted) { + terminal.write( + `The ${provider.name} model catalog refresh timed out; ${APP_NAME} refreshes it on next start.`, + ); + } else if (result.errors.size > 0) { + terminal.write( + `The ${provider.name} model catalog could not be refreshed; ${APP_NAME} retries on next start.`, + ); + } + } catch (error: unknown) { + if (signal.aborted) { + cancelled(); + return; + } + terminal.write( + `The ${provider.name} model catalog could not be refreshed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Point the Radius MCP server in the global mcp.json at the Radius login without asking, since the login is what the + * server needs; /login asks instead. Nothing changes when a global server already uses this login. + */ +function configureRadiusMcpServer(provider: Provider, options: AuthLoginOptions): void { + const { terminal } = options; + const plan = planRadiusMcpServer(provider.id, options.agentDir, options.cwd); + if (!plan) return; + try { + const replaced = addMcpServerConfig(plan.mcpPath, plan.name, plan.config); + terminal.write( + `${replaced ? "Updated" : "Added"} MCP server "${plan.name}" in ${plan.mcpPath} to use the ${provider.name} login.`, + ); + } catch (error: unknown) { + terminal.error(`Could not update ${plan.mcpPath}: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/packages/coding-agent/src/cli/auth-logout.ts b/packages/coding-agent/src/cli/auth-logout.ts new file mode 100644 index 000000000..59a201440 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-logout.ts @@ -0,0 +1,72 @@ +import type { CredentialInfo } from "@earendil-works/pi-ai"; +import { replaceControlCharacters } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../config.ts"; +import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts"; +import { findMatchingProviders } from "../core/provider-matching.ts"; +import type { AuthOutput } from "./auth-terminal.ts"; + +/** Dependencies of `runAuthLogout`, injected so tests can capture the output. */ +export interface AuthLogoutOptions { + modelRuntime: ModelRuntime; + terminal: AuthOutput; + signal: AbortSignal; +} + +const AUTH_LOGOUT_USAGE = `${APP_NAME} auth logout `; + +/** + * Run `pi auth logout `: remove the credential `pi auth login` or /login stored, and return the exit code. + * Environment variables and models.json config stay, as with /logout. + */ +export async function runAuthLogout(args: string[], options: AuthLogoutOptions): Promise { + const { modelRuntime, terminal, signal } = options; + const [providerRef, ...rest] = args; + if (providerRef === undefined || providerRef.startsWith("-") || rest.length > 0) { + terminal.error(`Usage: ${AUTH_LOGOUT_USAGE}`); + return 1; + } + + let stored: readonly CredentialInfo[]; + try { + stored = await modelRuntime.listCredentials({ signal }); + } catch (error: unknown) { + terminal.error(`Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0]; + // Providers registered by extensions are not loaded here, so their stored credentials match by id alone. + const credential = stored.find((candidate) => candidate.providerId === (provider?.id ?? providerRef)); + if (!provider && !credential) { + terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`); + const storedIds = stored.map((credential) => credential.providerId).sort(); + terminal.error(`Stored credentials: ${storedIds.join(", ") || "none"}`); + return 1; + } + const providerName = provider?.name ?? providerRef; + if (!credential) { + terminal.write( + `No stored credentials for ${providerName}. Environment variables and models.json config are unchanged.`, + ); + return 0; + } + + try { + await modelRuntime.logout(credential.providerId, { signal }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CredentialSynchronizationError) { + terminal.error( + `Credentials removed for ${providerName}, but local model state could not be synchronized: ${message}`, + ); + } else { + terminal.error(`Logout failed: ${message}`); + } + return 1; + } + terminal.write( + credential.type === "oauth" + ? `Logged out of ${providerName}.` + : `Removed stored API key for ${providerName}. Environment variables and models.json config are unchanged.`, + ); + return 0; +} diff --git a/packages/coding-agent/src/cli/auth-terminal.ts b/packages/coding-agent/src/cli/auth-terminal.ts new file mode 100644 index 000000000..a11a09e40 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-terminal.ts @@ -0,0 +1,78 @@ +import { openBrowser } from "../utils/open-browser.ts"; +import { questionTerminal } from "../utils/terminal-question.ts"; + +/** Output shared by the terminal authentication commands. */ +export interface AuthOutput { + write(line: string): void; + error(line: string): void; +} + +/** Line-based terminal I/O for `pi auth login`. Prompts reject when `signal` aborts. */ +export interface LoginTerminal extends AuthOutput { + /** Whether stdin and stdout are a terminal; login prompts need one. */ + interactive: boolean; + question(message: string, signal: AbortSignal): Promise; + /** Read a line without echoing it. */ + secret(message: string, signal: AbortSignal): Promise; + openUrl(url: string): void; +} + +/** Terminal I/O on stdin and stdout. `onInterrupt` runs on Ctrl+C or EOF in a question, or Ctrl+C in a secret. */ +export function createProcessLoginTerminal(onInterrupt: () => void): LoginTerminal { + return { + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, + write: (line) => process.stdout.write(`${line}\n`), + error: (line) => process.stderr.write(`${line}\n`), + question: (message, signal) => questionTerminal(message, { signal, output: process.stdout, onInterrupt }), + secret: (message, signal) => readSecret(message, signal, onInterrupt), + openUrl: openBrowser, + }; +} + +function readSecret(message: string, signal: AbortSignal, onInterrupt: () => void): Promise { + const stdin = process.stdin; + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("Login cancelled")); + return; + } + let value = ""; + const finish = () => { + stdin.off("data", onData); + signal.removeEventListener("abort", onAbort); + stdin.setRawMode(false); + stdin.pause(); + process.stdout.write("\n"); + }; + const onAbort = () => { + finish(); + reject(new Error("Login cancelled")); + }; + const onData = (data: Buffer) => { + const chunk = data.toString("utf8"); + // Ignore escape sequences such as arrow keys. + if (chunk.startsWith("\u001b")) return; + for (const char of chunk) { + if (char === "\r" || char === "\n") { + finish(); + resolve(value); + return; + } + if (char === "\u0003") { + onInterrupt(); + return; + } + if (char === "\u007f" || char === "\b") { + value = Array.from(value).slice(0, -1).join(""); + } else if (char >= " ") { + value += char; + } + } + }; + process.stdout.write(message); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/coding-agent/src/core/provider-matching.ts b/packages/coding-agent/src/core/provider-matching.ts new file mode 100644 index 000000000..26b94d563 --- /dev/null +++ b/packages/coding-agent/src/core/provider-matching.ts @@ -0,0 +1,11 @@ +/** Match provider IDs and display names exactly, ignoring case and surrounding whitespace in the reference. */ +export function findMatchingProviders( + providers: readonly T[], + providerRef: string, +): T[] { + const normalized = providerRef.trim().toLowerCase(); + if (!normalized) return []; + return providers.filter( + (provider) => provider.id.toLowerCase() === normalized || provider.name.toLowerCase() === normalized, + ); +} diff --git a/packages/coding-agent/src/core/radius-mcp.ts b/packages/coding-agent/src/core/radius-mcp.ts new file mode 100644 index 000000000..13a9751e6 --- /dev/null +++ b/packages/coding-agent/src/core/radius-mcp.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { mcpNamespace } from "@earendil-works/pi-mcp"; +import { loadMcpConfig } from "../extensions/mcp/config.ts"; +import type { McpHttpServerConfig } from "./mcp-servers.ts"; +import { RADIUS_MCP_URL } from "./radius.ts"; + +/** Change to the global mcp.json that points the Radius MCP server at a Radius login. */ +export interface RadiusMcpServerPlan { + mcpPath: string; + name: string; + config: McpHttpServerConfig; +} + +/** + * The global mcp.json entry that makes the Radius MCP server use the login of `providerId`, adding the server when + * missing, or undefined when a global server already uses that login. + */ +export function planRadiusMcpServer( + providerId: string, + agentDir: string, + cwd: string, +): RadiusMcpServerPlan | undefined { + const normalizeUrl = (url: string) => url.replace(/\/+$/u, ""); + const { servers } = loadMcpConfig({ agentDir, cwd, projectTrusted: false }); + const existing = servers.find( + (server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL), + ); + if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return undefined; + + let name = existing?.name ?? "radius"; + if (!existing) { + const namespaces = new Set(servers.map((server) => mcpNamespace(server.name))); + for (let suffix = 1; namespaces.has(mcpNamespace(name)); suffix++) { + name = suffix === 1 ? "radius-mcp" : `radius-mcp-${suffix}`; + } + } + const config: McpHttpServerConfig = + existing && "url" in existing.config + ? { ...existing.config, auth: { provider: providerId } } + : { url: RADIUS_MCP_URL, auth: { provider: providerId } }; + // `auth` replaces the MCP OAuth sign-in. + delete config.oauth; + return { mcpPath: path.join(agentDir, "mcp.json"), name, config }; +} diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index 9dba82a84..c55bc8002 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -7,13 +7,13 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { createInterface } from "node:readline/promises"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME } from "../../config.ts"; import { validateMcpServerConfig } from "../../core/mcp-servers.ts"; import { ModelRuntime } from "../../core/model-runtime.ts"; import { ProjectTrustStore } from "../../core/trust-manager.ts"; import { openBrowser } from "../../utils/open-browser.ts"; +import { questionTerminal } from "../../utils/terminal-question.ts"; import { addMcpServerConfig, getMcpToolExposure, @@ -285,7 +285,7 @@ export async function runMcpCommand(args: string[], options: McpCommandOptions): const authProvider = getAuthProvider(entry); if (authProvider) { error( - `MCP server "${name}" uses the credentials of provider "${authProvider}". Run /${command} ${authProvider} in ${APP_NAME}.`, + `MCP server "${name}" uses the credentials of provider "${authProvider}". Run "${APP_NAME} auth ${command} ${authProvider}".`, ); return 1; } @@ -659,21 +659,19 @@ async function login( /** * The pasted redirect URL in a terminal; otherwise only the browser callback can finish the sign-in. - * Resolves to undefined when `signal` aborts: the callback arrived, or the sign-in timed out. + * Resolves to undefined on cancellation or EOF, or when the callback arrives or the sign-in times out. */ async function waitForRedirectUrl(signal: AbortSignal, interactive: boolean): Promise { if (!interactive) { if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })); return undefined; } - const readline = createInterface({ input: process.stdin, output: process.stderr }); try { - return await readline.question("If the browser cannot reach this machine, paste the URL it was redirected to: ", { + return await questionTerminal("If the browser cannot reach this machine, paste the URL it was redirected to: ", { signal, + output: process.stderr, }); } catch { return undefined; - } finally { - readline.close(); } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 34111cca6..080ac0f0f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -26,6 +26,9 @@ import { printAuthCommandHelp, validateAuthCommandArgs, } from "./cli/auth-command.ts"; +import { runAuthLogin } from "./cli/auth-login.ts"; +import { runAuthLogout } from "./cli/auth-logout.ts"; +import { createProcessLoginTerminal } from "./cli/auth-terminal.ts"; import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; @@ -130,11 +133,55 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } +async function runAuthLoginCommand(args: string[]): Promise { + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal: AbortSignal.timeout(15_000) }); + + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.on("SIGINT", interrupt); + try { + return await runAuthLogin(args, { + modelRuntime, + terminal: createProcessLoginTerminal(interrupt), + agentDir, + cwd, + getDeviceId: () => settingsManager.getOrCreateDeviceId(), + signal: controller.signal, + }); + } finally { + process.off("SIGINT", interrupt); + } +} + +async function runAuthLogoutCommand(args: string[]): Promise { + const signal = AbortSignal.timeout(15_000); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); + return runAuthLogout(args, { + modelRuntime, + terminal: { write: (line) => console.log(line), error: (line) => console.error(line) }, + signal, + }); +} + async function runAuthCommand(args: string[]): Promise { if (isAuthCommandHelp(args)) { printAuthCommandHelp(); return true; } + if (args[0] === "auth" && args[1] === "login") { + // main() already applied --offline. + process.exitCode = await runAuthLoginCommand(args.slice(2).filter((arg) => arg !== "--offline")); + return true; + } + if (args[0] === "auth" && args[1] === "logout") { + process.exitCode = await runAuthLogoutCommand(args.slice(2).filter((arg) => arg !== "--offline")); + return true; + } let command: AuthCommand | undefined; try { diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 95e676803..0172c0042 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -13,30 +13,12 @@ import { } from "@earendil-works/pi-tui"; import { copyToClipboard } from "../../../utils/clipboard.ts"; import { openBrowser } from "../../../utils/open-browser.ts"; +import { displayUrl, webUrl } from "../../../utils/web-url.ts"; import { theme } from "../theme/theme.ts"; import { AuthUrlComponent } from "./auth-url.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; -/** - * The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom - * protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a - * terminal hyperlink early. - */ -function webUrl(value: string): string | undefined { - if (!URL.canParse(value)) return undefined; - const url = new URL(value); - return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; -} - -/** - * `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a - * hyperlink early or send its own escape sequences to the terminal. - */ -function displayUrl(value: string): string { - return webUrl(value) ?? replaceControlCharacters(value); -} - /** `text` as a terminal hyperlink to `url` when that is a web URL, else plain `text`. */ function webLink(text: string, url: string): string { const target = webUrl(url); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 999e1640d..c65c40c2a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -96,7 +96,6 @@ import type { import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; -import type { McpHttpServerConfig } from "../../core/mcp-servers.ts"; import { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts"; import { defaultModelPerProvider, @@ -105,7 +104,9 @@ import { } from "../../core/model-resolver.ts"; import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; -import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts"; +import { findMatchingProviders } from "../../core/provider-matching.ts"; +import { RADIUS_PROVIDER_ID } from "../../core/radius.ts"; +import { planRadiusMcpServer } from "../../core/radius-mcp.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { @@ -122,7 +123,7 @@ import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; -import { addMcpServerConfig, loadMcpConfig } from "../../extensions/mcp/config.ts"; +import { addMcpServerConfig } from "../../extensions/mcp/config.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -5980,26 +5981,13 @@ export class InteractiveMode { .sort((a, b) => a.name.localeCompare(b.name)); } - private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { - const normalizedProviderRef = providerRef.trim().toLowerCase(); - if (!normalizedProviderRef) { - return []; - } - - return this.getLoginProviderOptions().filter( - (provider) => - provider.id.toLowerCase() === normalizedProviderRef || - provider.name.toLowerCase() === normalizedProviderRef, - ); - } - private async handleLoginCommand(providerRef?: string): Promise { if (!providerRef) { this.showLoginAuthTypeSelector(); return; } - const providerOptions = this.findLoginProviderOptions(providerRef); + const providerOptions = findMatchingProviders(this.getLoginProviderOptions(), providerRef); if (providerOptions.length === 1) { await this.startProviderLogin(providerOptions[0]!); return; @@ -6520,26 +6508,9 @@ export class InteractiveMode { * when missing. Nothing is asked when a global server already uses this login. */ private offerRadiusMcpServer(providerId: string, providerName: string): void { - const mcpPath = path.join(getAgentDir(), "mcp.json"); - const normalizeUrl = (url: string) => url.replace(/\/+$/u, ""); - const { servers } = loadMcpConfig({ - agentDir: getAgentDir(), - cwd: this.sessionManager.getCwd(), - projectTrusted: false, - }); - const existing = servers.find( - (server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL), - ); - if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return; - - let name = existing?.name ?? "radius"; - if (!existing && servers.some((server) => server.name === name)) name = "radius-mcp"; - const config: McpHttpServerConfig = - existing && "url" in existing.config - ? { ...existing.config, auth: { provider: providerId } } - : { url: RADIUS_MCP_URL, auth: { provider: providerId } }; - // `auth` replaces the MCP OAuth sign-in. - delete config.oauth; + const plan = planRadiusMcpServer(providerId, getAgentDir(), this.sessionManager.getCwd()); + if (!plan) return; + const { mcpPath, name, config } = plan; this.showSelector((done) => { const selector = new ExtensionSelectorComponent( diff --git a/packages/coding-agent/src/utils/terminal-question.ts b/packages/coding-agent/src/utils/terminal-question.ts new file mode 100644 index 000000000..138c06d9b --- /dev/null +++ b/packages/coding-agent/src/utils/terminal-question.ts @@ -0,0 +1,29 @@ +import { createInterface } from "node:readline/promises"; + +/** Read one line from stdin, rejecting on abort, Ctrl+C, or EOF and always closing readline. */ +export async function questionTerminal( + message: string, + options: { + output: NodeJS.WritableStream; + signal: AbortSignal; + /** Called on Ctrl+C or EOF, not when the caller aborts just this prompt. */ + onInterrupt?: () => void; + }, +): Promise { + const readline = createInterface({ input: process.stdin, output: options.output }); + const interrupted = new AbortController(); + // EOF closes readline without settling its pending question; Ctrl+C normally only pauses it. + const onInterrupt = () => interrupted.abort(); + readline.on("SIGINT", onInterrupt); + readline.on("close", onInterrupt); + try { + return await readline.question(message, { signal: AbortSignal.any([options.signal, interrupted.signal]) }); + } catch (error) { + if (interrupted.signal.aborted) options.onInterrupt?.(); + throw error; + } finally { + readline.off("close", onInterrupt); + readline.off("SIGINT", onInterrupt); + readline.close(); + } +} diff --git a/packages/coding-agent/src/utils/web-url.ts b/packages/coding-agent/src/utils/web-url.ts new file mode 100644 index 000000000..f4e75e607 --- /dev/null +++ b/packages/coding-agent/src/utils/web-url.ts @@ -0,0 +1,20 @@ +import { replaceControlCharacters } from "@earendil-works/pi-tui"; + +/** + * The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom + * protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a + * terminal hyperlink early. + */ +export function webUrl(value: string): string | undefined { + if (!URL.canParse(value)) return undefined; + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; +} + +/** + * `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a + * hyperlink early or send its own escape sequences to the terminal. + */ +export function displayUrl(value: string): string { + return webUrl(value) ?? replaceControlCharacters(value); +} diff --git a/packages/coding-agent/test/auth-login.test.ts b/packages/coding-agent/test/auth-login.test.ts new file mode 100644 index 000000000..77d73f324 --- /dev/null +++ b/packages/coding-agent/test/auth-login.test.ts @@ -0,0 +1,481 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryModelsStore, type Model, type Provider } from "@earendil-works/pi-ai"; +import { describe, expect, test, vi } from "vitest"; +import { runAuthLogin } from "../src/cli/auth-login.ts"; +import type { LoginTerminal } from "../src/cli/auth-terminal.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts"; + +const PROVIDER_ID = "login-test"; + +interface ScriptedTerminal extends LoginTerminal { + lines: string[]; + errors: string[]; + questions: string[]; + opened: string[]; +} + +/** Answers prompts in order; `secret` and `question` share one answer queue. */ +function scriptedTerminal(answers: string[], interactive = true): ScriptedTerminal { + const lines: string[] = []; + const errors: string[] = []; + const questions: string[] = []; + const opened: string[] = []; + const answer = async (message: string, signal: AbortSignal) => { + questions.push(message); + if (signal.aborted) throw new Error("Login cancelled"); + const next = answers.shift(); + if (next === undefined) throw new Error(`Unexpected prompt: ${message}`); + return next; + }; + return { + interactive, + lines, + errors, + questions, + opened, + write: (line) => lines.push(line), + error: (line) => errors.push(line), + question: answer, + secret: answer, + openUrl: (url) => opened.push(url), + }; +} + +function testModel(providerId: string): Model<"openai-completions"> { + return { + id: "test-model", + name: "test-model", + api: "openai-completions", + provider: providerId, + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }; +} + +function testProvider(auth: Provider["auth"], providerId: string): Provider { + const unused = () => { + throw new Error("unused"); + }; + return { + id: providerId, + name: "Login Test", + auth, + getModels: () => [testModel(providerId)], + stream: unused, + streamSimple: unused, + }; +} + +const bothMethods: Provider["auth"] = { + oauth: { + name: "Login Test account", + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Sign in with browser" }, + { id: "device", label: "Sign in with device code" }, + ], + }); + if (method === "device") { + interaction.notify({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://example.test/device", + }); + return { type: "oauth", access: "access-device", refresh: "refresh", expires: Date.now() + 3_600_000 }; + } + interaction.notify({ + type: "auth_url", + url: "https://example.test/authorize", + instructions: "Finish in the browser", + }); + const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" }); + return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + apiKey: { + name: "Login Test API key", + login: async (interaction) => ({ + type: "api_key", + key: await interaction.prompt({ type: "secret", message: "Enter Login Test API key" }), + }), + resolve: async () => undefined, + }, +}; + +async function setup(auth: Provider["auth"], providerId = PROVIDER_ID) { + const credentials = AuthStorage.inMemory(); + const modelRuntime = await ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + }); + modelRuntime.registerNativeProvider(testProvider(auth, providerId)); + return { credentials, modelRuntime }; +} + +function run( + args: string[], + modelRuntime: ModelRuntime, + terminal: LoginTerminal, + signal = new AbortController().signal, + agentDir = "/nonexistent-agent-dir", +) { + return runAuthLogin(args, { + modelRuntime, + terminal, + agentDir, + cwd: "/nonexistent-cwd", + getDeviceId: () => "device", + signal, + }); +} + +describe("pi auth login", () => { + const usage = "Usage: pi auth login [--api-key] [--no-browser]"; + + test("requires exactly one provider and known options", async () => { + const { modelRuntime } = await setup(bothMethods); + for (const args of [[], ["--api-key"], ["--provider", PROVIDER_ID], [PROVIDER_ID, "extra"]]) { + const terminal = scriptedTerminal([]); + expect(await run(args, modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual([usage]); + } + }); + + test("rejects --no-browser with --api-key", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--api-key", "--no-browser"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["--no-browser only applies to account sign-in; omit it with --api-key."]); + }); + + test("rejects an unknown provider and lists the providers with a login flow", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run(["nope"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors[0]).toBe('Unknown provider "nope".'); + expect(terminal.errors[1]).toContain(PROVIDER_ID); + }); + + test("requires an interactive terminal after checking the arguments", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([], false); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual([ + '"pi auth login [--api-key] [--no-browser]" requires an interactive terminal', + ]); + }); + + test("signs in with an account in the browser without asking for a method", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal(["the-code"]); + // Matches the provider name as well as the id. + expect(await run(["login test"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Paste the code: "]); + expect(terminal.lines).toContain("Open this URL to sign in:\n https://example.test/authorize"); + expect(terminal.lines).toContain("Finish in the browser"); + expect(terminal.opened).toEqual(["https://example.test/authorize"]); + expect(terminal.lines).toContain( + 'To use "Sign in with device code" instead, run "pi auth login login-test --no-browser"', + ); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" }); + expect(terminal.lines.some((line) => line.startsWith("Logged in to Login Test."))).toBe(true); + }); + + test("lists several other sign-in methods", async () => { + const { modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Browser" }, + { id: "device", label: "Device code" }, + { id: "copy", label: "Copy code" }, + ], + }); + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + return { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.lines).toContain( + 'Other sign-in methods: "Device code", "Copy code". Run "pi auth login login-test --no-browser" to choose one.', + ); + }); + + test("offers no other sign-in methods when the provider has no method choice", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" }); + return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal(["the-code"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.opened).toEqual(["https://example.test/authorize"]); + expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" }); + }); + + test("--no-browser takes the only other sign-in method and opens nothing", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual([]); + expect(terminal.lines[0]).toBe("Sign in to Login Test: Sign in with device code"); + expect(terminal.lines).toContain("Open https://example.test/device and enter code: ABCD-1234"); + expect(terminal.opened).toEqual([]); + expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-device" }); + }); + + test("--no-browser asks among several other sign-in methods and re-asks on an invalid selection", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Browser" }, + { id: "device", label: "Device code" }, + { id: "copy", label: "Copy code" }, + ], + }); + return { + type: "oauth", + access: `access-${method}`, + refresh: "refresh", + expires: Date.now() + 3_600_000, + }; + }, + }, + }); + const terminal = scriptedTerminal(["7", "2"]); + expect(await run(["--no-browser", PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.lines.slice(0, 3)).toEqual(["Sign in to Login Test:", " 1. Device code", " 2. Copy code"]); + expect(terminal.lines).toContain("Enter a number from 1 to 2."); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-copy" }); + }); + + test("--no-browser keeps a provider's only sign-in method without opening the browser", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [{ id: "browser", label: "Browser" }], + }); + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + return { + type: "oauth", + access: `access-${method}`, + refresh: "refresh", + expires: Date.now() + 3_600_000, + }; + }, + }, + }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual([]); + expect(terminal.opened).toEqual([]); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-browser" }); + }); + + test("asks for selections during API key setup", async () => { + const { credentials, modelRuntime } = await setup({ + apiKey: { + ...bothMethods.apiKey!, + login: async (interaction) => ({ + type: "api_key", + key: await interaction.prompt({ + type: "select", + message: "Choose credentials:", + options: [ + { id: "first", label: "First" }, + { id: "second", label: "Second", description: "Other credentials" }, + ], + }), + }), + }, + }); + const terminal = scriptedTerminal(["2"]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Select 1-2: "]); + expect(terminal.lines).toContain(" 2. Second - Other credentials"); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "second" }); + }); + + test("cancels a pending prompt without cancelling the login when the browser callback wins", async () => { + const promptController = new AbortController(); + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const pending = interaction.prompt({ + type: "manual_code", + message: "Paste the code", + placeholder: "redirect URL", + signal: promptController.signal, + }); + promptController.abort(); + await expect(pending).rejects.toBe(promptController.signal.reason); + expect(interaction.signal.aborted).toBe(false); + return { type: "oauth", access: "callback", refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal([]); + terminal.question = vi.fn( + (_message: string, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + ); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.question).toHaveBeenCalledWith("Paste the code (redirect URL): ", expect.any(AbortSignal)); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "callback" }); + }); + + test("reports a committed login separately from a failed login", async () => { + const { modelRuntime } = await setup(bothMethods); + vi.spyOn(modelRuntime, "login").mockRejectedValue( + new CredentialSynchronizationError( + PROVIDER_ID, + "login", + { type: "api_key", key: "saved" }, + { cause: new Error("snapshot failed") }, + ), + ); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors[0]).toContain("Logged in to Login Test, but local model state could not be synchronized:"); + }); + + test("--api-key saves an API key", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Enter Login Test API key: "]); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" }); + expect(terminal.lines.some((line) => line.startsWith("Saved API key for Login Test."))).toBe(true); + }); + + test("uses the API key login of providers without account sign-in", async () => { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Enter Login Test API key: "]); + }); + + test("--api-key fails for providers without API key login", async () => { + const { modelRuntime } = await setup({ oauth: bothMethods.oauth }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["Login Test has no API key login. Omit --api-key to sign in."]); + }); + + test("reports providers configured outside pi", async () => { + const { modelRuntime } = await setup({ apiKey: { name: "AWS profile", resolve: async () => undefined } }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["Login Test is configured outside pi (AWS profile)."]); + }); + + test("Ctrl+C during the catalog refresh skips it and keeps the login", async () => { + const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const controller = new AbortController(); + // A refresh that only ends when aborted, interrupted as soon as it starts. + vi.spyOn(modelRuntime, "refresh").mockImplementation( + ({ signal } = {}) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve({ aborted: true, errors: new Map() })); + controller.abort(); + }), + ); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(0); + expect(terminal.lines).toContain("Skipped the Login Test model catalog refresh; pi refreshes it on next start."); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" }); + }); + + test("reports cancellation", async () => { + const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const controller = new AbortController(); + controller.abort(); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(1); + expect(terminal.errors).toEqual(["Login cancelled."]); + expect(await credentials.read(PROVIDER_ID)).toBeUndefined(); + }); + + test.each(["radius-mcp", "radius_mcp"])("keeps unrelated MCP servers when %s is occupied", async (fallback) => { + const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-")); + try { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius"); + const mcpPath = join(agentDir, "mcp.json"); + const servers = { + radius: { command: "original-one" }, + [fallback]: { command: "original-two" }, + "radius-mcp-2": { command: "original-three" }, + }; + writeFileSync(mcpPath, JSON.stringify({ mcpServers: servers })); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, terminal, undefined, agentDir)).toBe(0); + expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers).toEqual({ + ...servers, + "radius-mcp-3": { url: "https://radius.pi.dev/mcp", auth: { provider: "radius" } }, + }); + expect(terminal.lines).toContain(`Added MCP server "radius-mcp-3" in ${mcpPath} to use the Login Test login.`); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + + test("configures the Radius MCP server without asking, once", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-")); + try { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius"); + const mcpPath = join(agentDir, "mcp.json"); + const first = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, first, undefined, agentDir)).toBe(0); + expect(first.questions).toEqual(["Enter Login Test API key: "]); + expect(first.lines).toContain(`Added MCP server "radius" in ${mcpPath} to use the Login Test login.`); + expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers.radius).toMatchObject({ + auth: { provider: "radius" }, + }); + + const second = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, second, undefined, agentDir)).toBe(0); + expect(second.lines.some((line) => line.includes("MCP server"))).toBe(false); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/auth-logout.test.ts b/packages/coding-agent/test/auth-logout.test.ts new file mode 100644 index 000000000..5f050e07e --- /dev/null +++ b/packages/coding-agent/test/auth-logout.test.ts @@ -0,0 +1,99 @@ +import { type Credential, InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { describe, expect, test, vi } from "vitest"; +import { runAuthLogout } from "../src/cli/auth-logout.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts"; + +async function setup(data: Record) { + const credentials = AuthStorage.inMemory(data); + const modelRuntime = await ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + }); + const lines: string[] = []; + const errors: string[] = []; + const run = (args: string[]) => + runAuthLogout(args, { + modelRuntime, + terminal: { write: (line) => lines.push(line), error: (line) => errors.push(line) }, + signal: new AbortController().signal, + }); + return { credentials, modelRuntime, lines, errors, run }; +} + +const oauthCredential = { + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 3_600_000, +} as const; + +describe("pi auth logout", () => { + test("requires exactly one provider", async () => { + const { errors, run } = await setup({}); + for (const args of [[], ["--api-key"], ["radius", "extra"]]) { + errors.length = 0; + expect(await run(args)).toBe(1); + expect(errors).toEqual(["Usage: pi auth logout "]); + } + }); + + test("removes a stored account login", async () => { + const { credentials, lines, run } = await setup({ radius: oauthCredential }); + expect(await run(["radius"])).toBe(0); + expect(lines).toEqual(["Logged out of Radius."]); + expect(await credentials.read("radius")).toBeUndefined(); + }); + + test("removes a stored API key by provider name and keeps other credentials", async () => { + const { credentials, lines, run } = await setup({ + openai: { type: "api_key", key: "sk-test" }, + radius: oauthCredential, + }); + expect(await run(["OpenAI"])).toBe(0); + expect(lines).toEqual([ + "Removed stored API key for OpenAI. Environment variables and models.json config are unchanged.", + ]); + expect(await credentials.read("openai")).toBeUndefined(); + expect(await credentials.read("radius")).toMatchObject({ type: "oauth" }); + }); + + test("succeeds when nothing is stored, so it can be repeated", async () => { + const { lines, run } = await setup({}); + expect(await run(["radius"])).toBe(0); + expect(lines).toEqual([ + "No stored credentials for Radius. Environment variables and models.json config are unchanged.", + ]); + }); + + test("removes credentials of providers that are not loaded, matched by id", async () => { + const { credentials, lines, run } = await setup({ "extension-provider": oauthCredential }); + expect(await run(["extension-provider"])).toBe(0); + expect(lines).toEqual(["Logged out of extension-provider."]); + expect(await credentials.read("extension-provider")).toBeUndefined(); + }); + + test("reports credential removal failures", async () => { + const { modelRuntime, errors, run } = await setup({ radius: oauthCredential }); + vi.spyOn(modelRuntime, "logout").mockRejectedValue(new Error("storage failed")); + expect(await run(["radius"])).toBe(1); + expect(errors).toEqual(["Logout failed: storage failed"]); + }); + + test("reports committed credential removal separately from a failed logout", async () => { + const { modelRuntime, errors, run } = await setup({ radius: oauthCredential }); + vi.spyOn(modelRuntime, "logout").mockRejectedValue( + new CredentialSynchronizationError("radius", "logout", undefined, { cause: new Error("snapshot failed") }), + ); + expect(await run(["radius"])).toBe(1); + expect(errors[0]).toContain("Credentials removed for Radius, but local model state could not be synchronized:"); + }); + + test("rejects an unknown provider and lists the stored credentials", async () => { + const { errors, run } = await setup({ radius: oauthCredential }); + expect(await run(["nope"])).toBe(1); + expect(errors).toEqual(['Unknown provider "nope".', "Stored credentials: radius"]); + }); +}); diff --git a/packages/coding-agent/test/auth-terminal.test.ts b/packages/coding-agent/test/auth-terminal.test.ts new file mode 100644 index 000000000..b433234d7 --- /dev/null +++ b/packages/coding-agent/test/auth-terminal.test.ts @@ -0,0 +1,63 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createProcessLoginTerminal } from "../src/cli/auth-terminal.ts"; + +const inputs: PassThrough[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const input of inputs) input.destroy(); + inputs.length = 0; +}); + +function setup() { + const input = new PassThrough(); + inputs.push(input); + // Readline only needs the readable stream; the test does not exercise TTY detection or raw mode. + vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin); + vi.spyOn(process.stdout, "write").mockReturnValue(true); + const controller = new AbortController(); + const interrupt = vi.fn(() => controller.abort()); + const terminal = createProcessLoginTerminal(interrupt); + return { input, controller, interrupt, terminal }; +} + +describe("auth terminal questions", () => { + test("rejects and cancels the login when stdin closes before an answer", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.end(); + await rejected; + expect(interrupt).toHaveBeenCalledOnce(); + expect(controller.signal.aborted).toBe(true); + }); + + test("normal cleanup does not cancel the login after an answer", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + input.write("example.com\n"); + await expect(answer).resolves.toBe("example.com"); + expect(interrupt).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + }); + + test("keeps an answer delivered immediately before EOF", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + input.end("example.com\n"); + await expect(answer).resolves.toBe("example.com"); + expect(interrupt).not.toHaveBeenCalled(); + }); + + test("prompt cancellation does not cancel the whole login", async () => { + const { controller, interrupt, terminal } = setup(); + const prompt = new AbortController(); + const answer = terminal.question("Paste code: ", AbortSignal.any([controller.signal, prompt.signal])); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + prompt.abort(); + await rejected; + expect(interrupt).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/mcp-command.test.ts b/packages/coding-agent/test/mcp-command.test.ts index 5f09b37eb..c3d55471f 100644 --- a/packages/coding-agent/test/mcp-command.test.ts +++ b/packages/coding-agent/test/mcp-command.test.ts @@ -110,7 +110,7 @@ describe("pi mcp", () => { for (const command of ["login", "logout"]) { expect(await run([command, "radius"], radius)).toMatchObject({ exitCode: 1, - output: `MCP server "radius" uses the credentials of provider "radius". Run /${command} radius in pi.`, + output: `MCP server "radius" uses the credentials of provider "radius". Run "pi auth ${command} radius".`, }); } } finally { diff --git a/packages/coding-agent/test/provider-matching.test.ts b/packages/coding-agent/test/provider-matching.test.ts new file mode 100644 index 000000000..5ab3b1b15 --- /dev/null +++ b/packages/coding-agent/test/provider-matching.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { findMatchingProviders } from "../src/core/provider-matching.ts"; + +const providers = [ + { id: "first", name: "Shared Name", authType: "oauth" }, + { id: "first", name: "Shared Name", authType: "api_key" }, + { id: "other", name: "FIRST", authType: "api_key" }, + { id: "second", name: "Second Provider", authType: "oauth" }, +]; + +describe("provider reference matching", () => { + test("matches IDs and display names case-insensitively with surrounding whitespace", () => { + expect(findMatchingProviders(providers, " sEcOnD ")).toEqual([providers[3]]); + expect(findMatchingProviders(providers, "\tSECOND PROVIDER\n")).toEqual([providers[3]]); + }); + + test("keeps all matching providers and auth methods in input order", () => { + const matched = findMatchingProviders(providers, "first"); + expect(matched).toEqual(providers.slice(0, 3)); + expect(matched[0]).toBe(providers[0]); + expect(matched[1]?.authType).toBe("api_key"); + expect(findMatchingProviders(providers, "shared name")).toEqual(providers.slice(0, 2)); + }); + + test("does not fuzzy-match unknown or partial references", () => { + expect(findMatchingProviders(providers, "unknown")).toEqual([]); + expect(findMatchingProviders(providers, "sec")).toEqual([]); + expect(findMatchingProviders([], "first")).toEqual([]); + }); + + test("never matches an empty reference, even against empty display names", () => { + expect(findMatchingProviders([{ id: "unnamed", name: "" }], "")).toEqual([]); + expect(findMatchingProviders(providers, " \t ")).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/terminal-question.test.ts b/packages/coding-agent/test/terminal-question.test.ts new file mode 100644 index 000000000..67dc26e5f --- /dev/null +++ b/packages/coding-agent/test/terminal-question.test.ts @@ -0,0 +1,79 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { questionTerminal } from "../src/utils/terminal-question.ts"; + +const streams: PassThrough[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const stream of streams) stream.destroy(); + streams.length = 0; +}); + +function setup() { + const input = new PassThrough(); + const output = new PassThrough(); + streams.push(input, output); + // Readline only needs the stream; real terminal capabilities are added by the Ctrl+C test below. + vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin); + const controller = new AbortController(); + const onInterrupt = vi.fn(); + return { input, output, controller, onInterrupt }; +} + +describe("terminal questions", () => { + test("writes to the selected output and cleans up after an answer", async () => { + const { input, output, controller, onInterrupt } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + expect(output.read()?.toString()).toBe("Paste URL: "); + input.write("https://example.test/callback\n"); + await expect(answer).resolves.toBe("https://example.test/callback"); + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + expect(input.isPaused()).toBe(true); + }); + + test("rejects on EOF without requiring an interrupt callback", async () => { + const { input, output, controller } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.end(); + await rejected; + expect(input.listenerCount("data")).toBe(0); + }); + + test("Ctrl+C rejects the question and reports an interrupt", async () => { + const { input, output, controller, onInterrupt } = setup(); + const setRawMode = vi.fn(); + Object.assign(input, { setRawMode }); + Object.assign(output, { isTTY: true }); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.write("\u0003"); + await rejected; + expect(onInterrupt).toHaveBeenCalledOnce(); + expect(input.listenerCount("keypress")).toBe(0); + expect(output.listenerCount("resize")).toBe(0); + expect(setRawMode).toHaveBeenLastCalledWith(false); + }); + + test("caller cancellation does not report an interrupt", async () => { + const { input, output, controller, onInterrupt } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + controller.abort(); + await rejected; + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + }); + + test("rejects an already-aborted prompt and cleans up", async () => { + const { input, output, controller, onInterrupt } = setup(); + controller.abort(); + await expect( + questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + }); +});