Add remote Paseo Agent configuration

This commit is contained in:
Mohamed Boudra
2026-05-30 00:07:24 +07:00
parent c9cd414571
commit 44afecc366
26 changed files with 2778 additions and 76 deletions

View File

@@ -4,9 +4,9 @@ import { createCli } from "../../cli.js";
import { createLoginCommand } from "./index.js";
interface RecordedLogin {
providerInstance: string;
envHome: string | undefined;
mode: "browser" | "device";
providerInstance?: string;
envHome?: string | undefined;
}
describe("paseo login command", () => {
@@ -24,13 +24,15 @@ describe("paseo login command", () => {
const flags = chatgpt?.options.map((option) => option.long);
// Default flow is browser; device-code is an opt-in fallback.
expect(flags).toContain("--device-code");
expect(flags).toContain("--host");
expect(flags).toContain("--home");
// It must not require a copy/paste device flow by default.
expect(chatgpt?.description().toLowerCase()).toContain("chatgpt");
});
it("runs browser login by default and opens the Pi auth URL", async () => {
it("runs browser login by default and stores the credential through the daemon", async () => {
const recorded: RecordedLogin[] = [];
const stored: unknown[] = [];
const openedUrls: string[] = [];
const output: string[] = [];
@@ -44,29 +46,57 @@ describe("paseo login command", () => {
promptForCode: async () => {
throw new Error("manual code prompt should not be used for successful browser login");
},
loginBrowser: async (options) => {
recorded.push({
providerInstance: options.providerInstance,
envHome: options.env?.PASEO_HOME,
mode: "browser",
});
loginBrowserCredential: async (options) => {
recorded.push({ mode: "browser" });
options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo");
options.onProgress?.("callback complete");
return { path: "/tmp/paseo-home/paseo-agent/auth.json" };
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
},
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "test-daemon",
features: { paseoAgentConfig: true },
}),
storePaseoAgentChatGptCredential: async (input) => {
stored.push(input);
return {
requestId: "request-1",
success: true,
providerName: input.providerName,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
close: async () => {},
};
},
loginDeviceCode: async () => {
throw new Error("device-code login should not be used by default");
},
});
await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]);
await login.parseAsync(["node", "login", "chatgpt", "--host", "localhost:7777"]);
expect(recorded).toEqual([
{ providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" },
expect(recorded).toEqual([{ mode: "browser" }]);
expect(stored).toEqual([
{
providerName: "chatgpt",
credential: {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]);
expect(output.join("\n")).toContain("browser flow");
expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json");
expect(output.join("\n")).toContain("selected daemon (localhost:7777)");
expect(output.join("\n")).not.toContain("access-token");
expect(output.join("\n")).not.toContain("refresh-token");
});
it("uses device-code login only when explicitly requested", async () => {
@@ -82,9 +112,12 @@ describe("paseo login command", () => {
promptForCode: async () => {
throw new Error("manual browser prompt should not run for --device-code");
},
loginBrowser: async () => {
loginBrowserCredential: async () => {
throw new Error("browser login should not run for --device-code");
},
connectDaemon: async () => {
throw new Error("daemon client should not be used for local --device-code");
},
loginDeviceCode: async (options) => {
recorded.push({
providerInstance: options.providerInstance,
@@ -116,4 +149,116 @@ describe("paseo login command", () => {
expect(output.join("\n")).toContain("headless device-code flow");
expect(output.join("\n")).toContain("ABCD-EFGH");
});
it("rejects --device-code with --host instead of writing local auth for a remote host", async () => {
const output: string[] = [];
const login = createLoginCommand({
write: (message) => output.push(message),
writeError: (message) => output.push(message),
openBrowser: () => {
throw new Error("browser opener should not run");
},
promptForCode: async () => {
throw new Error("prompt should not run");
},
loginBrowserCredential: async () => {
throw new Error("browser login should not run");
},
loginDeviceCode: async () => {
throw new Error("device-code login should not run with --host");
},
connectDaemon: async () => {
throw new Error("daemon client should not be used");
},
});
await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]);
expect(output.join("\n")).toContain("--device-code cannot be combined with --host");
});
it("asks for a host update instead of sending credentials to an old daemon", async () => {
const stored: unknown[] = [];
const output: string[] = [];
const login = createLoginCommand({
write: (message) => output.push(message),
writeError: (message) => output.push(message),
openBrowser: () => {
throw new Error("browser opener should not run without the capability flag");
},
promptForCode: async () => {
throw new Error("manual code prompt should not be used");
},
loginBrowserCredential: async () => {
throw new Error("browser login should not run without the capability flag");
},
connectDaemon: async () => ({
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "test-daemon",
features: {},
}),
storePaseoAgentChatGptCredential: async (input) => {
stored.push(input);
throw new Error("store RPC should not be called without the capability flag");
},
close: async () => {},
}),
loginDeviceCode: async () => {
throw new Error("device-code login should not run");
},
});
await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]);
expect(stored).toEqual([]);
expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers.");
});
it("does not echo password-bearing host URIs after remote login", async () => {
const output: string[] = [];
const login = createLoginCommand({
write: (message) => output.push(message),
writeError: (message) => output.push(message),
openBrowser: () => true,
promptForCode: async () => {
throw new Error("manual code prompt should not be used");
},
loginBrowserCredential: async () => ({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: 123,
}),
connectDaemon: async () => ({
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "test-daemon",
features: { paseoAgentConfig: true },
}),
storePaseoAgentChatGptCredential: async (input) => ({
requestId: "request-1",
success: true,
providerName: input.providerName,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
}),
close: async () => {},
}),
loginDeviceCode: async () => {
throw new Error("device-code login should not run");
},
});
await login.parseAsync([
"node",
"login",
"chatgpt",
"--host",
"tcp://remote:7777?ssl=true&password=super-secret",
]);
expect(output.join("\n")).toContain("tcp://remote:7777?ssl=true");
expect(output.join("\n")).not.toContain("super-secret");
});
});

View File

@@ -1,23 +1,28 @@
import { createInterface } from "node:readline/promises";
import { Command } from "commander";
import {
loginCodexBrowser,
loginAndStoreCodex,
loginAndStoreCodexBrowser,
type CodexDeviceCodeInfo,
type StoredCodexOAuthCredential,
} from "@getpaseo/server";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { addDaemonHostOption } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import { openBrowserUrl } from "../../utils/open-browser.js";
// First-class auth UX: `paseo login chatgpt`.
// Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's
// helper; `--device-code` is a headless fallback. Credentials are stored in the
// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read.
// helper; credentials are then sent to the selected daemon for storage. `--device-code`
// remains a local-only fallback until a daemon-run device-code RPC exists.
const PROVIDER_INSTANCE = "chatgpt";
interface LoginChatgptOptions {
deviceCode?: boolean;
home?: string;
host?: string;
}
interface LoginResult {
@@ -26,7 +31,12 @@ interface LoginResult {
interface LoginCommandDependencies {
loginDeviceCode: typeof loginAndStoreCodex;
loginBrowser: typeof loginAndStoreCodexBrowser;
loginBrowserCredential: typeof loginCodexBrowser;
connectDaemon: (options: {
host?: string;
}) => Promise<
Pick<DaemonClient, "getLastServerInfoMessage" | "storePaseoAgentChatGptCredential" | "close">
>;
openBrowser: (url: string) => boolean;
promptForCode: (message: string) => Promise<string>;
write: (message: string) => void;
@@ -35,7 +45,8 @@ interface LoginCommandDependencies {
const defaultDependencies: LoginCommandDependencies = {
loginDeviceCode: loginAndStoreCodex,
loginBrowser: loginAndStoreCodexBrowser,
loginBrowserCredential: loginCodexBrowser,
connectDaemon: connectToDaemon,
openBrowser: openBrowserUrl,
promptForCode,
write: (message) => console.log(message),
@@ -46,6 +57,29 @@ function resolveEnv(home: string | undefined): NodeJS.ProcessEnv {
return home ? { ...process.env, PASEO_HOME: home } : process.env;
}
function requirePaseoAgentConfigFeature(client: Pick<DaemonClient, "getLastServerInfoMessage">) {
if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) {
return;
}
throw new Error("Update the host to configure Paseo Agent providers.");
}
function formatDaemonTarget(host: string | undefined): string {
if (!host) {
return "local daemon";
}
try {
if (host.startsWith("tcp://")) {
const url = new URL(host);
url.searchParams.delete("password");
return `selected daemon (${url.toString()})`;
}
} catch {
// Invalid hosts fail during connection; this path only formats the success message.
}
return `selected daemon (${host})`;
}
async function promptForCode(message: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
@@ -69,6 +103,12 @@ async function runChatgptLogin(
const env = resolveEnv(options.home);
const { write } = dependencies;
if (options.deviceCode && options.host) {
throw new Error(
"--device-code cannot be combined with --host yet. Use browser login for remote hosts.",
);
}
if (options.deviceCode) {
write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n");
const { path } = await dependencies.loginDeviceCode({
@@ -80,43 +120,59 @@ async function runChatgptLogin(
return { path };
}
write("Paseo login — ChatGPT/Codex subscription (browser flow)\n");
const { path } = await dependencies.loginBrowser({
providerInstance: PROVIDER_INSTANCE,
env,
onAuthUrl: (url) => {
const opened = dependencies.openBrowser(url);
write(
opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:",
);
write(` ${url}\n`);
write("Waiting for you to approve in the browser…");
write("(If the browser didn't open, copy the URL above. You can also paste the code here.)");
},
onProgress: (message) => write(message),
promptForCode: dependencies.promptForCode,
});
write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`);
return { path };
const client = await dependencies.connectDaemon({ host: options.host });
try {
requirePaseoAgentConfigFeature(client);
write("Paseo login — ChatGPT/Codex subscription (browser flow)\n");
const credential: StoredCodexOAuthCredential = await dependencies.loginBrowserCredential({
onAuthUrl: (url) => {
const opened = dependencies.openBrowser(url);
write(
opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:",
);
write(` ${url}\n`);
write("Waiting for you to approve in the browser…");
write(
"(If the browser didn't open, copy the URL above. You can also paste the code here.)",
);
},
onProgress: (message) => write(message),
promptForCode: dependencies.promptForCode,
});
const result = await client.storePaseoAgentChatGptCredential({
providerName: PROVIDER_INSTANCE,
credential,
});
if (!result.success || result.error) {
throw new Error(result.error ?? "Daemon rejected the ChatGPT credential");
}
} finally {
await client.close().catch(() => {});
}
const target = formatDaemonTarget(options.host);
write(`\n✓ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`);
return { path: target };
}
export function createLoginCommand(dependencies: Partial<LoginCommandDependencies> = {}): Command {
const deps = { ...defaultDependencies, ...dependencies };
const login = new Command("login").description("Authenticate Paseo providers");
login
.command("chatgpt")
.description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider")
.option("--device-code", "Use the headless device-code flow instead of the browser flow")
.option("--home <path>", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)")
.action(async (options: LoginChatgptOptions) => {
try {
await runChatgptLogin(options, deps);
} catch (error) {
deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
});
addDaemonHostOption(
login
.command("chatgpt")
.description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider")
.option("--device-code", "Use the headless device-code flow instead of the browser flow")
.option("--home <path>", "Paseo home directory for local --device-code only"),
).action(async (options: LoginChatgptOptions) => {
try {
await runChatgptLogin(options, deps);
} catch (error) {
deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
});
return login;
}

View File

@@ -1,10 +1,13 @@
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { addOpenRouterOptions, runAddOpenRouterCommand } from "./openrouter.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(): Command {
export function createProviderCommand(
dependencies: Parameters<typeof runAddOpenRouterCommand>[3] = {},
): Command {
const provider = new Command("provider").description("Manage agent providers");
addJsonAndDaemonHostOptions(
@@ -19,5 +22,12 @@ export function createProviderCommand(): Command {
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
const add = provider.command("add").description("Configure a provider");
addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action(
withOutput<Awaited<ReturnType<typeof runAddOpenRouterCommand>>["data"], [string]>(
(name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies),
),
);
return provider;
}

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { runAddOpenRouterCommand } from "./openrouter.js";
describe("provider add openrouter", () => {
it("sends OpenRouter config to the selected daemon and redacts output", async () => {
const calls: unknown[] = [];
const result = await runAddOpenRouterCommand(
"openrouter-main",
{
host: "localhost:7777",
apiKeyStdin: true,
model: ["anthropic/claude-3.7-sonnet", "openai/gpt-4o"],
},
{} as never,
{
readStdin: async () => "redaction-sentinel\n",
env: {},
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "test-daemon",
features: { paseoAgentConfig: true },
}),
setPaseoAgentProvider: async (input) => {
calls.push(input);
return {
requestId: "request-1",
success: true,
provider: {
name: input.name,
providerType: "openrouter",
models: input.options.models,
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
close: async () => {},
};
},
},
);
expect(calls).toEqual([
{
name: "openrouter-main",
providerType: "openrouter",
options: {
apiKey: "redaction-sentinel",
models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }],
},
},
]);
const json = render(result, { format: "json" });
const table = render(result, { format: "table", noColor: true });
expect(json).not.toContain("redaction-sentinel");
expect(table).not.toContain("redaction-sentinel");
expect(table).toContain("openrouter-main");
expect(table).toContain("anthropic/claude-3.7-sonnet");
});
it("uses OPENROUTER_API_KEY by default and requires explicit models", async () => {
await expect(
runAddOpenRouterCommand("openrouter-main", { model: [] }, {} as never, {
env: { OPENROUTER_API_KEY: "redaction-sentinel" },
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () => {
throw new Error("daemon should not be called without models");
},
}),
).rejects.toMatchObject({ code: "MISSING_MODELS" });
});
it("asks for a host update instead of sending provider config to an old daemon", async () => {
const calls: unknown[] = [];
await expect(
runAddOpenRouterCommand(
"openrouter-main",
{
host: "localhost:7777",
apiKeyStdin: true,
model: ["anthropic/claude-3.7-sonnet"],
},
{} as never,
{
readStdin: async () => "redaction-sentinel\n",
env: {},
connectDaemon: async () => ({
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "test-daemon",
features: {},
}),
setPaseoAgentProvider: async (input) => {
calls.push(input);
throw new Error("set provider RPC should not run without the capability flag");
},
close: async () => {},
}),
},
),
).rejects.toMatchObject({
code: "HOST_UPDATE_REQUIRED",
message: "Update the host to configure Paseo Agent providers.",
});
expect(calls).toEqual([]);
});
});

View File

@@ -0,0 +1,182 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
interface OpenRouterAddOptions extends CommandOptions {
apiKey?: string;
apiKeyEnv?: string;
apiKeyStdin?: boolean;
model?: string[];
}
interface OpenRouterConfiguredItem {
name: string;
providerType: string;
auth: string;
available: string;
models: string;
}
interface OpenRouterDependencies {
connectDaemon: (options: {
host?: string;
}) => Promise<Pick<DaemonClient, "getLastServerInfoMessage" | "setPaseoAgentProvider" | "close">>;
env: NodeJS.ProcessEnv;
readStdin: () => Promise<string>;
}
const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY";
const defaultDependencies: OpenRouterDependencies = {
connectDaemon: connectToDaemon,
env: process.env,
readStdin,
};
export const openRouterConfiguredSchema: OutputSchema<OpenRouterConfiguredItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "TYPE", field: "providerType", width: 12 },
{ header: "AUTH", field: "auth", width: 16 },
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 50 },
],
};
async function readStdin(): Promise<string> {
process.stdin.setEncoding("utf8");
let value = "";
for await (const chunk of process.stdin) {
value += chunk;
}
return value;
}
function normalizeModels(rawModels: string[] | undefined): string[] {
return (rawModels ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
}
async function resolveApiKey(
options: OpenRouterAddOptions,
dependencies: OpenRouterDependencies,
): Promise<string> {
if (options.apiKey) {
return options.apiKey;
}
if (options.apiKeyStdin) {
const value = (await dependencies.readStdin()).trim();
if (value) {
return value;
}
throw {
code: "MISSING_API_KEY",
message: "No OpenRouter API key was read from stdin",
};
}
const envName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
const value = dependencies.env[envName]?.trim();
if (value) {
return value;
}
throw {
code: "MISSING_API_KEY",
message: `OpenRouter API key not found in $${envName}`,
details:
"Set OPENROUTER_API_KEY, pass --api-key-env <name>, or pipe the key with --api-key-stdin.",
};
}
function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRouterConfiguredItem {
return {
name: provider.name,
providerType: provider.providerType,
auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured",
available: provider.available ? "yes" : "no",
models: provider.models.map((model) => model.id).join(", "),
};
}
function requirePaseoAgentConfigFeature(client: Pick<DaemonClient, "getLastServerInfoMessage">) {
if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the host to configure Paseo Agent providers.",
};
}
export async function runAddOpenRouterCommand(
name: string,
options: OpenRouterAddOptions,
_command: Command,
dependencies: Partial<OpenRouterDependencies> = {},
): Promise<SingleResult<OpenRouterConfiguredItem>> {
const deps = { ...defaultDependencies, ...dependencies };
const models = normalizeModels(options.model);
if (models.length === 0) {
throw {
code: "MISSING_MODELS",
message: "At least one OpenRouter model is required",
details: "Pass --model <provider/model-id>. Repeat --model to configure more than one.",
};
}
const apiKey = await resolveApiKey(options, deps);
const client = await deps.connectDaemon({ host: options.host });
try {
requirePaseoAgentConfigFeature(client);
const result = await client.setPaseoAgentProvider({
name,
providerType: "openrouter",
options: {
apiKey,
models: models.map((id) => ({ id })),
},
});
if (!result.success || !result.provider) {
throw {
code: "PROVIDER_CONFIG_FAILED",
message: result.error ?? "Daemon rejected the OpenRouter provider config",
};
}
return {
type: "single",
data: toConfiguredItem(result.provider),
schema: openRouterConfiguredSchema,
};
} finally {
await client.close().catch(() => {});
}
}
export function addOpenRouterOptions(command: Command): Command {
return command
.description("Configure an OpenRouter inference provider for Paseo Agent")
.argument("<name>", "Provider instance name")
.option(
"--model <id>",
"OpenRouter model ID to expose (repeatable, comma-separated also accepted)",
collectMultiple,
[],
)
.option(
"--api-key-env <name>",
`Environment variable containing the API key`,
DEFAULT_API_KEY_ENV,
)
.option("--api-key-stdin", "Read the API key from stdin")
.option("--api-key <key>", "OpenRouter API key (prefer env or stdin to avoid shell history)");
}