feat(coding-agent): add auth preflight

closes #7152
This commit is contained in:
Armin Ronacher 2026-08-06 19:43:52 +02:00
parent 58fc0431a7
commit a261366bde
10 changed files with 631 additions and 162 deletions

View file

@ -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

View file

@ -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 <command> Print credentials for external clients
${APP_NAME} auth <command> Print credentials or check provider readiness
${APP_NAME} <command> --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}

View file

@ -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<AuthCheckResult> {
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<string | undefined> {
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<ModelRuntime> {
return ModelRuntime.create({
credentials,
modelsStore: new InMemoryCodingAgentModelsStore(),
allowModelNetwork: false,
refreshOnCreate: false,
});
}

View file

@ -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<AuthCommandKind, string> = {
check: "pi auth check --provider <provider> [--json] [--credentials] [--no-refresh]",
api_key: "pi auth print-api-key --provider <provider> [--model <model>]",
bearer_token: "pi auth print-bearer-token --provider <provider> [--model <model>] [--min-expiry <duration>]",
};
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 <provider>] [--model <model>]
pi auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]
pi auth check [--provider <provider>] [--model <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 <provider> or --model <model>");
}
return { provider, model };
}
if (!provider && !model) {
throw new AuthCommandError("Credential printing requires --provider <provider> or --model <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;
}

View file

@ -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 <model> [--provider <provider>]
pi auth print-bearer-token --model <model> [--provider <provider>] [--min-expiry <duration>]
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 <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<AuthCommandKind, "check">;
/**
* 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<string> {
validateCredentialPrintArgs(args);
const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, kind);
const credentialTypes = new Map<string, CredentialInfo["type"]>(
(await modelRuntime.listCredentials({ signal })).map((credential) => [credential.providerId, credential.type]),
);
const models: Model<Api>[] = [];
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<Api> }> = [];
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.`,
);
}

View file

@ -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<string, Credential>;
@ -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<string, unknown>;
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<Credential | undefined> {
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<readonly CredentialInfo[]> {
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<Credential | undefined>,
_options?: AuthOperationOptions,
): Promise<Credential | undefined> {
throw new Error("Read-only credential storage cannot modify auth.json");
}
async delete(_providerId: string, _options?: AuthOperationOptions): Promise<void> {
throw new Error("Read-only credential storage cannot modify auth.json");
}
}
export class InMemoryAuthStorageBackend implements AuthStorageBackend {
private value: string | undefined;
private asyncChain: Promise<unknown> = Promise.resolve();

View file

@ -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);
}

View file

@ -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<boolean> {
if (isCredentialPrintHelp(args)) {
printCredentialPrintHelp();
async function runAuthCommand(args: string[]): Promise<boolean> {
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<boolean> {
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) {

View file

@ -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<ModelRuntime> {
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();
});
});

View file

@ -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<ModelRuntime> {
return ModelRuntime.create({
@ -22,7 +19,7 @@ async function createRuntime(credentials: AuthStorage): Promise<ModelRuntime> {
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 <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 <provider> or --model <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");
});
});