From a261366bde90c24826eb77bfc600f1bb62ad36e2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 19:43:52 +0200 Subject: [PATCH] feat(coding-agent): add auth preflight closes #7152 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/cli/args.ts | 6 +- packages/coding-agent/src/cli/auth-check.ts | 73 ++++++++ packages/coding-agent/src/cli/auth-command.ts | 125 +++++++++++++ .../coding-agent/src/cli/credential-print.ts | 142 ++++----------- .../coding-agent/src/core/auth-storage.ts | 91 +++++++++- .../coding-agent/src/core/model-runtime.ts | 6 +- packages/coding-agent/src/main.ts | 115 ++++++++---- packages/coding-agent/test/auth-check.test.ts | 171 ++++++++++++++++++ .../test/credential-print.test.ts | 60 ++++-- 10 files changed, 631 insertions(+), 162 deletions(-) create mode 100644 packages/coding-agent/src/cli/auth-check.ts create mode 100644 packages/coding-agent/src/cli/auth-command.ts create mode 100644 packages/coding-agent/test/auth-check.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2c65f76a6..9bbad2e7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). + ## [0.84.0] - 2026-08-06 ### New Features diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b6adf435f..5ea1ee8fb 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -248,7 +248,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 for external clients + ${APP_NAME} auth Print credentials or check provider readiness ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth ${chalk.bold("Options:")} @@ -299,10 +299,10 @@ Extensions can register additional flags (e.g., --plan from plan-mode extension) ${chalk.bold("Examples:")} # Print a provider API key for an external client - ${APP_NAME} auth print-api-key --provider openai --model gpt-5.5 + ${APP_NAME} auth print-api-key --provider openai # Print an OAuth bearer token for an external client (refreshes if expired) - ${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5 + ${APP_NAME} auth print-bearer-token --provider openai-codex # Interactive mode ${APP_NAME} diff --git a/packages/coding-agent/src/cli/auth-check.ts b/packages/coding-agent/src/cli/auth-check.ts new file mode 100644 index 000000000..9266ddb7f --- /dev/null +++ b/packages/coding-agent/src/cli/auth-check.ts @@ -0,0 +1,73 @@ +import type { CredentialStore } from "@earendil-works/pi-ai"; +import { resolveCliModel } from "../core/model-resolver.ts"; +import { ModelRuntime } from "../core/model-runtime.ts"; +import { InMemoryCodingAgentModelsStore } from "../core/models-store.ts"; +import type { Args } from "./args.ts"; +import { AuthCommandError, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; + +export type AuthCheckStatus = "ready" | "not_ready" | "invalid"; +export type AuthCheckReason = + | "provider_not_found" + | "credentials_not_configured" + | "credential_not_available" + | "invalid_state"; + +export interface AuthCheckResult { + status: AuthCheckStatus; + provider: string; + reason?: AuthCheckReason; + authType?: "api_key" | "oauth"; +} + +export async function checkProviderAuth( + args: Args, + modelRuntime: ModelRuntime, + options: { refresh: boolean } = { refresh: false }, +): Promise { + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, "check"); + let provider = cliProvider; + if (cliModel) { + const resolved = resolveCliModel({ cliProvider, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? `Unable to resolve model "${cliModel}"`); + } + provider = resolved.model.provider; + } + if (!provider) throw new AuthCommandError("Unable to resolve an auth provider"); + if (modelRuntime.getError()) { + return { status: "invalid", provider, reason: "invalid_state" }; + } + if (!modelRuntime.getProvider(provider)) { + return { status: "not_ready", provider, reason: "provider_not_found" }; + } + try { + const auth = await modelRuntime.checkAuth(provider); + if (!auth) return { status: "not_ready", provider, reason: "credentials_not_configured" }; + if (options.refresh && !(await modelRuntime.getAuth(provider))) { + return { status: "not_ready", provider, reason: "credentials_not_configured" }; + } + return { status: "ready", provider, authType: auth.type }; + } catch { + return { status: "invalid", provider, reason: "invalid_state" }; + } +} + +export async function getProviderCredential( + providerId: string, + modelRuntime: ModelRuntime, + credentials: CredentialStore, + options: { refresh: boolean }, +): Promise { + const credential = await credentials.read(providerId); + if (!options.refresh && credential?.type === "oauth") return credential.access; + return getAuthCredential(await modelRuntime.getAuth(providerId)); +} + +export async function createAuthCheckModelRuntime(credentials: CredentialStore): Promise { + return ModelRuntime.create({ + credentials, + modelsStore: new InMemoryCodingAgentModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts new file mode 100644 index 000000000..a0e58d142 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -0,0 +1,125 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import type { Args } from "./args.ts"; + +export type AuthCommandKind = "check" | "api_key" | "bearer_token"; + +export interface AuthCommand { + kind: AuthCommandKind; + args: string[]; + json: boolean; + credentials: boolean; + noRefresh: boolean; + minExpiryMs?: number; +} + +export class AuthCommandError extends Error {} + +const AUTH_COMMAND_USAGE: Record = { + check: "pi auth check --provider [--json] [--credentials] [--no-refresh]", + api_key: "pi auth print-api-key --provider [--model ]", + bearer_token: "pi auth print-bearer-token --provider [--model ] [--min-expiry ]", +}; + +export function getAuthCommandName(kind: AuthCommandKind): string { + return kind === "check" ? "auth check" : kind === "api_key" ? "auth print-api-key" : "auth print-bearer-token"; +} + +export function getAuthCommandUsage(kind: AuthCommandKind): string { + return AUTH_COMMAND_USAGE[kind]; +} + +export function isAuthCommandHelp(args: string[]): boolean { + return ( + args[0] === "auth" && + (args[1] === undefined || args[1] === "help" || args.includes("--help") || args.includes("-h")) + ); +} + +export function printAuthCommandHelp(): void { + console.log(`Usage: + 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.`); +} + +export function parseAuthCommand(args: string[]): AuthCommand | undefined { + if (args[0] !== "auth") return undefined; + + const kind = + args[1] === "check" + ? "check" + : args[1] === "print-api-key" + ? "api_key" + : args[1] === "print-bearer-token" + ? "bearer_token" + : undefined; + if (!kind) { + throw new AuthCommandError( + `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key", "pi auth print-bearer-token", or "pi auth check".`, + ); + } + + const commandArgs: string[] = []; + let json = false; + let credentials = false; + let noRefresh = false; + let minExpiryMs: number | undefined; + for (let index = 2; index < args.length; index++) { + const arg = args[index]; + if (arg === "--min-expiry") { + if (kind !== "bearer_token") + throw new AuthCommandError("--min-expiry is only supported by print-bearer-token"); + const value = args[++index]; + const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; + if (!match) throw new AuthCommandError("--min-expiry must use a duration such as 30m or 1h"); + const amount = Number(match[1]); + const unit = match[2]; + minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); + continue; + } + if (arg === "--json" || arg === "--credentials" || arg === "--no-refresh") { + if (kind !== "check") throw new AuthCommandError(`${arg} is only supported by auth check`); + if (arg === "--json") json = true; + else if (arg === "--credentials") credentials = true; + else noRefresh = true; + continue; + } + commandArgs.push(arg); + } + + return minExpiryMs === undefined + ? { kind, args: commandArgs, json, credentials, noRefresh } + : { kind, args: commandArgs, json, credentials, noRefresh, minExpiryMs }; +} + +export function validateAuthCommandArgs(args: Args, kind: AuthCommandKind): { provider?: string; model?: string } { + const provider = args.provider?.trim() || undefined; + const model = args.model?.trim() || undefined; + if (args.unknownFlags.size > 0) { + const option = args.unknownFlags.keys().next().value; + throw new AuthCommandError(`Unknown option --${option} for "${getAuthCommandName(kind)}".`); + } + if (args.apiKey !== undefined || args.messages.length > 0 || args.fileArgs.length > 0) { + throw new AuthCommandError("Auth commands only accept --provider and --model"); + } + if (kind === "check") { + if (!provider && !model) { + throw new AuthCommandError("Auth checks require --provider or --model "); + } + return { provider, model }; + } + if (!provider && !model) { + throw new AuthCommandError("Credential printing requires --provider or --model "); + } + return { provider, model }; +} + +export function getAuthCredential(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; +} diff --git a/packages/coding-agent/src/cli/credential-print.ts b/packages/coding-agent/src/cli/credential-print.ts index 8f069781d..304876659 100644 --- a/packages/coding-agent/src/cli/credential-print.ts +++ b/packages/coding-agent/src/cli/credential-print.ts @@ -2,81 +2,14 @@ import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai"; import { resolveCliModel } from "../core/model-resolver.ts"; import type { ModelRuntime } from "../core/model-runtime.ts"; import type { Args } from "./args.ts"; - -export type CredentialPrintKind = "api_key" | "bearer_token"; +import { AuthCommandError, type AuthCommandKind, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000; -export interface CredentialPrintCommand { - kind: CredentialPrintKind; - args: string[]; - minExpiryMs?: number; -} - -export class CredentialPrintError extends Error {} - -export function isCredentialPrintHelp(args: string[]): boolean { - return ( - args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h") - ); -} - -export function printCredentialPrintHelp(): void { - console.log(`Usage: - pi auth print-api-key --model [--provider ] - pi auth print-bearer-token --model [--provider ] [--min-expiry ] - -Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`); -} - -/** Parse the small, extensible `pi auth` command surface before normal startup. */ -export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined { - if (args[0] !== "auth") return undefined; - - const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined; - if (!kind) { - throw new CredentialPrintError( - `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`, - ); - } - - const commandArgs: string[] = []; - let minExpiryMs: number | undefined; - for (let index = 2; index < args.length; index++) { - if (args[index] !== "--min-expiry") { - commandArgs.push(args[index]); - continue; - } - if (kind !== "bearer_token") { - throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token"); - } - const value = args[++index]; - const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; - if (!match) { - throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h"); - } - const amount = Number(match[1]); - const unit = match[2]; - minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); - } - - return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs }; -} - -export function validateCredentialPrintArgs(args: Args): void { - if (!args.model?.trim()) { - throw new CredentialPrintError("Credential printing requires --model "); - } - if (args.apiKey !== undefined) { - throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported"); - } - if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) { - throw new CredentialPrintError("Credential printing only accepts --provider and --model"); - } -} +type CredentialPrintKind = Exclude; /** - * Resolve one request credential for a specific provider/model pair. + * Resolve one configured provider credential. * * This intentionally calls ModelRuntime.getAuth(), which refreshes and persists * OAuth credentials with less than five minutes remaining through the normal request-auth path. @@ -88,64 +21,67 @@ export async function resolveCredentialForPrint( minExpiryMs?: number, signal?: AbortSignal, ): Promise { - validateCredentialPrintArgs(args); - + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, kind); const credentialTypes = new Map( (await modelRuntime.listCredentials({ signal })).map((credential) => [credential.providerId, credential.type]), ); - const models: Model[] = []; - if (args.provider) { - const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime }); - if (resolved.error || !resolved.model) { - throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model"); + const providers: Array<{ id: string; model?: Model }> = []; + if (cliProvider) { + const provider = modelRuntime.getProvider(cliProvider); + if (!provider) { + throw new AuthCommandError(`Unknown provider "${cliProvider}". Use --list-models to see available providers.`); + } + if (cliModel) { + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? "Unable to resolve the requested provider/model"); + } + providers.push({ id: provider.id, model: resolved.model }); + } else { + providers.push({ id: provider.id }); } - models.push(resolved.model); } else { for (const provider of modelRuntime.getProviders()) { if (!credentialTypes.has(provider.id)) continue; - const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime }); + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: cliModel!, modelRuntime }); if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) { - models.push(resolved.model); + providers.push({ id: provider.id, model: resolved.model }); } } - if (models.length === 0) { - throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`); + if (providers.length === 0) { + throw new AuthCommandError(`Model "${cliModel}" not found. Use --list-models to see available models.`); } } const credentials: Array<{ providerId: string; value: string }> = []; - for (const model of models) { - const type = credentialTypes.get(model.provider); + for (const provider of providers) { + const type = credentialTypes.get(provider.id); if (kind === "api_key" && type === "oauth") continue; if (kind === "bearer_token" && type !== "oauth") continue; - - const auth = await modelRuntime.getAuth(model, { + const authOptions = { ...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}), signal, - }); - const authorization = Object.entries(auth?.auth.headers ?? {}).find( - ([name]) => name.toLowerCase() === "authorization", - )?.[1]; - const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; - const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey; - if (value) credentials.push({ providerId: model.provider, value }); + }; + const auth = provider.model + ? await modelRuntime.getAuth(provider.model, authOptions) + : await modelRuntime.getAuth(provider.id, authOptions); + const value = getAuthCredential(auth); + if (value) credentials.push({ providerId: provider.id, value }); } if (credentials.length === 1) return credentials[0].value; if (credentials.length === 0) { - const providerId = models[0]?.provider; + const providerId = providers[0]?.id; const type = providerId ? credentialTypes.get(providerId) : undefined; - if (args.provider && kind === "api_key" && type === "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`); + if (cliProvider && kind === "api_key" && type === "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is configured with OAuth, not an API key`); } - if (args.provider && kind === "bearer_token" && type !== "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`); + if (cliProvider && kind === "bearer_token" && type !== "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is not configured with an OAuth bearer token`); } - throw new CredentialPrintError( - `No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`, - ); + throw new AuthCommandError(`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`); } - throw new CredentialPrintError( - `Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, + throw new AuthCommandError( + `Multiple configured providers matched (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, ); } diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 2f792f0df..602e03a80 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -11,7 +11,7 @@ import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; -import { resolveConfigValue } from "./resolve-config-value.ts"; +import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; @@ -201,6 +201,95 @@ export class FileAuthStorageBackend implements AuthStorageBackend { } } +export class ReadOnlyAuthStorage implements CredentialStore { + private readonly authPath: string; + private data: AuthStorageData | undefined; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = normalizePath(authPath); + } + + private load(): AuthStorageData { + if (this.data) return this.data; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(this.authPath, "utf-8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.data = {}; + return this.data; + } + throw new Error(`Failed to read auth.json: ${error instanceof Error ? error.message : String(error)}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Invalid auth.json: expected an object"); + } + for (const [providerId, credential] of Object.entries(parsed)) { + if (typeof credential !== "object" || credential === null || Array.isArray(credential)) { + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + const value = credential as Record; + if (value.type === "api_key") { + const validKey = value.key === undefined || typeof value.key === "string"; + const validEnv = + value.env === undefined || + (typeof value.env === "object" && + value.env !== null && + !Array.isArray(value.env) && + Object.values(value.env).every((entry) => typeof entry === "string")); + if (validKey && validEnv) continue; + } else if ( + value.type === "oauth" && + typeof value.access === "string" && + typeof value.refresh === "string" && + typeof value.expires === "number" && + Number.isFinite(value.expires) + ) { + continue; + } + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + + this.data = parsed as AuthStorageData; + return this.data; + } + + async read(providerId: string, options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credential = this.load()[providerId]; + options?.signal?.throwIfAborted(); + if (!credential) return undefined; + if (credential.type !== "api_key" || !credential.key || isCommandConfigValue(credential.key)) { + return structuredClone(credential); + } + return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; + } + + async list(options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credentials = Object.entries(this.load()).map(([providerId, credential]) => ({ + providerId, + type: credential.type, + })); + options?.signal?.throwIfAborted(); + return credentials; + } + + async modify( + _providerId: string, + _fn: (current: Credential | undefined) => Promise, + _options?: AuthOperationOptions, + ): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } + + async delete(_providerId: string, _options?: AuthOperationOptions): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } +} + export class InMemoryAuthStorageBackend implements AuthStorageBackend { private value: string | undefined; private asyncChain: Promise = Promise.resolve(); diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 0ecd88de3..6f4071b60 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -77,6 +77,8 @@ export interface CreateModelRuntimeOptions { catalogBaseUrl?: string; /** Optional caller cancellation for initial cache restoration and availability checks. */ signal?: AbortSignal; + /** Skip initial catalog and availability refresh. Static models remain available. */ + refreshOnCreate?: boolean; } export interface ModelRuntimeAuthOverrides extends AuthOperationOptions { @@ -205,7 +207,9 @@ export class ModelRuntime implements Models { : controller.signal : options.signal; try { - await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + if (options.refreshOnCreate !== false) { + await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + } } finally { if (timeout) clearTimeout(timeout); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a100c7c7f..8db96a670 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,21 +10,29 @@ import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { - type CredentialPrintCommand, - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - printCredentialPrintHelp, - resolveCredentialForPrint, - validateCredentialPrintArgs, -} from "./cli/credential-print.ts"; + type AuthCheckResult, + checkProviderAuth, + createAuthCheckModelRuntime, + getProviderCredential, +} from "./cli/auth-check.ts"; +import { + type AuthCommand, + AuthCommandError, + getAuthCommandName, + getAuthCommandUsage, + isAuthCommandHelp, + parseAuthCommand, + printAuthCommandHelp, + validateAuthCommandArgs, +} from "./cli/auth-command.ts"; +import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; -import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; +import { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { type AgentSessionRuntimeDiagnostic, @@ -32,6 +40,7 @@ import { createAgentSessionServices, } from "./core/agent-session-services.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; @@ -127,17 +136,17 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } -async function runCredentialPrintCommand(args: string[]): Promise { - if (isCredentialPrintHelp(args)) { - printCredentialPrintHelp(); +async function runAuthCommand(args: string[]): Promise { + if (isAuthCommandHelp(args)) { + printAuthCommandHelp(); return true; } - let command: CredentialPrintCommand | undefined; + let command: AuthCommand | undefined; try { - command = parseCredentialPrintCommand(args); + command = parseAuthCommand(args); } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command"; + const message = error instanceof AuthCommandError ? error.message : "Failed to parse auth command"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; @@ -145,30 +154,62 @@ async function runCredentialPrintCommand(args: string[]): Promise { if (!command) return false; const parsed = parseArgs(command.args); - if (parsed.diagnostics.length > 0) { - for (const diagnostic of parsed.diagnostics) { - console.error(chalk.red(`Error: ${diagnostic.message}`)); - } + if (parsed.unknownFlags.size > 0) { + const option = parsed.unknownFlags.keys().next().value; + console.error(chalk.red(`Unknown option --${option} for "${getAuthCommandName(command.kind)}".`)); + console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getAuthCommandUsage(command.kind)}".`)); process.exitCode = 1; return true; } - try { - validateCredentialPrintArgs(parsed); - const signal = AbortSignal.timeout(15_000); - const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); - const credential = await resolveCredentialForPrint( - parsed, - modelRuntime, - command.kind, - command.minExpiryMs, - signal, - ); - process.stdout.write(`${credential}\n`); + if (parsed.diagnostics.length > 0) { + throw new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + } + if (command.kind !== "check") { + const signal = AbortSignal.timeout(15_000); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); + const credential = await resolveCredentialForPrint( + parsed, + modelRuntime, + command.kind, + command.minExpiryMs, + signal, + ); + process.stdout.write(`${credential}\n`); + return true; + } + + const requestedAuth = validateAuthCommandArgs(parsed, command.kind); + let result: AuthCheckResult; + let credential: string | undefined; + try { + const credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create(); + const modelRuntime = await createAuthCheckModelRuntime(credentials); + result = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh }); + if (command.credentials && result.status === "ready") { + credential = await getProviderCredential(result.provider, modelRuntime, credentials, { + refresh: !command.noRefresh, + }); + if (!credential) { + result = { status: "not_ready", provider: result.provider, reason: "credential_not_available" }; + } + } + } catch { + result = { + status: "invalid", + provider: requestedAuth.provider ?? requestedAuth.model!, + reason: "invalid_state", + }; + } + const output = command.json + ? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) }) + : (credential ?? result.status); + process.stdout.write(`${output}\n`); + process.exitCode = result.status === "ready" ? 0 : result.status === "not_ready" ? 1 : 2; } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential"; + const message = error instanceof AuthCommandError ? error.message : "Failed to resolve credential"; console.error(chalk.red(`Error: ${message}`)); - process.exitCode = 1; + process.exitCode = command.kind === "check" ? 2 : 1; } return true; } @@ -534,6 +575,10 @@ export async function main(args: string[], options?: MainOptions) { process.env.PI_SKIP_VERSION_CHECK = "1"; } + if (await runAuthCommand(args)) { + return; + } + if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } @@ -561,10 +606,6 @@ export async function main(args: string[], options?: MainOptions) { return; } - if (await runCredentialPrintCommand(args)) { - return; - } - const parsed = parseArgs(args); if (parsed.diagnostics.length > 0) { for (const d of parsed.diagnostics) { diff --git a/packages/coding-agent/test/auth-check.test.ts b/packages/coding-agent/test/auth-check.test.ts new file mode 100644 index 000000000..5f1ce6327 --- /dev/null +++ b/packages/coding-agent/test/auth-check.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { parseArgs } from "../src/cli/args.ts"; +import { checkProviderAuth, createAuthCheckModelRuntime, getProviderCredential } from "../src/cli/auth-check.ts"; +import { parseAuthCommand } from "../src/cli/auth-command.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; + +const tempDir = join(tmpdir(), `pi-test-auth-check-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +async function createRuntime(credentials: AuthStorage | ReadOnlyAuthStorage): Promise { + return ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} + +describe("auth check command", () => { + beforeEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + }); + + test("reports a configured provider as ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + }); + + test("resolves the provider from --model", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--model", "openai/gpt-5.5"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + await expect( + checkProviderAuth(parseArgs(["--provider", "openai", "--model", "gpt-5.5"]), runtime), + ).resolves.toMatchObject({ status: "ready", provider: "openai" }); + }); + + test("reads credentials without refreshing OAuth when requested", async () => { + const apiCredentials = AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }); + const apiRuntime = await createRuntime(apiCredentials); + await expect(getProviderCredential("openai", apiRuntime, apiCredentials, { refresh: false })).resolves.toBe( + "test-key", + ); + + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const oauthRuntime = await createRuntime(credentials); + const oauth = oauthRuntime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(oauth.refresh); + oauth.refresh = refresh; + + await expect(getProviderCredential("openai-codex", oauthRuntime, credentials, { refresh: false })).resolves.toBe( + "old-token", + ); + expect(refresh).not.toHaveBeenCalled(); + }); + + test("refreshes OAuth by default", async () => { + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const runtime = await createRuntime(credentials); + const oauth = runtime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(async () => ({ + type: "oauth" as const, + access: "fresh-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60 * 1000, + })); + oauth.refresh = refresh; + + await expect( + checkProviderAuth(parseArgs(["--provider", "openai-codex"]), runtime, { refresh: true }), + ).resolves.toMatchObject({ + status: "ready", + }); + expect(refresh).toHaveBeenCalledOnce(); + }); + + test("reports an unknown provider as not ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory()); + + await expect(checkProviderAuth(parseArgs(["--provider", "not-installed"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "not-installed", + reason: "provider_not_found", + }); + }); + + test("does not treat an unresolved stored environment reference as configured", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "openai", + reason: "credentials_not_configured", + }); + }); + + test("reports malformed auth state as invalid", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, "{invalid-json", "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "invalid", + provider: "openai", + reason: "invalid_state", + }); + }); + + test("does not create an auth file or its parent directory", async () => { + const authPath = join(tempDir, "agent", "auth.json"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toMatchObject({ + status: "not_ready", + reason: "credentials_not_configured", + }); + expect(existsSync(authPath)).toBe(false); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + test("accepts optional JSON output, credential output, and --no-refresh", () => { + expect(parseAuthCommand(["auth", "check", "--provider", "openai"])).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, + }); + expect( + parseAuthCommand(["auth", "check", "--json", "--credentials", "--no-refresh", "--provider", "openai"]), + ).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: true, + credentials: true, + noRefresh: true, + }); + }); + + test("creates an auth-check runtime without catalog storage", async () => { + const runtime = await createAuthCheckModelRuntime(AuthStorage.inMemory()); + expect(runtime.getProvider("openai")).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts index be17a1b3e..a1ca56d9c 100644 --- a/packages/coding-agent/test/credential-print.test.ts +++ b/packages/coding-agent/test/credential-print.test.ts @@ -1,14 +1,11 @@ import { InMemoryModelsStore } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; import { parseArgs } from "../src/cli/args.ts"; -import { - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - resolveCredentialForPrint, -} from "../src/cli/credential-print.ts"; +import { AuthCommandError, isAuthCommandHelp, parseAuthCommand } from "../src/cli/auth-command.ts"; +import { resolveCredentialForPrint } from "../src/cli/credential-print.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; +import { main } from "../src/main.ts"; async function createRuntime(credentials: AuthStorage): Promise { return ModelRuntime.create({ @@ -22,7 +19,7 @@ async function createRuntime(credentials: AuthStorage): Promise { describe("credential print commands", () => { test("prints a resolved API key", async () => { const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } })); - const args = parseArgs(["--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai"]); await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key"); }); @@ -38,7 +35,7 @@ describe("credential print commands", () => { }, }), ); - const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]); + const args = parseArgs(["--provider", "kimi-coding"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token"); }); @@ -62,13 +59,31 @@ describe("credential print commands", () => { const oauth = runtime.getProvider("openai-codex")?.auth.oauth; if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); oauth.refresh = refresh; - const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai-codex"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token"); expect(refresh).toHaveBeenCalledOnce(); expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" }); }); + test("reports unknown auth options like package commands", async () => { + const originalExitCode = process.exitCode; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + process.exitCode = undefined; + await main(["auth", "check", "--provider", "openai-codex", "--credentails"]); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain('Unknown option --credentails for "auth check".'); + expect(stderr).toContain( + 'Use "pi --help" or "pi auth check --provider [--json] [--credentials] [--no-refresh]".', + ); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + } + }); + test("parses credential commands and rejects invalid arguments or credential types", async () => { const runtime = await createRuntime( AuthStorage.inMemory({ @@ -81,24 +96,35 @@ describe("credential print commands", () => { }), ); - expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ + expect(parseAuthCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ kind: "api_key", args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ + expect(parseAuthCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); + expect(parseAuthCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ kind: "bearer_token", args: [], + json: false, + credentials: false, + noRefresh: false, minExpiryMs: 30 * 60_000, }); - expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( + expect(() => parseAuthCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( "only supported by print-bearer-token", ); - expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true); - expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError); - await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model"); + expect(isAuthCommandHelp(["auth", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-api-key", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true); + expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true); + expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError); + await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow( + "requires --provider or --model ", + ); await expect( - resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"), + resolveCredentialForPrint(parseArgs(["--provider", "openai-codex"]), runtime, "api_key"), ).rejects.toThrow("configured with OAuth"); }); });