mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): add pi auth login and logout
pi auth login <provider> logs in from the shell without starting a session, like pi mcp login. Account sign-in is the default and takes the provider's browser method; when the provider has another method, it prints a hint to rerun with --no-browser. --no-browser skips the browser method and uses the other one, such as device code, asking only when there are several. --api-key saves an API key instead and cannot be combined with --no-browser. After logging in, it fetches the provider's model catalog, and for Radius it points the Radius MCP server in the global mcp.json at the login without asking. pi auth logout <provider> removes the stored credentials like /logout and succeeds when nothing is stored. For MCP servers that use provider credentials, pi mcp login and logout now refer to these commands instead of /login and /logout. /login shares the check for the Radius MCP server entry and the web URL helpers with the new command.
This commit is contained in:
parent
e5f3b41506
commit
9b9cb30ae1
21 changed files with 1392 additions and 68 deletions
|
|
@ -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))
|
- `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))
|
- 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.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 <provider>` 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 <provider>` removes the stored credentials, like `/logout`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ pi uninstall <source> [options]
|
||||||
pi update [target] [options]
|
pi update [target] [options]
|
||||||
pi list
|
pi list
|
||||||
pi config [options]
|
pi config [options]
|
||||||
|
pi auth login <provider> [--api-key] [--no-browser]
|
||||||
|
pi auth logout <provider>
|
||||||
pi auth <check|print-api-key|print-bearer-token> [options]
|
pi auth <check|print-api-key|print-bearer-token> [options]
|
||||||
pi mcp <add|remove|list|login|logout> [options]
|
pi mcp <add|remove|list|login|logout> [options]
|
||||||
```
|
```
|
||||||
|
|
@ -302,11 +304,22 @@ export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
|
||||||
|
|
||||||
## Credential commands
|
## Credential commands
|
||||||
|
|
||||||
|
`pi auth login <provider>` 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 <provider>`. 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 <provider>` 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
|
```sh
|
||||||
|
pi auth login radius
|
||||||
|
pi auth logout radius
|
||||||
pi auth check --provider openai --json
|
pi auth check --provider openai --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Authentication commands require `--provider <provider>` or `--model <model>`. See [Providers](providers.md) for supported methods.
|
The other authentication commands require `--provider <provider>` or `--model <model>`. See [Providers](providers.md) for supported methods.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,7 @@ ${chalk.bold("Commands:")}
|
||||||
${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs
|
${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs
|
||||||
${APP_NAME} list List installed extensions from settings
|
${APP_NAME} list List installed extensions from settings
|
||||||
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
||||||
${APP_NAME} auth <command> Print credentials or check provider readiness
|
${APP_NAME} auth <command> Log in or out, print credentials, or check provider readiness
|
||||||
${APP_NAME} mcp <command> Manage MCP servers
|
${APP_NAME} mcp <command> Manage MCP servers
|
||||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth/mcp
|
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth/mcp
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,35 @@ export function isAuthCommandHelp(args: string[]): boolean {
|
||||||
|
|
||||||
export function printAuthCommandHelp(): void {
|
export function printAuthCommandHelp(): void {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
|
pi auth login <provider> [--api-key] [--no-browser]
|
||||||
|
pi auth logout <provider>
|
||||||
pi auth print-api-key [--provider <provider>] [--model <model>]
|
pi auth print-api-key [--provider <provider>] [--model <model>]
|
||||||
pi auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]
|
pi auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]
|
||||||
pi auth check [--provider <provider>] [--model <model>] [--json] [--credentials] [--no-refresh]
|
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.`);
|
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 <provider> Sign in with an account in the browser, or save an API key, then exit
|
||||||
|
logout <provider> 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 <provider> Resolve credentials for a provider
|
||||||
|
--model <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 <duration> 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 {
|
export function parseAuthCommand(args: string[]): AuthCommand | undefined {
|
||||||
|
|
@ -58,7 +82,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined {
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
throw new AuthCommandError(
|
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".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
277
packages/coding-agent/src/cli/auth-login.ts
Normal file
277
packages/coding-agent/src/cli/auth-login.ts
Normal file
|
|
@ -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 <provider> [--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 <provider>` in the terminal and return the process exit code. */
|
||||||
|
export async function runAuthLogin(args: string[], options: AuthLoginOptions): Promise<number> {
|
||||||
|
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<AuthPrompt, { type: "select" }>, 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<string> {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
packages/coding-agent/src/cli/auth-logout.ts
Normal file
72
packages/coding-agent/src/cli/auth-logout.ts
Normal file
|
|
@ -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 <provider>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `pi auth logout <provider>`: 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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
78
packages/coding-agent/src/cli/auth-terminal.ts
Normal file
78
packages/coding-agent/src/cli/auth-terminal.ts
Normal file
|
|
@ -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<string>;
|
||||||
|
/** Read a line without echoing it. */
|
||||||
|
secret(message: string, signal: AbortSignal): Promise<string>;
|
||||||
|
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<string> {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
11
packages/coding-agent/src/core/provider-matching.ts
Normal file
11
packages/coding-agent/src/core/provider-matching.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** Match provider IDs and display names exactly, ignoring case and surrounding whitespace in the reference. */
|
||||||
|
export function findMatchingProviders<T extends { id: string; name: string }>(
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
44
packages/coding-agent/src/core/radius-mcp.ts
Normal file
44
packages/coding-agent/src/core/radius-mcp.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { APP_NAME, CONFIG_DIR_NAME } from "../../config.ts";
|
import { APP_NAME, CONFIG_DIR_NAME } from "../../config.ts";
|
||||||
import { validateMcpServerConfig } from "../../core/mcp-servers.ts";
|
import { validateMcpServerConfig } from "../../core/mcp-servers.ts";
|
||||||
import { ModelRuntime } from "../../core/model-runtime.ts";
|
import { ModelRuntime } from "../../core/model-runtime.ts";
|
||||||
import { ProjectTrustStore } from "../../core/trust-manager.ts";
|
import { ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||||
import { openBrowser } from "../../utils/open-browser.ts";
|
import { openBrowser } from "../../utils/open-browser.ts";
|
||||||
|
import { questionTerminal } from "../../utils/terminal-question.ts";
|
||||||
import {
|
import {
|
||||||
addMcpServerConfig,
|
addMcpServerConfig,
|
||||||
getMcpToolExposure,
|
getMcpToolExposure,
|
||||||
|
|
@ -285,7 +285,7 @@ export async function runMcpCommand(args: string[], options: McpCommandOptions):
|
||||||
const authProvider = getAuthProvider(entry);
|
const authProvider = getAuthProvider(entry);
|
||||||
if (authProvider) {
|
if (authProvider) {
|
||||||
error(
|
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;
|
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.
|
* 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<string | undefined> {
|
async function waitForRedirectUrl(signal: AbortSignal, interactive: boolean): Promise<string | undefined> {
|
||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
|
if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
||||||
try {
|
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,
|
signal,
|
||||||
|
output: process.stderr,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
|
||||||
readline.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ import {
|
||||||
printAuthCommandHelp,
|
printAuthCommandHelp,
|
||||||
validateAuthCommandArgs,
|
validateAuthCommandArgs,
|
||||||
} from "./cli/auth-command.ts";
|
} 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 { resolveCredentialForPrint } from "./cli/credential-print.ts";
|
||||||
import { processFileArguments } from "./cli/file-processor.ts";
|
import { processFileArguments } from "./cli/file-processor.ts";
|
||||||
import { buildInitialMessage } from "./cli/initial-message.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);
|
return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runAuthLoginCommand(args: string[]): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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<boolean> {
|
async function runAuthCommand(args: string[]): Promise<boolean> {
|
||||||
if (isAuthCommandHelp(args)) {
|
if (isAuthCommandHelp(args)) {
|
||||||
printAuthCommandHelp();
|
printAuthCommandHelp();
|
||||||
return true;
|
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;
|
let command: AuthCommand | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -13,30 +13,12 @@ import {
|
||||||
} from "@earendil-works/pi-tui";
|
} from "@earendil-works/pi-tui";
|
||||||
import { copyToClipboard } from "../../../utils/clipboard.ts";
|
import { copyToClipboard } from "../../../utils/clipboard.ts";
|
||||||
import { openBrowser } from "../../../utils/open-browser.ts";
|
import { openBrowser } from "../../../utils/open-browser.ts";
|
||||||
|
import { displayUrl, webUrl } from "../../../utils/web-url.ts";
|
||||||
import { theme } from "../theme/theme.ts";
|
import { theme } from "../theme/theme.ts";
|
||||||
import { AuthUrlComponent } from "./auth-url.ts";
|
import { AuthUrlComponent } from "./auth-url.ts";
|
||||||
import { DynamicBorder } from "./dynamic-border.ts";
|
import { DynamicBorder } from "./dynamic-border.ts";
|
||||||
import { keyHint } from "./keybinding-hints.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`. */
|
/** `text` as a terminal hyperlink to `url` when that is a web URL, else plain `text`. */
|
||||||
function webLink(text: string, url: string): string {
|
function webLink(text: string, url: string): string {
|
||||||
const target = webUrl(url);
|
const target = webUrl(url);
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ import type {
|
||||||
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
|
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
|
||||||
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
|
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
|
||||||
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.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 { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts";
|
||||||
import {
|
import {
|
||||||
defaultModelPerProvider,
|
defaultModelPerProvider,
|
||||||
|
|
@ -105,7 +104,9 @@ import {
|
||||||
} from "../../core/model-resolver.ts";
|
} from "../../core/model-resolver.ts";
|
||||||
import { CredentialSynchronizationError } from "../../core/model-runtime.ts";
|
import { CredentialSynchronizationError } from "../../core/model-runtime.ts";
|
||||||
import { DefaultPackageManager } from "../../core/package-manager.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 type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
||||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -122,7 +123,7 @@ import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts";
|
||||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||||
import { getUsageCostBreakdown } from "../../core/usage-totals.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 { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||||
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
|
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
|
||||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||||
|
|
@ -5980,26 +5981,13 @@ export class InteractiveMode {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.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<void> {
|
private async handleLoginCommand(providerRef?: string): Promise<void> {
|
||||||
if (!providerRef) {
|
if (!providerRef) {
|
||||||
this.showLoginAuthTypeSelector();
|
this.showLoginAuthTypeSelector();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = this.findLoginProviderOptions(providerRef);
|
const providerOptions = findMatchingProviders(this.getLoginProviderOptions(), providerRef);
|
||||||
if (providerOptions.length === 1) {
|
if (providerOptions.length === 1) {
|
||||||
await this.startProviderLogin(providerOptions[0]!);
|
await this.startProviderLogin(providerOptions[0]!);
|
||||||
return;
|
return;
|
||||||
|
|
@ -6520,26 +6508,9 @@ export class InteractiveMode {
|
||||||
* when missing. Nothing is asked when a global server already uses this login.
|
* when missing. Nothing is asked when a global server already uses this login.
|
||||||
*/
|
*/
|
||||||
private offerRadiusMcpServer(providerId: string, providerName: string): void {
|
private offerRadiusMcpServer(providerId: string, providerName: string): void {
|
||||||
const mcpPath = path.join(getAgentDir(), "mcp.json");
|
const plan = planRadiusMcpServer(providerId, getAgentDir(), this.sessionManager.getCwd());
|
||||||
const normalizeUrl = (url: string) => url.replace(/\/+$/u, "");
|
if (!plan) return;
|
||||||
const { servers } = loadMcpConfig({
|
const { mcpPath, name, config } = plan;
|
||||||
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;
|
|
||||||
|
|
||||||
this.showSelector((done) => {
|
this.showSelector((done) => {
|
||||||
const selector = new ExtensionSelectorComponent(
|
const selector = new ExtensionSelectorComponent(
|
||||||
|
|
|
||||||
29
packages/coding-agent/src/utils/terminal-question.ts
Normal file
29
packages/coding-agent/src/utils/terminal-question.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/coding-agent/src/utils/web-url.ts
Normal file
20
packages/coding-agent/src/utils/web-url.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
481
packages/coding-agent/test/auth-login.test.ts
Normal file
481
packages/coding-agent/test/auth-login.test.ts
Normal file
|
|
@ -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 <provider> [--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 <provider> [--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<string>((_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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
99
packages/coding-agent/test/auth-logout.test.ts
Normal file
99
packages/coding-agent/test/auth-logout.test.ts
Normal file
|
|
@ -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<string, Credential>) {
|
||||||
|
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 <provider>"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/coding-agent/test/auth-terminal.test.ts
Normal file
63
packages/coding-agent/test/auth-terminal.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -110,7 +110,7 @@ describe("pi mcp", () => {
|
||||||
for (const command of ["login", "logout"]) {
|
for (const command of ["login", "logout"]) {
|
||||||
expect(await run([command, "radius"], radius)).toMatchObject({
|
expect(await run([command, "radius"], radius)).toMatchObject({
|
||||||
exitCode: 1,
|
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 {
|
} finally {
|
||||||
|
|
|
||||||
35
packages/coding-agent/test/provider-matching.test.ts
Normal file
35
packages/coding-agent/test/provider-matching.test.ts
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/coding-agent/test/terminal-question.test.ts
Normal file
79
packages/coding-agent/test/terminal-question.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue