From c9cd4145713061c74d98dd6eaac9acdc16ae8f35 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 15:37:35 +0700 Subject: [PATCH] Add Paseo Agent provider --- CLAUDE.md | 2 +- docs/paseo-agent.md | 186 ++++++ .../src/components/provider-icon-name.test.ts | 1 + packages/app/src/components/provider-icons.ts | 2 + packages/cli/src/cli.ts | 4 + packages/cli/src/commands/login/index.test.ts | 119 ++++ packages/cli/src/commands/login/index.ts | 122 ++++ packages/cli/src/utils/open-browser.ts | 30 + packages/protocol/src/provider-config.ts | 10 +- packages/protocol/src/provider-icon-names.ts | 1 + packages/protocol/src/provider-manifest.ts | 7 + packages/server/package.json | 3 + .../src/server/agent/provider-registry.ts | 21 +- .../agent/provider-snapshot-manager.test.ts | 3 +- .../server/agent/provider-snapshot-manager.ts | 5 + .../agent/providers/paseo-agent/agent.test.ts | 94 +++ .../agent/providers/paseo-agent/agent.ts | 560 ++++++++++++++++++ .../providers/paseo-agent/config.test.ts | 318 ++++++++++ .../agent/providers/paseo-agent/config.ts | 407 +++++++++++++ .../paseo-agent/event-mapping.test.ts | 78 +++ .../providers/paseo-agent/event-mapping.ts | 349 +++++++++++ .../providers/paseo-agent/mcp-bridge.test.ts | 169 ++++++ .../agent/providers/paseo-agent/mcp-bridge.ts | 245 ++++++++ .../providers/paseo-agent/mcp-schema.test.ts | 77 +++ .../agent/providers/paseo-agent/mcp-schema.ts | 161 +++++ .../paseo-agent/oauth-credentials.test.ts | 35 ++ .../paseo-agent/oauth-credentials.ts | 71 +++ .../providers/paseo-agent/oauth-store.test.ts | 129 ++++ .../providers/paseo-agent/oauth-store.ts | 124 ++++ .../providers/paseo-agent/pi-services.test.ts | 205 +++++++ .../providers/paseo-agent/pi-services.ts | 195 ++++++ packages/server/src/server/bootstrap.ts | 3 + packages/server/src/server/config.ts | 1 + packages/server/src/server/exports.ts | 8 + .../src/server/persisted-config.test.ts | 24 + .../server/src/server/persisted-config.ts | 2 + .../public/schemas/paseo.config.v1.json | 98 +++ 37 files changed, 3864 insertions(+), 5 deletions(-) create mode 100644 docs/paseo-agent.md create mode 100644 packages/cli/src/commands/login/index.test.ts create mode 100644 packages/cli/src/commands/login/index.ts create mode 100644 packages/cli/src/utils/open-browser.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/pi-services.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0dac6f1011..836042b9c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | -| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): in-process Pi harness, `agents.paseo` typed inference providers, auth, OAuth limitation | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md new file mode 100644 index 0000000000..5c55688dbc --- /dev/null +++ b/docs/paseo-agent.md @@ -0,0 +1,186 @@ +# Paseo Agent provider + +Paseo Agent is a built-in provider that runs Pi's coding-agent harness **in process** (no `pi` CLI, no `~/.pi` discovery). It is configured entirely by Paseo-owned config under `agents.paseo` in `$PASEO_HOME/config.json`. The model backends are "inference providers": one or more typed entries, each pointing at an API with its own key and models. + +The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other provider, e.g. `paseo run --provider paseo --model / ...`. + +This is a prototype. There is no UI yet — config-file only. + +> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing +> provider/config code, run `npm run build:server` (or run a source/dev daemon) before +> smoking, otherwise a stale `dist` may reject the `agents.paseo` config. Always pass +> `--host ` to CLI smoke commands so they hit your isolated daemon, not the real +> daemon on `:6767`. + +## MCP tools + +Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the +daemon-injected `paseo` MCP server (and any other configured MCP server) is available to +the model. On session start the provider connects to each server, lists its tools, and +registers them as Pi tools named `__`; tool input schemas (JSON +Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map +back to the model. Connections are torn down on session close. Servers that fail to +connect or list are logged and skipped rather than failing the session. + +Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP); +SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP +servers come from Paseo's normal injection/config, not from `agents.paseo`. + +## Config shape + +```jsonc +{ + "agents": { + "paseo": { + // Optional. "/". Used when an agent is + // started without an explicit model. + "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + + // Inference providers, keyed by instance name. Names are free-form; you may + // run several entries of the same type against different APIs/keys/models. + "providers": { + "openrouter-main": { + "type": "openrouter", + "options": { + // apiKey may be omitted to fall back to the type's env var + // (here OPENROUTER_API_KEY). It may also be a literal, an env + // reference like "$OPENROUTER_API_KEY" / "${OPENROUTER_API_KEY}", + // or a "!command" that prints the key. + "models": [ + { "id": "anthropic/claude-3.7-sonnet", "label": "Claude 3.7", "reasoning": true }, + { "id": "openai/gpt-4o", "label": "GPT-4o" }, + ], + }, + }, + }, + }, + }, +} +``` + +## Supported types + +Each type supplies sensible defaults so you usually only provide an API key (or its +env var) and model ids. + +| type | wire `api` | default base URL | default key env var | +| ------------------- | --------------------------- | --------------------------------- | --------------------- | +| `openrouter` | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | +| `openai` | `openai-responses` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | +| `anthropic` | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | +| `opencode` | `openai-completions` | `https://opencode.ai/zen/v1` | `OPENCODE_API_KEY` | +| `openai-compatible` | `openai-completions` | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ | +| `openai-codex` | `openai-codex-responses` | `https://chatgpt.com/backend-api` | _(OAuth — see below)_ | +| `custom` | _(required: `options.api`)_ | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ | + +Per-entry overrides live in `options`: `baseUrl`, `api`, `apiKey`, `headers`, +`authHeader` (send `Authorization: Bearer `), and `models[]`. Each model may +override `api` (e.g. an `anthropic-messages` model behind an otherwise +`openai-completions` provider), plus `label`, `reasoning`, `contextWindow`, `maxTokens`. + +### OpenCode Zen / Go (OpenAI-compatible) + +OpenCode Zen models speak either `openai-completions` or `anthropic-messages`. Use the +`opencode` type for Zen, or `openai-compatible` with an explicit base URL for Go, and +override the per-model `api` where a model is Anthropic-family: + +```jsonc +{ + "type": "opencode", + "options": { + "models": [ + { "id": "big-pickle" }, + { "id": "claude-sonnet", "api": "anthropic-messages" } + ] + } +} +// OpenCode Go: +{ + "type": "openai-compatible", + "options": { + "baseUrl": "https://opencode.ai/zen/go/v1", + "apiKey": "$OPENCODE_API_KEY", + "models": [{ "id": "glm-5" }] + } +} +``` + +Pi attaches its own `x-opencode-*` attribution headers automatically when the base URL +is on `opencode.ai`, so you do not set those yourself. + +### `custom` escape hatch + +When a backend needs a wire protocol the named types don't cover, use `custom` and set +`options.api` to a Pi wire protocol (e.g. `google-generative-ai`, `mistral-conversations`, +`openai-codex-responses`) plus `options.baseUrl`. This is a thin pass-through, not a place +to embed raw Pi internals. + +## Authentication + +- **API key / env var / command** — works for every type. Omit `apiKey` to use the + type's default env var; or set a literal, a `$ENV` reference, or a `!command`. +- A provider only counts as "available" (and its models listable for use) when its + resolved key is actually present — a literal value, a set env var, or a command. + +### ChatGPT / OpenAI subscription (OAuth) — `openai-codex` + +The `openai-codex` type uses a ChatGPT/OpenAI **subscription** via OAuth instead of an +API key, against `https://chatgpt.com/backend-api` with the `openai-codex-responses` wire +API. Paseo **owns** the auth: `paseo login chatgpt` runs Pi's browser PKCE/callback +OAuth flow by default, stores the credential in a Paseo-controlled file, and lets Pi +refresh/rotate it there. Paseo does **not** read ChatGPT/Codex/OpenCode/Pi or any other +tool's auth files. + +Config — just declare the provider and its models (no credential field): + +```jsonc +{ + "agents": { + "paseo": { + "providers": { + "chatgpt": { + "type": "openai-codex", + "options": { "models": [{ "id": "gpt-5.3-codex", "reasoning": true }] }, + }, + }, + }, + }, +} +``` + +> Use a model id that ChatGPT-account Codex supports — e.g. `gpt-5.3-codex` (live-verified), +> or another Pi codex id like `gpt-5.2`, `gpt-5.4`, `gpt-5.4-mini`. The non-subscription id +> `gpt-5-codex` is **not** accepted on a ChatGPT account (the backend returns a 400 +> "model is not supported when using Codex with a ChatGPT account"). + +Then log in once (the credential is stored under the `chatgpt` provider instance): + +```bash +paseo login chatgpt +# Opens your browser to approve (OAuth PKCE + local callback on 127.0.0.1:1455). +# If the browser can't open, the URL is printed to copy; you can also paste the code. +``` + +Headless machines (no browser) can use the device-code fallback: + +```bash +paseo login chatgpt --device-code # prints a URL + code to enter on another device +``` + +Both store the credential at `$PASEO_HOME/paseo-agent/auth.json` (mode `0600`, created by +Pi's AuthStorage; pass `--home` to target a specific Paseo home). On each session the +provider loads that credential, and Pi refreshes expired access tokens — persisting any +rotated refresh token **back into Paseo's own file**. A codex provider counts as +"available" once a credential is stored. Token values are never logged or printed. + +> **Rotation is handled within Paseo.** Because Paseo owns the store and Pi writes +> refreshed tokens back to it, rotation does not break Paseo. Run `paseo login chatgpt` +> again any time to re-authorize (e.g. after a long idle period or an explicit logout). + +**Advanced / manual override (not the normal path).** If you already hold your own refresh +token you may set `options.refreshToken` to a literal, `"$ENV"`/`"${ENV}"`, or `"!command"`. +It is seeded into the Paseo store at session start. This is for power users/automation; the +normal path is `paseo login chatgpt`. Paseo still never reads another tool's auth files. + +Other OAuth providers (Anthropic Pro/Max, Copilot) remain unwired; for those you can pass a +pre-obtained bearer token via `apiKey`/env where accepted (e.g. `ANTHROPIC_OAUTH_TOKEN`). diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index fabfbd25e4..0bc93e6664 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -13,6 +13,7 @@ describe("resolveProviderIconName", () => { expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" }); expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" }); expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" }); + expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" }); }); it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 92d8c1ca38..66448ab254 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -7,6 +7,7 @@ import { CopilotIcon } from "@/components/icons/copilot-icon"; import { MiniMaxIcon } from "@/components/icons/minimax-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PiIcon } from "@/components/icons/pi-icon"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; import { resolveProviderIconName } from "@/components/provider-icon-name"; @@ -26,6 +27,7 @@ const BUILTIN_PROVIDER_ICONS: Record = { minimax: MiniMaxIcon as unknown as ProviderIconComponent, omp: OmpIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, + paseo: PaseoLogo as unknown as ProviderIconComponent, pi: PiIcon as unknown as ProviderIconComponent, }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f89004dd51..32f868e03d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,7 @@ import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createChatCommand } from "./commands/chat/index.js"; +import { createLoginCommand } from "./commands/login/index.js"; import { createLoopCommand } from "./commands/loop/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; @@ -161,6 +162,9 @@ export function createCli(): Command { // Permission commands program.addCommand(createPermitCommand()); + // Auth / login commands + program.addCommand(createLoginCommand()); + // Provider commands program.addCommand(createProviderCommand()); diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts new file mode 100644 index 0000000000..cc01063a15 --- /dev/null +++ b/packages/cli/src/commands/login/index.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { createCli } from "../../cli.js"; +import { createLoginCommand } from "./index.js"; + +interface RecordedLogin { + providerInstance: string; + envHome: string | undefined; + mode: "browser" | "device"; +} + +describe("paseo login command", () => { + it("is registered on the top-level CLI", () => { + const program = createCli(); + const login = program.commands.find((command) => command.name() === "login"); + expect(login).toBeDefined(); + }); + + it("exposes a `chatgpt` subcommand with browser default + headless flag", () => { + const login = createLoginCommand(); + const chatgpt = login.commands.find((command) => command.name() === "chatgpt"); + expect(chatgpt).toBeDefined(); + + 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("--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 () => { + const recorded: RecordedLogin[] = []; + const openedUrls: string[] = []; + const output: string[] = []; + + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: (url) => { + openedUrls.push(url); + return true; + }, + 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", + }); + options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo"); + options.onProgress?.("callback complete"); + return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + }, + loginDeviceCode: async () => { + throw new Error("device-code login should not be used by default"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]); + + expect(recorded).toEqual([ + { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" }, + ]); + 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"); + }); + + it("uses device-code login only when explicitly requested", async () => { + const recorded: RecordedLogin[] = []; + 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 for --device-code"); + }, + promptForCode: async () => { + throw new Error("manual browser prompt should not run for --device-code"); + }, + loginBrowser: async () => { + throw new Error("browser login should not run for --device-code"); + }, + loginDeviceCode: async (options) => { + recorded.push({ + providerInstance: options.providerInstance, + envHome: options.env?.PASEO_HOME, + mode: "device", + }); + options.onDeviceCode({ + userCode: "ABCD-EFGH", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + }, + }); + + await login.parseAsync([ + "node", + "login", + "chatgpt", + "--device-code", + "--home", + "/tmp/paseo-home", + ]); + + expect(recorded).toEqual([ + { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "device" }, + ]); + expect(output.join("\n")).toContain("headless device-code flow"); + expect(output.join("\n")).toContain("ABCD-EFGH"); + }); +}); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts new file mode 100644 index 0000000000..0a7fc6eef6 --- /dev/null +++ b/packages/cli/src/commands/login/index.ts @@ -0,0 +1,122 @@ +import { createInterface } from "node:readline/promises"; +import { Command } from "commander"; +import { + loginAndStoreCodex, + loginAndStoreCodexBrowser, + type CodexDeviceCodeInfo, +} from "@getpaseo/server"; + +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. + +const PROVIDER_INSTANCE = "chatgpt"; + +interface LoginChatgptOptions { + deviceCode?: boolean; + home?: string; +} + +interface LoginResult { + path: string; +} + +interface LoginCommandDependencies { + loginDeviceCode: typeof loginAndStoreCodex; + loginBrowser: typeof loginAndStoreCodexBrowser; + openBrowser: (url: string) => boolean; + promptForCode: (message: string) => Promise; + write: (message: string) => void; + writeError: (message: string) => void; +} + +const defaultDependencies: LoginCommandDependencies = { + loginDeviceCode: loginAndStoreCodex, + loginBrowser: loginAndStoreCodexBrowser, + openBrowser: openBrowserUrl, + promptForCode, + write: (message) => console.log(message), + writeError: (message) => console.error(message), +}; + +function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { + return home ? { ...process.env, PASEO_HOME: home } : process.env; +} + +async function promptForCode(message: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await rl.question(`${message} `)).trim(); + } finally { + rl.close(); + } +} + +function printDeviceCode(write: (message: string) => void, info: CodexDeviceCodeInfo): void { + write("To authorize Paseo:"); + write(` 1. Open: ${info.verificationUri}`); + write(` 2. Enter code: ${info.userCode}`); + write(` (expires in ~${Math.round(info.expiresInSeconds / 60)} min — waiting...)\n`); +} + +async function runChatgptLogin( + options: LoginChatgptOptions, + dependencies: LoginCommandDependencies, +): Promise { + const env = resolveEnv(options.home); + const { write } = dependencies; + + if (options.deviceCode) { + write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); + const { path } = await dependencies.loginDeviceCode({ + providerInstance: PROVIDER_INSTANCE, + env, + onDeviceCode: (info) => printDeviceCode(write, info), + }); + write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); + 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 }; +} + +export function createLoginCommand(dependencies: Partial = {}): 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 ", "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; + } + }); + + return login; +} diff --git a/packages/cli/src/utils/open-browser.ts b/packages/cli/src/utils/open-browser.ts new file mode 100644 index 0000000000..b0f2432a6b --- /dev/null +++ b/packages/cli/src/utils/open-browser.ts @@ -0,0 +1,30 @@ +import { spawn } from "node:child_process"; + +/** + * Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the + * opener process was spawned, false otherwise. Callers must always print the URL too, + * so a failed/headless open still lets the user copy it. + */ +function browserOpenCommand(url: string): { command: string; args: string[] } { + switch (process.platform) { + case "darwin": + return { command: "open", args: [url] }; + case "win32": + return { command: "cmd", args: ["/c", "start", "", url] }; + default: + return { command: "xdg-open", args: [url] }; + } +} + +export function openBrowserUrl(url: string): boolean { + const { command, args } = browserOpenCommand(url); + + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.on("error", () => {}); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 2ab879c31f..989cc1af69 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -57,7 +57,15 @@ export const ProviderOverrideSchema = z.object({ order: z.number().optional(), }); -const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const; +const BUILTIN_PROVIDER_IDS = [ + "claude", + "codex", + "copilot", + "opencode", + "pi", + "omp", + "paseo", +] as const; const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/; export const ProviderOverridesSchema = z diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index c471a68c6f..a6685cd1b3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -6,6 +6,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [ "minimax", "omp", "opencode", + "paseo", "pi", ]; diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 312e704559..50e6a4d48f 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -220,6 +220,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModeId: null, modes: [], }, + { + id: "paseo", + label: "Paseo Agent", + description: "Paseo's in-process agent harness with configurable inference providers", + defaultModeId: null, + modes: [], + }, ]; export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ diff --git a/packages/server/package.json b/packages/server/package.json index 43bc2021b4..8447c3e044 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -65,6 +65,9 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", + "@earendil-works/pi-agent-core": "0.77.0", + "@earendil-works/pi-ai": "0.77.0", + "@earendil-works/pi-coding-agent": "0.77.0", "@getpaseo/client": "0.1.103", "@getpaseo/highlight": "0.1.103", "@getpaseo/protocol": "0.1.103", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 42382cb0ce..923ac2109f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -36,6 +36,8 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { PiRpcAgentClient } from "./providers/pi/agent.js"; +import { PaseoAgentClient } from "./providers/paseo-agent/agent.js"; +import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; import { @@ -78,11 +80,16 @@ export interface BuildProviderRegistryOptions { workspaceGitService?: Pick; managedProcesses?: ManagedProcessRegistry; isDev?: boolean; + /** + * Opaque Paseo Agent config blob. The registry only forwards it to the + * paseo-agent client factory; it never reads the nested inference providers. + */ + paseoAgentConfig?: PaseoAgentConfig; } interface ProviderClientFactoryOptions extends Pick< BuildProviderRegistryOptions, - "workspaceGitService" | "managedProcesses" + "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" > { providerParams?: unknown; customProvider?: { @@ -159,6 +166,11 @@ const PROVIDER_CLIENT_FACTORIES: Record = { }, commandsRpcType: "get_available_commands", }), + paseo: (logger, _runtimeSettings, options) => + new PaseoAgentClient({ + logger, + config: options?.paseoAgentConfig ?? {}, + }), mock: (logger) => new MockLoadTestAgentClient(logger), "mock-slow": () => new MockSlowProviderClient(), }; @@ -567,7 +579,10 @@ function createResolvedProviderClient( function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, - options: Pick, + options: Pick< + BuildProviderRegistryOptions, + "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" + >, isDev: boolean, ): Map { const resolvedProviders = new Map(); @@ -598,6 +613,7 @@ function buildResolvedBuiltinProviders( workspaceGitService: options.workspaceGitService, managedProcesses: options.managedProcesses, providerParams: override?.params, + paseoAgentConfig: options.paseoAgentConfig, }), }); } @@ -717,6 +733,7 @@ export function buildProviderRegistry( { workspaceGitService: options?.workspaceGitService, managedProcesses: options?.managedProcesses, + paseoAgentConfig: options?.paseoAgentConfig, }, options?.isDev === true, ); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 3223de6c30..640fc4e487 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -363,12 +363,13 @@ describe("ProviderSnapshotManager public surface", () => { copilot: { enabled: false }, opencode: { enabled: false }, pi: { enabled: false }, + paseo: { enabled: false }, }, }); try { const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true }); const providers = entries.map((entry) => entry.provider).sort(); - expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]); + expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "paseo", "pi"]); for (const entry of entries) { expect(entry.enabled).toBe(false); expect(entry.status).toBe("unavailable"); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index d6d270320e..7af2893fc8 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -27,6 +27,7 @@ import { shutdownAgentClients, type ProviderDefinition, } from "./provider-registry.js"; +import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js"; import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js"; import { formatProviderDiagnostic, @@ -76,6 +77,7 @@ export interface ProviderSnapshotManagerOptions { extraClients?: Partial>; refreshTimeoutMs?: number; diagnosticTimeoutMs?: number; + paseoAgentConfig?: PaseoAgentConfig; } interface ProviderSnapshotRefreshOptions { @@ -164,6 +166,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; + private readonly paseoAgentConfig: PaseoAgentConfig | undefined; private providerRegistry: Record; private providerClients: Record; @@ -176,6 +179,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; + this.paseoAgentConfig = options.paseoAgentConfig; this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs( options.diagnosticTimeoutMs, @@ -421,6 +425,7 @@ export class ProviderSnapshotManager { workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, isDev: this.isDev, + paseoAgentConfig: this.paseoAgentConfig, }); for (const [provider, client] of Object.entries(this.extraClients) as Array< diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts new file mode 100644 index 0000000000..55358deb1f --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentSessionConfig } from "../../agent-sdk-types.js"; +import { PaseoAgentClient } from "./agent.js"; +import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; + +function makeConfig(): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + defaultModel: "openrouter-main/test-model", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "test-model", label: "Test Model" }], + }, + }, + }, + }); +} + +function sessionConfig(overrides?: Partial): AgentSessionConfig { + return { provider: "paseo", cwd: process.cwd(), ...overrides }; +} + +describe("PaseoAgentClient", () => { + it("is available only when config has a usable inference provider", async () => { + const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + expect(await withConfig.isAvailable()).toBe(true); + + const empty = new PaseoAgentClient({ + logger: createTestLogger(), + config: PaseoAgentConfigSchema.parse({}), + }); + expect(await empty.isAvailable()).toBe(false); + }); + + it("lists only configured models, never Pi disk/default models", async () => { + const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + const models = await client.listModels({ cwd: process.cwd(), force: false }); + expect(models.map((m) => m.id)).toEqual(["openrouter-main/test-model"]); + expect(models[0]?.isDefault).toBe(true); + }); + + it("throws when creating a session with no configured providers", async () => { + const client = new PaseoAgentClient({ + logger: createTestLogger(), + config: PaseoAgentConfigSchema.parse({}), + }); + await expect(client.createSession(sessionConfig())).rejects.toThrow(/no configured/i); + }); + + it("creates an in-process session bound to the configured model", async () => { + const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + const session = await client.createSession(sessionConfig()); + try { + expect(session.provider).toBe("paseo"); + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/test-model"); + // In-memory prototype: no durable persistence handle. + expect(session.describePersistence()).toBeNull(); + } finally { + await session.close(); + } + }); + + it("honors an explicitly requested model over the default", async () => { + const config = PaseoAgentConfigSchema.parse({ + defaultModel: "openrouter-main/a", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config }); + const session = await client.createSession(sessionConfig({ model: "openrouter-main/b" })); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts new file mode 100644 index 0000000000..0a39ed779f --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -0,0 +1,560 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; + +import { + getAgentStreamEventTurnId, + type AgentCapabilityFlags, + type AgentClient, + type AgentLaunchContext, + type AgentMode, + type AgentModelDefinition, + type AgentPermissionRequest, + type AgentPermissionResponse, + type AgentPersistenceHandle, + type AgentPromptInput, + type AgentRunOptions, + type AgentRunResult, + type AgentRuntimeInfo, + type AgentSession, + type AgentSessionConfig, + type AgentSlashCommand, + type AgentStreamEvent, + type AgentTimelineItem, + type AgentUsage, + type ListModelsOptions, +} from "../../agent-sdk-types.js"; +import { + PASEO_AGENT_PROVIDER, + type PaseoAgentConfig, + listPaseoAgentModels, + paseoAgentHasUsableModel, + paseoAgentInferenceProviders, + parsePaseoAgentModelId, + resolvePaseoAgentModel, +} from "./config.js"; +import { + convertPromptInput, + getUserMessageText, + mapToolDetail, + parseToolArgs, + parseToolResult, + toAgentUsage, + type PiTrackedToolCall, +} from "./event-mapping.js"; +import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; +import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; +import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; + +const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; + +const PASEO_AGENT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + // Phase 4 uses in-memory sessions; resume/persistence is out of scope. + supportsSessionPersistence: false, + supportsDynamicModes: false, + // MCP servers from AgentSessionConfig.mcpServers are bridged to Pi custom tools. + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const THINKING_LEVELS: ReadonlySet = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]); + +function normalizeThinkingLevel(value: string | null | undefined): ThinkingLevel | null { + return value && THINKING_LEVELS.has(value as ThinkingLevel) ? (value as ThinkingLevel) : null; +} + +function resolveIsolatedAgentDir(): string { + // Paseo-owned, never ~/.pi. Inert because all Pi services are in-memory, but we + // still keep it off the project tree and outside Pi's default global config. + const base = process.env.PASEO_HOME ?? join(tmpdir(), "paseo-agent"); + return join(base, "pi-harness"); +} + +interface PaseoAgentClientOptions { + logger: Logger; + config: PaseoAgentConfig; +} + +export class PaseoAgentSession implements AgentSession { + readonly provider = PASEO_AGENT_PROVIDER; + readonly capabilities = PASEO_AGENT_CAPABILITIES; + + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly activeToolCalls = new Map(); + private activeTurnId: string | null = null; + private lastThinkingOptionId: string | null; + + constructor( + private readonly handle: PaseoAgentSessionHandle, + private readonly config: AgentSessionConfig, + private readonly mcpBridge: McpToolBridge, + ) { + this.lastThinkingOptionId = + normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null; + this.piSession.subscribe((event) => this.handleSessionEvent(event)); + } + + private get piSession() { + return this.handle.session; + } + + get id(): string | null { + return this.piSession.sessionId; + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private emitToolCall( + toolCallId: string, + toolCall: PiTrackedToolCall, + status: "running" | "completed" | "failed", + result: ReturnType, + error: unknown, + ): void { + const turnId = this.activeTurnId ?? undefined; + const baseItem = { + type: "tool_call" as const, + callId: toolCallId, + name: toolCall.toolName, + detail: mapToolDetail(toolCall, result), + }; + const item = + status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; + this.emit({ type: "timeline", provider: PASEO_AGENT_PROVIDER, turnId, item }); + } + + private handleSessionEvent(event: AgentSessionEvent): void { + const turnId = this.activeTurnId ?? undefined; + switch (event.type) { + case "agent_start": + this.emit({ + type: "thread_started", + provider: PASEO_AGENT_PROVIDER, + sessionId: this.piSession.sessionId, + }); + return; + case "turn_start": + this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId }); + return; + case "message_update": { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, + }); + } + return; + } + case "tool_execution_start": { + const toolCall = parseToolArgs(event.toolName, event.args); + this.activeToolCalls.set(event.toolCallId, toolCall); + this.emitToolCall(event.toolCallId, toolCall, "running", null, null); + return; + } + case "tool_execution_update": { + const toolCall = this.activeToolCalls.get(event.toolCallId); + if (!toolCall) { + return; + } + this.emitToolCall( + event.toolCallId, + toolCall, + "running", + parseToolResult(event.partialResult), + null, + ); + return; + } + case "tool_execution_end": { + const toolCall = + this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); + this.activeToolCalls.delete(event.toolCallId); + const result = parseToolResult(event.result); + const status = event.isError ? "failed" : "completed"; + this.emitToolCall( + event.toolCallId, + toolCall, + status, + result, + event.isError ? event.result : null, + ); + return; + } + case "agent_end": { + const usage = toAgentUsage(this.piSession.getSessionStats()); + const currentTurnId = turnId; + this.activeTurnId = null; + const errorMessage = this.piSession.agent.state.errorMessage; + if (errorMessage) { + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + usage, + }); + return; + } + default: + return; + } + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const timeline: AgentTimelineItem[] = []; + let finalText = ""; + let usage: AgentUsage | undefined; + let turnId: string | null = null; + const bufferedEvents: AgentStreamEvent[] = []; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; + + function processEvent(event: AgentStreamEvent): void { + if (settled) { + return; + } + const eventTurnId = getAgentStreamEventTurnId(event); + if (turnId && eventTurnId && eventTurnId !== turnId) { + return; + } + if (event.type === "timeline") { + timeline.push(event.item); + if (event.item.type === "assistant_message") { + finalText += event.item.text; + } + return; + } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + } + } + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + bufferedEvents.push(event); + return; + } + processEvent(event); + }); + + try { + const result = await this.startTurn(prompt, options); + turnId = result.turnId; + for (const event of bufferedEvents) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); + } + + return { sessionId: this.piSession.sessionId, finalText, usage, timeline }; + } + + async startTurn( + prompt: AgentPromptInput, + _options?: AgentRunOptions, + ): Promise<{ turnId: string }> { + if (this.activeTurnId) { + throw new Error("A Paseo Agent turn is already active"); + } + const payload = convertPromptInput(prompt); + const turnId = randomUUID(); + this.activeTurnId = turnId; + + void this.piSession + .prompt(payload.text, payload.images ? { images: payload.images } : undefined) + .catch((error: unknown) => { + const failedTurnId = this.activeTurnId ?? turnId; + this.activeTurnId = null; + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: failedTurnId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + const pendingToolCalls = new Map(); + let userIndex = 0; + + for (const message of this.piSession.messages) { + if (message.role === "user") { + const text = getUserMessageText(message.content); + if (text) { + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: { type: "user_message", text, messageId: `paseo-agent-user-${userIndex}` }, + }; + } + userIndex += 1; + continue; + } + if (message.role === "assistant") { + for (const content of message.content) { + if (content.type === "text" && content.text) { + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: { type: "assistant_message", text: content.text }, + }; + continue; + } + if (content.type === "thinking" && content.thinking) { + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: { type: "reasoning", text: content.thinking }, + }; + continue; + } + if (content.type === "toolCall") { + const tracked = parseToolArgs(content.name, content.arguments); + pendingToolCalls.set(content.id, tracked); + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: { + type: "tool_call", + callId: content.id, + name: tracked.toolName, + status: "running", + detail: mapToolDetail(tracked, null), + error: null, + }, + }; + } + } + continue; + } + if (message.role === "toolResult") { + const tracked = + pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); + pendingToolCalls.delete(message.toolCallId); + const detail = mapToolDetail(tracked, parseToolResult({ content: message.content })); + const base = { + type: "tool_call" as const, + callId: message.toolCallId, + name: tracked.toolName, + detail, + }; + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: message.isError + ? { ...base, status: "failed", error: "Tool call failed" } + : { ...base, status: "completed", error: null }, + }; + } + } + } + + async getRuntimeInfo(): Promise { + const model = this.piSession.model; + return { + provider: PASEO_AGENT_PROVIDER, + sessionId: this.piSession.sessionId, + model: model ? `${model.provider}/${model.id}` : null, + thinkingOptionId: + normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null, + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(_modeId: string): Promise { + throw new Error("Paseo Agent does not expose selectable modes"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission( + _requestId: string, + _response: AgentPermissionResponse, + ): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return null; + } + + async interrupt(): Promise { + await this.piSession.abort(); + } + + async close(): Promise { + this.piSession.dispose(); + await this.mcpBridge.close(); + } + + async listCommands(): Promise { + return []; + } + + async setModel(modelId: string | null): Promise { + if (!modelId) { + return; + } + const reference = parsePaseoAgentModelId(modelId); + if (!reference) { + throw new Error(`Invalid Paseo Agent model: ${modelId}`); + } + const model = this.handle.modelRegistry.find(reference.provider, reference.id); + if (!model) { + throw new Error(`Unknown Paseo Agent model: ${modelId}`); + } + await this.piSession.setModel(model); + this.config.model = modelId; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const level = normalizeThinkingLevel(thinkingOptionId) ?? DEFAULT_THINKING_LEVEL; + this.piSession.setThinkingLevel(level); + this.lastThinkingOptionId = level; + this.config.thinkingOptionId = level; + } +} + +export class PaseoAgentClient implements AgentClient { + readonly provider = PASEO_AGENT_PROVIDER; + readonly capabilities = PASEO_AGENT_CAPABILITIES; + + private readonly logger: Logger; + private readonly config: PaseoAgentConfig; + + constructor(options: PaseoAgentClientOptions) { + this.logger = options.logger; + this.config = options.config; + } + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + const inferenceProviders = paseoAgentInferenceProviders(this.config); + if (inferenceProviders.length === 0) { + throw new Error( + "Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.", + ); + } + + const model = resolvePaseoAgentModel(this.config, config.model, inferenceProviders); + const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; + this.logger.debug( + { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null }, + "Creating Paseo Agent session", + ); + + // OAuth providers (ChatGPT/Codex) use a Paseo-owned, file-backed AuthStorage so Pi + // reads the stored credential and persists refreshed tokens (rotation) back to it. + const usesOAuth = inferenceProviders.some((provider) => provider.oauth); + const authStorage = usesOAuth ? createPaseoAgentAuthStorage() : undefined; + + // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. + const mcpBridge = await createMcpToolBridge({ + mcpServers: config.mcpServers, + logger: this.logger, + }); + + try { + const handle = await createPaseoAgentSession({ + cwd: config.cwd, + agentDir: resolveIsolatedAgentDir(), + inferenceProviders, + ...(model ? { model } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(authStorage ? { authStorage } : {}), + ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + }); + return new PaseoAgentSession(handle, config, mcpBridge); + } catch (error) { + await mcpBridge.close(); + throw error; + } + } + + async resumeSession(): Promise { + throw new Error("Paseo Agent does not support session resume in this prototype"); + } + + async listModels(_options: ListModelsOptions): Promise { + return listPaseoAgentModels(this.config); + } + + async isAvailable(): Promise { + return paseoAgentHasUsableModel(this.config, process.env, (providerInstance) => + hasStoredOAuthCredential(providerInstance), + ); + } +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts new file mode 100644 index 0000000000..6b580900a6 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import { + PaseoAgentConfigSchema, + encodePaseoAgentModelId, + listPaseoAgentModels, + paseoAgentHasUsableModel, + paseoAgentInferenceProviders, + parsePaseoAgentModelId, + resolvePaseoAgentModel, + type PaseoAgentConfig, +} from "./config.js"; + +function configWith(overrides?: Partial): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-test", + models: [ + { id: "anthropic/claude", label: "Claude", reasoning: true }, + { id: "openai/gpt", reasoning: false }, + ], + }, + }, + }, + ...overrides, + }); +} + +describe("PaseoAgentConfigSchema", () => { + it("rejects unknown keys (strict)", () => { + expect(() => PaseoAgentConfigSchema.parse({ providers: {}, unexpected: true })).toThrow(); + }); + + it("rejects an unknown inference provider type", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "mystery", options: { models: [{ id: "m" }] } } }, + }), + ).toThrow(); + }); + + it("requires at least one model per inference provider", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "openrouter", options: { models: [] } } }, + }), + ).toThrow(); + }); + + it("requires baseUrl for openai-compatible and api for custom", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "openai-compatible", options: { models: [{ id: "m" }] } } }, + }), + ).toThrow(/baseUrl/); + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { + p: { type: "custom", options: { baseUrl: "https://x.test", models: [{ id: "m" }] } }, + }, + }), + ).toThrow(/api/); + }); + + it("accepts an empty config", () => { + expect(PaseoAgentConfigSchema.parse({})).toEqual({}); + }); + + it("accepts multiple entries of the same type with distinct names", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + "openai-a": { type: "openai", options: { apiKey: "sk-a", models: [{ id: "gpt-a" }] } }, + "openai-b": { + type: "openai", + options: { baseUrl: "https://proxy.test/v1", apiKey: "sk-b", models: [{ id: "gpt-b" }] }, + }, + }, + }); + expect(Object.keys(config.providers ?? {})).toEqual(["openai-a", "openai-b"]); + }); +}); + +describe("model id encoding", () => { + it("round-trips provider + model id", () => { + const id = encodePaseoAgentModelId("openrouter-main", "anthropic/claude"); + expect(parsePaseoAgentModelId(id)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("returns null for an unprefixed id", () => { + expect(parsePaseoAgentModelId("noslash")).toBeNull(); + }); +}); + +describe("listPaseoAgentModels", () => { + it("exposes every configured model with provider-prefixed ids", () => { + const models = listPaseoAgentModels(configWith()); + expect(models.map((m) => m.id)).toEqual([ + "openrouter-main/anthropic/claude", + "openrouter-main/openai/gpt", + ]); + expect(models.every((m) => m.provider === "paseo")).toBe(true); + }); + + it("marks the configured default model", () => { + const models = listPaseoAgentModels(configWith({ defaultModel: "openrouter-main/openai/gpt" })); + const defaults = models.filter((m) => m.isDefault).map((m) => m.id); + expect(defaults).toEqual(["openrouter-main/openai/gpt"]); + }); +}); + +describe("paseoAgentInferenceProviders (per-type defaults)", () => { + it("applies openrouter defaults (base url, api, model fields)", () => { + const [provider] = paseoAgentInferenceProviders(configWith()); + expect(provider.name).toBe("openrouter-main"); + expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1"); + expect(provider.config.apiKey).toBe("sk-test"); + expect(provider.config.models?.[0]).toMatchObject({ + id: "anthropic/claude", + name: "Claude", + api: "openai-completions", + reasoning: true, + contextWindow: 128_000, + maxTokens: 16_384, + }); + }); + + it("falls back to the type's env var when no apiKey is given", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.baseUrl).toBe("https://api.anthropic.com"); + expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY"); + expect(provider.config.models?.[0]?.api).toBe("anthropic-messages"); + }); + + it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + zen: { + type: "openai-compatible", + options: { + baseUrl: "https://opencode.ai/zen/v1", + apiKey: "$OPENCODE_API_KEY", + models: [{ id: "big-pickle" }, { id: "claude-sonnet", api: "anthropic-messages" }], + }, + }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1"); + expect(provider.config.models?.[0]?.api).toBe("openai-completions"); + expect(provider.config.models?.[1]?.api).toBe("anthropic-messages"); + }); + + it("passes through the custom escape hatch (explicit api + authHeader)", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + vertex: { + type: "custom", + options: { + baseUrl: "https://my-gateway.test/v1", + api: "google-generative-ai", + apiKey: "sk-custom", + authHeader: true, + headers: { "x-extra": "1" }, + models: [{ id: "gemini" }], + }, + }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.api).toBe("google-generative-ai"); + expect(provider.config.authHeader).toBe(true); + expect(provider.config.headers).toEqual({ "x-extra": "1" }); + expect(provider.config.models?.[0]?.api).toBe("google-generative-ai"); + }); +}); + +describe("paseoAgentHasUsableModel (env-aware auth)", () => { + it("is true for a literal api key", () => { + expect(paseoAgentHasUsableModel(configWith(), {})).toBe(true); + }); + + it("is false when no providers are configured", () => { + expect(paseoAgentHasUsableModel(PaseoAgentConfigSchema.parse({}), {})).toBe(false); + }); + + it("is false for an openai-compatible provider without any key", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + local: { + type: "openai-compatible", + options: { baseUrl: "https://local.test/v1", models: [{ id: "m" }] }, + }, + }, + }); + expect(paseoAgentHasUsableModel(config, {})).toBe(false); + }); + + it("follows the env var for an env-backed key", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } } }, + }); + expect(paseoAgentHasUsableModel(config, {})).toBe(false); + expect(paseoAgentHasUsableModel(config, { OPENROUTER_API_KEY: "sk-env" })).toBe(true); + }); +}); + +describe("resolvePaseoAgentModel", () => { + it("prefers the explicit request, then default, then first configured", () => { + const config = configWith({ defaultModel: "openrouter-main/openai/gpt" }); + expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + expect(resolvePaseoAgentModel(config, null)).toEqual({ + provider: "openrouter-main", + id: "openai/gpt", + }); + expect(resolvePaseoAgentModel(configWith(), null)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("returns undefined when no providers are configured", () => { + expect(resolvePaseoAgentModel(PaseoAgentConfigSchema.parse({}), null)).toBeUndefined(); + }); + + it("ignores an implicit default whose provider is not registered", () => { + const config = configWith({ defaultModel: "ghost/model" }); + expect(resolvePaseoAgentModel(config, null)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("honors an explicit request even if its provider is not registered", () => { + expect(resolvePaseoAgentModel(configWith(), "ghost/model")).toEqual({ + provider: "ghost", + id: "model", + }); + }); +}); + +describe("openai-codex (ChatGPT subscription) provider", () => { + function codexConfig(options: Record): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "openai-codex", + options: { models: [{ id: "gpt-5.3-codex" }], ...options }, + }, + }, + }); + } + + it("accepts a codex provider with no credential field (login provides it)", () => { + const config = codexConfig({}); + expect(config.providers?.chatgpt?.type).toBe("openai-codex"); + }); + + it("rejects an unknown option like a foreign credentials file", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "openai-codex", + options: { credentialsFile: "/Users/me/.codex/auth.json", models: [{ id: "x" }] }, + }, + }, + }), + ).toThrow(); + }); + + it("maps to a codex inference provider with an oauth marker and no api key", () => { + const [provider] = paseoAgentInferenceProviders(codexConfig({}), {}); + expect(provider.name).toBe("chatgpt"); + expect(provider.oauth).toEqual({ kind: "openai-codex" }); + expect(provider.config.apiKey).toBeUndefined(); + expect(provider.config.api).toBe("openai-codex-responses"); + expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api"); + expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses"); + }); + + it("carries an advanced self-supplied refresh token resolved from an env var", () => { + const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }); + const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" }); + expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" }); + }); + + it("availability uses the OAuth store predicate, or an advanced refresh token", () => { + // No stored credential and no advanced token → not available. + expect(paseoAgentHasUsableModel(codexConfig({}), {})).toBe(false); + // Stored credential (predicate true) → available. + expect(paseoAgentHasUsableModel(codexConfig({}), {}, () => true)).toBe(true); + // Advanced env-backed token → available without the store. + expect( + paseoAgentHasUsableModel(codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }), { + CODEX_REFRESH_TOKEN: "rt-env", + }), + ).toBe(true); + }); + + it("lists codex models regardless of auth state", () => { + const models = listPaseoAgentModels(codexConfig({})); + expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.3-codex"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts new file mode 100644 index 0000000000..540f41dd36 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -0,0 +1,407 @@ +import { z } from "zod"; + +import type { AgentModelDefinition } from "../../agent-sdk-types.js"; +import { + isRefreshTokenExpressionConfigured, + resolveRefreshTokenExpression, +} from "./oauth-credentials.js"; +import type { PaseoAgentInferenceProvider, PaseoAgentModelReference } from "./pi-services.js"; + +export const PASEO_AGENT_PROVIDER = "paseo"; + +// Dedicated Paseo-owned config for the Paseo Agent provider. This is the single +// schema for `agents.paseo`; inference-provider fields are intentionally NOT +// merged into the shared strict ProviderOverrideSchema, and only this provider +// (via the helpers below) consumes it. This module imports no Pi runtime code so +// it stays cheap to load from persisted-config parsing. +// +// Inference providers are typed by `type`. Each known type carries sensible +// defaults (base URL, Pi wire `api`, and the env var its API key is read from), +// so a user only needs to give an `apiKey` (or the env var) and one or more model +// ids. `openai-compatible` covers any OpenAI Chat Completions endpoint (incl. +// OpenCode Zen/Go behind a custom base URL); `custom` is a thin escape hatch for +// directly choosing Pi's wire `api`. +// +// AUTH: API-key and env-var auth work for every type. ChatGPT/OpenAI subscription +// OAuth is supported via the `openai-codex` type. The product path is `paseo login +// chatgpt`, which runs Pi's browser PKCE/callback login by default and stores the +// credential in a Paseo-controlled file (see oauth-store.ts); the session loads it and +// Pi refreshes/persists rotation there. `options.refreshToken` is an advanced, manual +// escape hatch for users supplying their OWN token (literal/`$ENV`/`!cmd`) — it is not +// the normal path. Paseo never reads another tool's auth files. Other OAuth providers +// (e.g. Anthropic Pro/Max) remain unwired. + +const PROVIDER_TYPES = [ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", +] as const; + +export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number]; + +interface ProviderTypeDefault { + /** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */ + api?: string; + /** Default base URL. `undefined` means the user must supply `options.baseUrl`. */ + baseUrl?: string; + /** Env var the API key is read from when `options.apiKey` is omitted. */ + envVar?: string; +} + +// Defaults mirror Pi's built-in provider definitions (packages/ai models). Pi adds +// its own attribution headers for openrouter/opencode based on the base URL, so we +// deliberately do not inject provider headers here. +const PROVIDER_TYPE_DEFAULTS: Record = { + openrouter: { + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + envVar: "OPENROUTER_API_KEY", + }, + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + envVar: "OPENAI_API_KEY", + }, + anthropic: { + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + envVar: "ANTHROPIC_API_KEY", + }, + opencode: { + // OpenCode Zen. Some Zen models speak anthropic-messages; override per model + // with `api` when needed. Go models live behind a custom base URL via + // `openai-compatible` (or set `options.baseUrl` to .../zen/go/v1 here). + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + envVar: "OPENCODE_API_KEY", + }, + "openai-compatible": { + api: "openai-completions", + baseUrl: undefined, + envVar: undefined, + }, + "openai-codex": { + // ChatGPT/OpenAI subscription via OAuth. Auth is a refresh token, not an API + // key, so there is no default env var here. + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + envVar: undefined, + }, + custom: { + api: undefined, + baseUrl: undefined, + envVar: undefined, + }, +}; + +const PaseoAgentModelSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + // Override the provider's wire api for this model (e.g. an anthropic-messages + // model served by an otherwise openai-completions provider like OpenCode Zen). + api: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + }) + .strict(); + +const PaseoAgentProviderOptionsSchema = z + .object({ + // API key. Literal value, an env-var reference like `$OPENROUTER_API_KEY` / + // `${OPENROUTER_API_KEY}`, or a `!command` (resolved by Pi at request time). + // When omitted, known types fall back to their default env var. + apiKey: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + // Override the wire api. Required for `custom`; optional elsewhere. + api: z.string().min(1).optional(), + headers: z.record(z.string()).optional(), + // Advanced: send `Authorization: Bearer ` as a header. Only needed for + // endpoints whose wire api doesn't already attach the key. + authHeader: z.boolean().optional(), + // Advanced/manual ONLY: a self-supplied OAuth refresh token for `openai-codex` + // (literal, `$ENV`/`${ENV}`, or `!command`). The normal path is the Paseo-owned + // login (`paseo login chatgpt`), which stores the credential for you. + refreshToken: z.string().min(1).optional(), + models: z.array(PaseoAgentModelSchema).min(1), + }) + .strict(); + +const PaseoAgentInferenceProviderSchema = z + .object({ + type: z.enum(PROVIDER_TYPES), + options: PaseoAgentProviderOptionsSchema, + }) + .strict() + .superRefine((entry, ctx) => { + const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + if (!defaults.baseUrl && !entry.options.baseUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", "baseUrl"], + message: `Inference provider type "${entry.type}" requires options.baseUrl.`, + }); + } + if (!defaults.api && !entry.options.api) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", "api"], + message: `Inference provider type "${entry.type}" requires options.api.`, + }); + } + // `openai-codex` needs no credential field here: auth comes from the Paseo-owned + // store populated by `paseo login chatgpt`. `options.refreshToken` is an + // optional advanced override. + }); + +export const PaseoAgentConfigSchema = z + .object({ + // Optional default model as "/". + defaultModel: z.string().min(1).optional(), + // Inference providers keyed by instance name. Multiple entries may share a + // type while pointing at different APIs/base URLs/models. + providers: z.record(PaseoAgentInferenceProviderSchema).optional(), + }) + .strict(); + +export type PaseoAgentConfig = z.infer; +type PaseoAgentInferenceProviderEntry = z.infer; + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 16_384; + +interface ResolvedProviderSettings { + baseUrl?: string; + api?: string; + apiKey?: string; + headers?: Record; + authHeader?: boolean; +} + +function entries(config: PaseoAgentConfig): [string, PaseoAgentInferenceProviderEntry][] { + return Object.entries(config.providers ?? {}); +} + +/** Apply per-type defaults to a raw inference provider entry. */ +function resolveProviderSettings( + entry: PaseoAgentInferenceProviderEntry, +): ResolvedProviderSettings { + const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + const apiKey = entry.options.apiKey ?? (defaults.envVar ? `$${defaults.envVar}` : undefined); + return { + baseUrl: entry.options.baseUrl ?? defaults.baseUrl, + api: entry.options.api ?? defaults.api, + apiKey, + headers: entry.options.headers, + authHeader: entry.options.authHeader, + }; +} + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; + +/** + * Whether a resolved API-key value is actually configured. Mirrors Pi's config-value + * semantics without importing Pi: literals and `!command` values count as present; + * `$ENV` / `${ENV}` references count only when every referenced var is set. + */ +function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): boolean { + if (!value) { + return false; + } + if (value.startsWith("!")) { + return true; + } + const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + if (referencedVars.length === 0) { + return true; + } + return referencedVars.every((name) => Boolean(env[name])); +} + +/** Encode the Paseo-facing model id from an inference provider + Pi model id. */ +export function encodePaseoAgentModelId(providerName: string, modelId: string): string { + return `${providerName}/${modelId}`; +} + +/** Parse a Paseo-facing model id back into its inference provider + Pi model id. */ +export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReference | null { + const slash = modelId.indexOf("/"); + if (slash <= 0 || slash === modelId.length - 1) { + return null; + } + return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) }; +} + +function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedProviderSettings) { + return entry.options.models.map((model) => { + const api = model.api ?? settings.api; + return { + id: model.id, + name: model.label ?? model.id, + ...(api ? { api } : {}), + reasoning: model.reasoning ?? false, + input: ["text"] as ("text" | "image")[], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS, + }; + }); +} + +/** + * Map Paseo-owned config into the in-memory inference providers the Pi seam expects. + * `openai-codex` entries carry an oauth marker instead of an API key. Their credential + * normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an + * advanced `options.refreshToken` may supply the user's own token instead. + */ +export function paseoAgentInferenceProviders( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv = process.env, +): PaseoAgentInferenceProvider[] { + return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => { + const settings = resolveProviderSettings(entry); + const models = toPiModels(entry, settings); + + if (entry.type === "openai-codex") { + const refreshToken = entry.options.refreshToken + ? resolveRefreshTokenExpression(entry.options.refreshToken, env) + : undefined; + return [ + { + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.api ? { api: settings.api } : {}), + models, + }, + oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, + }, + ]; + } + + return [ + { + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + ...(settings.api ? { api: settings.api } : {}), + ...(settings.headers ? { headers: settings.headers } : {}), + ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), + models, + }, + }, + ]; + }); +} + +/** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */ +export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] { + const models: AgentModelDefinition[] = []; + for (const [name, entry] of entries(config)) { + for (const model of entry.options.models) { + const id = encodePaseoAgentModelId(name, model.id); + models.push({ + provider: PASEO_AGENT_PROVIDER, + id, + label: model.label ?? model.id, + description: `${name} · ${model.id}`, + isDefault: config.defaultModel === id, + }); + } + } + return models; +} + +/** + * An inference provider is usable when it has at least one model and auth is configured. + * For API-key types that means a resolvable key (literal, set env var, or command). For + * `openai-codex`, auth comes from the Paseo-owned store (checked via `isOAuthAuthed`, + * keyed by provider instance name) or, as an advanced override, a resolvable + * `options.refreshToken`. + */ +export function paseoAgentHasUsableModel( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv = process.env, + isOAuthAuthed: (providerInstance: string) => boolean = () => false, +): boolean { + return entries(config).some(([name, entry]) => { + if (entry.options.models.length === 0) { + return false; + } + if (entry.type === "openai-codex") { + if ( + entry.options.refreshToken && + isRefreshTokenExpressionConfigured(entry.options.refreshToken, env) + ) { + return true; + } + return isOAuthAuthed(name); + } + return isAuthConfigured(resolveProviderSettings(entry).apiKey, env); + }); +} + +/** + * Resolve which Pi model to launch: the explicit request is honored as-is; implicit + * default selection only chooses models from the providers actually registered with Pi. + */ +export function resolvePaseoAgentModel( + config: PaseoAgentConfig, + requestedModelId: string | null | undefined, + registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), +): PaseoAgentModelReference | undefined { + if (requestedModelId) { + return parsePaseoAgentModelId(requestedModelId) ?? undefined; + } + + for (const candidate of [config.defaultModel, firstModelId(config)]) { + if (!candidate) { + continue; + } + const parsed = parsePaseoAgentModelId(candidate); + if (parsed && hasRegisteredModel(registeredProviders, parsed)) { + return parsed; + } + } + + return firstRegisteredModel(registeredProviders); +} + +function firstModelId(config: PaseoAgentConfig): string | undefined { + for (const [name, entry] of entries(config)) { + const first = entry.options.models[0]; + if (first) { + return encodePaseoAgentModelId(name, first.id); + } + } + return undefined; +} + +function hasRegisteredModel( + providers: PaseoAgentInferenceProvider[], + model: PaseoAgentModelReference, +): boolean { + return providers.some( + (provider) => + provider.name === model.provider && + provider.config.models?.some((registered) => registered.id === model.id), + ); +} + +function firstRegisteredModel( + providers: PaseoAgentInferenceProvider[], +): PaseoAgentModelReference | undefined { + for (const provider of providers) { + const first = provider.config.models?.[0]; + if (first) { + return { provider: provider.name, id: first.id }; + } + } + return undefined; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts new file mode 100644 index 0000000000..eac786db97 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + convertPromptInput, + mapToolDetail, + parseToolArgs, + parseToolResult, +} from "./event-mapping.js"; + +describe("parseToolArgs", () => { + it("classifies known built-in tools", () => { + expect(parseToolArgs("bash", { command: "ls" }).kind).toBe("bash"); + expect(parseToolArgs("read", { path: "/tmp/a" }).kind).toBe("read"); + expect(parseToolArgs("grep", { pattern: "foo" }).kind).toBe("grep"); + }); + + it("falls back to unknown for unrecognized tools or bad args", () => { + expect(parseToolArgs("mcp__paseo__do", { anything: 1 }).kind).toBe("unknown"); + expect(parseToolArgs("bash", { notACommand: true }).kind).toBe("unknown"); + }); + + it("accepts legacy edit args (old_string/new_string)", () => { + const call = parseToolArgs("edit", { + path: "/tmp/a", + old_string: "x", + new_string: "y", + }); + expect(call.kind).toBe("edit"); + if (call.kind === "edit") { + expect(call.args.edits[0]).toEqual({ oldText: "x", newText: "y" }); + } + }); +}); + +describe("mapToolDetail", () => { + it("maps a bash call with exit code and output", () => { + const call = parseToolArgs("bash", { command: "echo hi" }); + const detail = mapToolDetail(call, parseToolResult({ output: "hi", exitCode: 0 })); + expect(detail).toMatchObject({ type: "shell", command: "echo hi", output: "hi", exitCode: 0 }); + }); + + it("maps an edit call to a diff detail", () => { + const call = parseToolArgs("edit", { + path: "/tmp/a", + edits: [{ oldText: "a", newText: "b" }], + }); + const detail = mapToolDetail(call, parseToolResult({ details: { diff: "--- diff ---" } })); + expect(detail).toMatchObject({ + type: "edit", + filePath: "/tmp/a", + oldString: "a", + newString: "b", + unifiedDiff: "--- diff ---", + }); + }); + + it("maps unknown tools to a passthrough detail", () => { + const call = parseToolArgs("mystery", { foo: 1 }); + const detail = mapToolDetail(call, parseToolResult("done")); + expect(detail.type).toBe("unknown"); + }); +}); + +describe("convertPromptInput", () => { + it("passes a plain string through", () => { + expect(convertPromptInput("hello")).toEqual({ text: "hello" }); + }); + + it("joins text blocks and collects images", () => { + const payload = convertPromptInput([ + { type: "text", text: "one" }, + { type: "image", data: "base64", mimeType: "image/png" }, + { type: "text", text: "two" }, + ]); + expect(payload.text).toBe("one\n\ntwo"); + expect(payload.images).toEqual([{ type: "image", data: "base64", mimeType: "image/png" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts new file mode 100644 index 0000000000..5e308f7646 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts @@ -0,0 +1,349 @@ +import type { + BashToolInput, + EditToolInput, + FindToolInput, + GrepToolInput, + LsToolInput, + ReadToolInput, + SessionStats, + WriteToolInput, +} from "@earendil-works/pi-coding-agent"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import { z } from "zod"; + +import type { AgentPromptInput, AgentUsage, ToolCallDetail } from "../../agent-sdk-types.js"; +import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; + +// Pure event/tool/model mapping ported from the old direct Pi provider. These +// translate Pi's harness shapes into Paseo's provider-agnostic timeline types. +// Kept free of any session/runtime state so they can be unit-tested directly. + +export interface PiPromptPayload { + text: string; + images?: ImageContent[]; +} + +interface ToolCallOutputSummary { + output?: string; + exitCode?: number | null; +} + +interface PiBashToolCall { + kind: "bash"; + toolName: "bash"; + args: BashToolInput; +} +interface PiReadToolCall { + kind: "read"; + toolName: "read"; + args: ReadToolInput; +} +interface PiEditToolCall { + kind: "edit"; + toolName: "edit"; + args: EditToolInput; +} +interface PiWriteToolCall { + kind: "write"; + toolName: "write"; + args: WriteToolInput; +} +interface PiFindToolCall { + kind: "find"; + toolName: "find"; + args: FindToolInput; +} +interface PiGrepToolCall { + kind: "grep"; + toolName: "grep"; + args: GrepToolInput; +} +interface PiLsToolCall { + kind: "ls"; + toolName: "ls"; + args: LsToolInput; +} +interface PiUnknownToolCall { + kind: "unknown"; + toolName: string; + args: unknown; +} + +export type PiTrackedToolCall = + | PiBashToolCall + | PiReadToolCall + | PiEditToolCall + | PiWriteToolCall + | PiFindToolCall + | PiGrepToolCall + | PiLsToolCall + | PiUnknownToolCall; + +const PiToolResultTextContentSchema = z.object({ type: z.literal("text"), text: z.string() }); +const PiToolResultUnknownContentSchema = z.object({ type: z.string() }).passthrough(); +const PiToolResultContentSchema = z.union([ + PiToolResultTextContentSchema, + PiToolResultUnknownContentSchema, +]); +const PiToolResultObjectSchema = z + .object({ + output: z.string().optional(), + stdout: z.string().optional(), + text: z.string().optional(), + content: z.array(PiToolResultContentSchema).optional(), + exitCode: z.number().optional(), + code: z.number().optional(), + details: z.object({ diff: z.string().optional() }).passthrough().optional(), + }) + .passthrough(); +const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); + +type PiToolResult = z.infer; + +const BashToolInputSchema: z.ZodType = z.object({ + command: z.string(), + timeout: z.number().optional(), +}); +const ReadToolInputSchema: z.ZodType = z.object({ + path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), +}); +const EditToolInputSchema: z.ZodType = z.object({ + path: z.string(), + edits: z.array(z.object({ oldText: z.string(), newText: z.string() })), +}); +const LegacyEditToolInputSchema = z.object({ + path: z.string(), + old_string: z.string().optional(), + oldString: z.string().optional(), + new_string: z.string().optional(), + newString: z.string().optional(), +}); +const WriteToolInputSchema: z.ZodType = z.object({ + path: z.string(), + content: z.string(), +}); +const FindToolInputSchema: z.ZodType = z.object({ + pattern: z.string(), + path: z.string().optional(), + limit: z.number().optional(), +}); +const GrepToolInputSchema: z.ZodType = z.object({ + pattern: z.string(), + path: z.string().optional(), + glob: z.string().optional(), + ignoreCase: z.boolean().optional(), + literal: z.boolean().optional(), + context: z.number().optional(), + limit: z.number().optional(), +}); +const LsToolInputSchema: z.ZodType = z.object({ + path: z.string().optional(), + limit: z.number().optional(), +}); + +type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls"; +const SIMPLE_TOOL_SCHEMAS: { + [K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } }; +} = { + bash: BashToolInputSchema, + read: ReadToolInputSchema, + write: WriteToolInputSchema, + find: FindToolInputSchema, + grep: GrepToolInputSchema, + ls: LsToolInputSchema, +}; + +export function parseToolResult(rawResult: unknown): PiToolResult { + const parsed = PiToolResultSchema.safeParse(rawResult); + return parsed.success ? parsed.data : null; +} + +function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null { + const parsed = LegacyEditToolInputSchema.safeParse(rawArgs); + if (!parsed.success) { + return null; + } + const oldText = parsed.data.old_string ?? parsed.data.oldString; + const newText = parsed.data.new_string ?? parsed.data.newString; + if (!oldText || newText === undefined) { + return null; + } + return { path: parsed.data.path, edits: [{ oldText, newText }] }; +} + +function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall { + const parsed = EditToolInputSchema.safeParse(rawArgs); + if (parsed.success) { + return { kind: "edit", toolName: "edit", args: parsed.data }; + } + const legacyArgs = normalizeLegacyEditArgs(rawArgs); + if (legacyArgs) { + return { kind: "edit", toolName: "edit", args: legacyArgs }; + } + return { kind: "unknown", toolName: "edit", args: rawArgs ?? null }; +} + +export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall { + if (toolName === "edit") { + return parseEditToolArgs(rawArgs); + } + const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind]; + if (schema) { + const parsed = schema.safeParse(rawArgs); + if (parsed.success) { + return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall; + } + } + return { kind: "unknown", toolName, args: rawArgs ?? null }; +} + +export function extractTextFromToolResult(result: PiToolResult): string | undefined { + if (typeof result === "string") { + return result; + } + if (!result) { + return undefined; + } + const directText = result.output ?? result.stdout ?? result.text; + if (directText) { + return directText; + } + if (!result.content) { + return undefined; + } + const textParts: string[] = []; + for (const block of result.content) { + if (block.type === "text" && "text" in block) { + textParts.push(block.text as string); + } + } + return textParts.length > 0 ? textParts.join("\n") : undefined; +} + +function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary { + if (typeof result === "string") { + return { output: result }; + } + if (!result) { + return {}; + } + const summary: ToolCallOutputSummary = { output: extractTextFromToolResult(result) }; + if (typeof result.exitCode === "number") { + return { ...summary, exitCode: result.exitCode }; + } + if (typeof result.code === "number") { + return { ...summary, exitCode: result.code }; + } + return { ...summary, exitCode: null }; +} + +export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail { + const parsedResult = result ?? null; + switch (toolCall.kind) { + case "bash": { + const summary = resolveToolCallOutput(parsedResult); + return { + type: "shell", + command: toolCall.args.command, + output: summary.output, + exitCode: summary.exitCode, + }; + } + case "read": + return { + type: "read", + filePath: toolCall.args.path, + content: extractTextFromToolResult(parsedResult), + offset: toolCall.args.offset, + limit: toolCall.args.limit, + }; + case "edit": { + const firstEdit = toolCall.args.edits[0]; + const unifiedDiff = + parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined; + return { + type: "edit", + filePath: toolCall.args.path, + oldString: firstEdit?.oldText, + newString: firstEdit?.newText, + unifiedDiff, + }; + } + case "write": + return { type: "write", filePath: toolCall.args.path, content: toolCall.args.content }; + case "find": + return { + type: "search", + query: toolCall.args.pattern, + toolName: "search", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + case "grep": + return { + type: "search", + query: toolCall.args.pattern, + toolName: "grep", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + case "ls": + return { + type: "search", + query: toolCall.args.path ?? "ls", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + default: + return { type: "unknown", input: toolCall.args, output: parsedResult }; + } +} + +export function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload { + if (typeof prompt === "string") { + return { text: prompt }; + } + const textParts: string[] = []; + const images: ImageContent[] = []; + for (const block of prompt) { + if (block.type === "text") { + textParts.push(block.text); + continue; + } + if (block.type === "image") { + images.push({ type: "image", data: block.data, mimeType: block.mimeType }); + continue; + } + textParts.push(renderPromptAttachmentAsText(block)); + } + const payload: PiPromptPayload = { text: textParts.join("\n\n") }; + if (images.length > 0) { + payload.images = images; + } + return payload; +} + +export function toAgentUsage(stats: SessionStats): AgentUsage | undefined { + const inputTokens = stats.tokens.input; + const cachedInputTokens = stats.tokens.cacheRead; + const outputTokens = stats.tokens.output; + const totalCostUsd = stats.cost; + if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && totalCostUsd === 0) { + return undefined; + } + return { inputTokens, cachedInputTokens, outputTokens, totalCostUsd }; +} + +const PiTextContentSchema = z.object({ type: z.literal("text"), text: z.string() }); + +export function getUserMessageText(content: string | (TextContent | ImageContent)[]): string { + if (typeof content === "string") { + return content; + } + const textParts: string[] = []; + for (const block of content) { + if (PiTextContentSchema.safeParse(block).success) { + textParts.push((block as TextContent).text); + } + } + return textParts.join("\n\n"); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts new file mode 100644 index 0000000000..e66baad632 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { McpServerConfig } from "../../agent-sdk-types.js"; +import { + type McpCallToolResult, + type McpConnection, + type McpConnectionFactory, + type McpToolInfo, + createMcpToolBridge, + mapMcpToolContent, +} from "./mcp-bridge.js"; + +interface FakeConnectionSpec { + tools?: McpToolInfo[]; + listToolsError?: Error; + callResult?: McpCallToolResult; +} + +function fakeConnection(spec: FakeConnectionSpec) { + const calls: { toolName: string; args: Record }[] = []; + const closed = { value: false }; + const connection: McpConnection = { + async listTools() { + if (spec.listToolsError) { + throw spec.listToolsError; + } + return spec.tools ?? []; + }, + async callTool(toolName, args) { + calls.push({ toolName, args }); + return spec.callResult ?? { content: [{ type: "text", text: "ok" }] }; + }, + async close() { + closed.value = true; + }, + }; + return { connection, calls, closed }; +} + +const HTTP_SERVER: McpServerConfig = { type: "http", url: "https://example.test/mcp" }; + +describe("mapMcpToolContent", () => { + it("maps text and image blocks, notes other kinds", () => { + const content = mapMcpToolContent({ + content: [ + { type: "text", text: "hello" }, + { type: "image", data: "b64", mimeType: "image/png" }, + { type: "resource_link", uri: "file:///x" }, + { type: "audio", mimeType: "audio/wav" }, + ], + }); + expect(content).toEqual([ + { type: "text", text: "hello" }, + { type: "image", data: "b64", mimeType: "image/png" }, + { type: "text", text: "[resource file:///x]" }, + { type: "text", text: "[audio audio/wav]" }, + ]); + }); +}); + +describe("createMcpToolBridge", () => { + it("lists tools from each server as namespaced Pi tools", async () => { + const { connection } = fakeConnection({ + tools: [ + { name: "do_thing", description: "does a thing", inputSchema: { type: "object" } }, + { name: "other", inputSchema: { type: "object" } }, + ], + }); + const connect: McpConnectionFactory = async () => connection; + + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + + expect(bridge.tools.map((t) => t.name)).toEqual(["paseo__do_thing", "paseo__other"]); + expect(bridge.tools[0]?.description).toBe("does a thing"); + await bridge.close(); + }); + + it("proxies execute to callTool and maps the result", async () => { + const { connection, calls } = fakeConnection({ + tools: [{ name: "echo", inputSchema: { type: "object" } }], + callResult: { content: [{ type: "text", text: "pong" }] }, + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + const tool = bridge.tools[0]; + const result = await tool.execute("call-1", { msg: "ping" }, undefined, undefined, {} as never); + + expect(calls).toEqual([{ toolName: "echo", args: { msg: "ping" } }]); + expect(result.content).toEqual([{ type: "text", text: "pong" }]); + await bridge.close(); + }); + + it("throws from execute when the MCP result is an error", async () => { + const { connection } = fakeConnection({ + tools: [{ name: "boom", inputSchema: { type: "object" } }], + callResult: { isError: true, content: [{ type: "text", text: "kaboom" }] }, + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + await expect( + bridge.tools[0].execute("call-1", {}, undefined, undefined, {} as never), + ).rejects.toThrow(/kaboom/); + await bridge.close(); + }); + + it("skips a server whose listTools fails but still closes it on teardown", async () => { + const { connection, closed } = fakeConnection({ listToolsError: new Error("nope") }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + expect(bridge.tools).toHaveLength(0); + await bridge.close(); + expect(closed.value).toBe(true); + }); + + it("skips a server that fails to connect without throwing", async () => { + const connect = vi.fn(async () => { + throw new Error("connect failed"); + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + + expect(bridge.tools).toHaveLength(0); + expect(connect).toHaveBeenCalledTimes(1); + await bridge.close(); + }); + + it("closes every connection on teardown", async () => { + const a = fakeConnection({ tools: [{ name: "t", inputSchema: { type: "object" } }] }); + const b = fakeConnection({ tools: [{ name: "u", inputSchema: { type: "object" } }] }); + const connect: McpConnectionFactory = async (serverName) => + serverName === "a" ? a.connection : b.connection; + + const bridge = await createMcpToolBridge({ + mcpServers: { a: HTTP_SERVER, b: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + await bridge.close(); + + expect(a.closed.value).toBe(true); + expect(b.closed.value).toBe(true); + }); + + it("produces no tools when there are no MCP servers", async () => { + const bridge = await createMcpToolBridge({ logger: createTestLogger() }); + expect(bridge.tools).toHaveLength(0); + await bridge.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts new file mode 100644 index 0000000000..f1f0716b9b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts @@ -0,0 +1,245 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { Logger } from "pino"; + +import type { AgentToolResultLike, ToolDefinition } from "./pi-services.js"; +import type { McpServerConfig } from "../../agent-sdk-types.js"; +import { mcpInputSchemaToTypeBox } from "./mcp-schema.js"; + +// Provider-owned bridge that turns `AgentSessionConfig.mcpServers` into Pi +// `customTools`. It owns the MCP client lifecycle: connect on creation, expose tools, +// proxy execution, and tear down on session close. Network/transport construction is +// behind an injectable connection factory so the bridge is testable with a fake. + +const MCP_CLIENT_NAME = "paseo-agent"; +const MCP_CLIENT_VERSION = "0.1.0"; + +export interface McpToolInfo { + name: string; + description?: string; + inputSchema?: unknown; +} + +interface McpContentBlock { + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + resource?: { text?: string; uri?: string; mimeType?: string; blob?: string }; +} + +export interface McpCallToolResult { + content?: McpContentBlock[]; + isError?: boolean; + structuredContent?: unknown; +} + +/** A live connection to one MCP server. The default impl wraps the MCP SDK Client. */ +export interface McpConnection { + listTools(): Promise; + callTool(toolName: string, args: Record): Promise; + close(): Promise; +} + +export type McpConnectionFactory = ( + serverName: string, + config: McpServerConfig, +) => Promise; + +export interface McpToolBridge { + /** Pi custom tools for every successfully-listed MCP tool. */ + tools: ToolDefinition[]; + /** Close every MCP connection. Idempotent and best-effort. */ + close(): Promise; +} + +function buildTransport(config: McpServerConfig): Transport { + switch (config.type) { + case "http": + return new StreamableHTTPClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined, + ); + case "sse": + return new SSEClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined, + ); + case "stdio": + return new StdioClientTransport({ + command: config.command, + ...(config.args ? { args: config.args } : {}), + ...(config.env ? { env: config.env } : {}), + }); + } +} + +/** Default connection factory: a real MCP SDK client over the configured transport. */ +async function connectWithSdk( + _serverName: string, + config: McpServerConfig, +): Promise { + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { capabilities: {} }, + ); + await client.connect(buildTransport(config)); + return { + async listTools() { + const result = await client.listTools(); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + }, + async callTool(toolName, args) { + return (await client.callTool({ name: toolName, arguments: args })) as McpCallToolResult; + }, + async close() { + await client.close(); + }, + }; +} + +function mcpResultText(result: McpCallToolResult): string { + const parts: string[] = []; + for (const block of result.content ?? []) { + if (block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } else if (block.type === "resource" && typeof block.resource?.text === "string") { + parts.push(block.resource.text); + } + } + return parts.join("\n"); +} + +/** + * Map an MCP `CallToolResult` content array into Pi tool-result content. Text and image + * blocks map directly; other block kinds become a short text note so nothing is silently + * dropped. Pure and exported for testing. + */ +export function mapMcpToolContent(result: McpCallToolResult): (TextContent | ImageContent)[] { + const content: (TextContent | ImageContent)[] = []; + for (const block of result.content ?? []) { + switch (block.type) { + case "text": + content.push({ type: "text", text: block.text ?? "" }); + break; + case "image": + if (block.data && block.mimeType) { + content.push({ type: "image", data: block.data, mimeType: block.mimeType }); + } + break; + case "audio": + content.push({ + type: "text", + text: `[audio${block.mimeType ? ` ${block.mimeType}` : ""}]`, + }); + break; + case "resource": + if (typeof block.resource?.text === "string") { + content.push({ type: "text", text: block.resource.text }); + } else if (block.resource?.uri) { + content.push({ type: "text", text: `[resource ${block.resource.uri}]` }); + } + break; + case "resource_link": + if (block.uri) { + content.push({ type: "text", text: `[resource ${block.uri}]` }); + } + break; + default: + content.push({ type: "text", text: JSON.stringify(block) }); + } + } + return content; +} + +function buildToolDefinition( + serverName: string, + info: McpToolInfo, + connection: McpConnection, +): ToolDefinition { + const toolName = `${serverName}__${info.name}`; + const description = info.description ?? info.name; + const definition: ToolDefinition = { + name: toolName, + label: info.name, + description, + promptSnippet: description, + parameters: mcpInputSchemaToTypeBox(info.inputSchema), + async execute(_toolCallId, params) { + const args = (params ?? {}) as Record; + const result = await connection.callTool(info.name, args); + if (result.isError) { + // Throwing makes Pi mark the tool call failed and surface the message. + throw new Error(mcpResultText(result) || `MCP tool "${info.name}" reported an error`); + } + const toolResult: AgentToolResultLike = { + content: mapMcpToolContent(result), + details: result.structuredContent ?? null, + }; + return toolResult; + }, + }; + return definition; +} + +/** + * Connect to every configured MCP server, list its tools, and produce Pi custom tools. + * Servers that fail to connect or list are logged and skipped rather than failing the + * whole session. Call `close()` on session teardown. + */ +export async function createMcpToolBridge(options: { + mcpServers?: Record; + logger: Logger; + connect?: McpConnectionFactory; +}): Promise { + const connect = options.connect ?? connectWithSdk; + const connections: McpConnection[] = []; + const tools: ToolDefinition[] = []; + + for (const [serverName, serverConfig] of Object.entries(options.mcpServers ?? {})) { + let connection: McpConnection; + try { + connection = await connect(serverName, serverConfig); + } catch (error) { + options.logger.warn({ err: error, mcpServer: serverName }, "Paseo Agent: MCP connect failed"); + continue; + } + connections.push(connection); + + try { + const toolInfos = await connection.listTools(); + for (const info of toolInfos) { + tools.push(buildToolDefinition(serverName, info, connection)); + } + } catch (error) { + options.logger.warn( + { err: error, mcpServer: serverName }, + "Paseo Agent: MCP listTools failed", + ); + } + } + + return { + tools, + async close() { + await Promise.all( + connections.map((connection) => + connection + .close() + .catch((error) => + options.logger.warn({ err: error }, "Paseo Agent: MCP connection close failed"), + ), + ), + ); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts new file mode 100644 index 0000000000..d918080b84 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts @@ -0,0 +1,77 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; + +import { mcpInputSchemaToTypeBox } from "./mcp-schema.js"; + +describe("mcpInputSchemaToTypeBox", () => { + it("converts an object schema with required and optional properties", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { a: { type: "string" }, b: { type: "number" } }, + required: ["a"], + }); + expect(Value.Check(schema, { a: "x" })).toBe(true); + expect(Value.Check(schema, { a: "x", b: 1 })).toBe(true); + expect(Value.Check(schema, { b: 1 })).toBe(false); // missing required "a" + expect(Value.Check(schema, { a: 1 })).toBe(false); // wrong type for "a" + }); + + it("converts enums to a closed set", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { mode: { type: "string", enum: ["read", "write"] } }, + required: ["mode"], + }); + expect(Value.Check(schema, { mode: "read" })).toBe(true); + expect(Value.Check(schema, { mode: "delete" })).toBe(false); + }); + + it("converts arrays with typed items", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { tags: { type: "array", items: { type: "number" } } }, + required: ["tags"], + }); + expect(Value.Check(schema, { tags: [1, 2] })).toBe(true); + expect(Value.Check(schema, { tags: ["x"] })).toBe(false); + }); + + it("converts nested objects", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { + filter: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + required: ["filter"], + }); + expect(Value.Check(schema, { filter: { name: "a" } })).toBe(true); + expect(Value.Check(schema, { filter: {} })).toBe(false); + }); + + it("accepts any object when the schema is missing or empty", () => { + expect(Value.Check(mcpInputSchemaToTypeBox(undefined), { anything: 1 })).toBe(true); + expect(Value.Check(mcpInputSchemaToTypeBox({}), { anything: 1 })).toBe(true); + }); + + it("treats a property-only schema (no declared type) as an object", () => { + const schema = mcpInputSchemaToTypeBox({ + properties: { q: { type: "string" } }, + required: ["q"], + }); + expect(Value.Check(schema, { q: "hi" })).toBe(true); + expect(Value.Check(schema, {})).toBe(false); + }); + + it("preserves descriptions", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + description: "the tool input", + properties: {}, + }) as Record; + expect(schema.description).toBe("the tool input"); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts new file mode 100644 index 0000000000..61d78104b9 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts @@ -0,0 +1,161 @@ +import { Type, type TSchema } from "@earendil-works/pi-ai"; + +// JSON-Schema → TypeBox conversion for MCP tool input schemas. MCP advertises tool +// parameters as JSON Schema; Pi tool definitions expect a TypeBox `TSchema`. This +// covers the shapes common MCP servers emit (objects, primitives, arrays, enums, +// unions, required/optional, additionalProperties) and falls back to a permissive +// `Type.Unknown()` for anything it doesn't recognise, so unusual schemas degrade to +// "accept anything" rather than throwing. + +type JsonSchemaRecord = Record; + +// Index signature lets these annotations satisfy TypeBox's option types directly. +interface SchemaAnnotations { + [key: PropertyKey]: unknown; + description?: string; + default?: unknown; + title?: string; +} + +function isRecord(value: unknown): value is JsonSchemaRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function annotationsOf(schema: JsonSchemaRecord): SchemaAnnotations { + const annotations: SchemaAnnotations = {}; + if (typeof schema.description === "string") { + annotations.description = schema.description; + } + if (typeof schema.title === "string") { + annotations.title = schema.title; + } + if ("default" in schema) { + annotations.default = schema.default; + } + return annotations; +} + +function literalOf(value: unknown): TSchema { + if (value === null) { + return Type.Null(); + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return Type.Literal(value); + } + // Non-literal enum members (objects/arrays) can't be TypeBox literals. + return Type.Unknown(); +} + +function unionOf(members: TSchema[], annotations: SchemaAnnotations): TSchema { + if (members.length === 0) { + return Type.Unknown(annotations); + } + if (members.length === 1) { + return members[0]; + } + return Type.Union(members, annotations); +} + +function convertByType( + type: string, + schema: JsonSchemaRecord, + annotations: SchemaAnnotations, +): TSchema { + switch (type) { + case "string": + return Type.String(annotations); + case "number": + return Type.Number(annotations); + case "integer": + return Type.Integer(annotations); + case "boolean": + return Type.Boolean(annotations); + case "null": + return Type.Null(annotations); + case "array": { + const items = isRecord(schema.items) ? convertSchema(schema.items) : Type.Unknown(); + return Type.Array(items, annotations); + } + case "object": + return convertObject(schema, annotations); + default: + return Type.Unknown(annotations); + } +} + +function convertObject(schema: JsonSchemaRecord, annotations: SchemaAnnotations): TSchema { + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : [], + ); + + const fields: Record = {}; + for (const [key, propSchema] of Object.entries(properties)) { + const converted = isRecord(propSchema) ? convertSchema(propSchema) : Type.Unknown(); + fields[key] = required.has(key) ? converted : Type.Optional(converted); + } + + const objectOptions: Record = { ...annotations }; + const additional = schema.additionalProperties; + if (additional === false || additional === true) { + objectOptions.additionalProperties = additional; + } else if (isRecord(additional)) { + objectOptions.additionalProperties = convertSchema(additional); + } + + return Type.Object(fields, objectOptions); +} + +function convertSchema(schema: JsonSchemaRecord): TSchema { + const annotations = annotationsOf(schema); + + if (Array.isArray(schema.enum)) { + return unionOf(schema.enum.map(literalOf), annotations); + } + + const composite = schema.anyOf ?? schema.oneOf; + if (Array.isArray(composite)) { + const members = composite.filter(isRecord).map(convertSchema); + return unionOf(members, annotations); + } + + if (Array.isArray(schema.type)) { + const members = schema.type + .filter((t): t is string => typeof t === "string") + .map((t) => convertByType(t, schema, annotations)); + return unionOf(members, annotations); + } + + if (typeof schema.type === "string") { + return convertByType(schema.type, schema, annotations); + } + + // No declared type: treat as an object when properties are present, else accept anything. + if (isRecord(schema.properties)) { + return convertObject(schema, annotations); + } + + return Type.Unknown(annotations); +} + +/** + * Convert an MCP tool `inputSchema` (JSON Schema) into a TypeBox schema for a Pi tool + * definition. Always returns an object schema at the top level so tool parameters are + * a well-formed object, even when the server omits or malforms the schema. + */ +export function mcpInputSchemaToTypeBox(inputSchema: unknown): TSchema { + if (!isRecord(inputSchema)) { + return Type.Object({}, { additionalProperties: true }); + } + const { type } = inputSchema; + // MCP tool parameters are objects. Convert object schemas faithfully; for a bare + // schema with neither a declared object type nor properties, accept any object. + if (type === "object" || isRecord(inputSchema.properties)) { + return convertObject(inputSchema, annotationsOf(inputSchema)); + } + // No declared type, or a non-object top-level type (unusual for tool params): + // accept any object so the tool stays callable. + return Type.Object({}, { additionalProperties: true }); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts new file mode 100644 index 0000000000..d8e65b5e6b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + isRefreshTokenExpressionConfigured, + resolveRefreshTokenExpression, +} from "./oauth-credentials.js"; + +describe("resolveRefreshTokenExpression", () => { + it("returns a literal value", () => { + expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal"); + }); + + it("resolves an env reference and returns undefined when unset", () => { + expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env"); + expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env"); + expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined(); + }); + + it("runs a !command and returns its trimmed output", () => { + expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd"); + expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined(); + }); +}); + +describe("isRefreshTokenExpressionConfigured", () => { + it("is true for a literal and a set env var, false for an unset env var", () => { + expect(isRefreshTokenExpressionConfigured("rt", {})).toBe(true); + expect(isRefreshTokenExpressionConfigured("$RT", { RT: "x" })).toBe(true); + expect(isRefreshTokenExpressionConfigured("$RT", {})).toBe(false); + }); + + it("assumes a !command is runnable without executing it", () => { + expect(isRefreshTokenExpressionConfigured("!exit 1", {})).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts new file mode 100644 index 0000000000..e4d310389b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -0,0 +1,71 @@ +import { execSync } from "node:child_process"; + +// Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env +// reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an +// advanced/manual escape hatch for users who already hold their own ChatGPT/Codex +// refresh token; the product path is `paseo login chatgpt`, which performs browser +// OAuth by default and writes a Paseo-owned credential store (see oauth-store.ts). +// +// This module deliberately does NOT read any other tool's auth files (Codex CLI, +// OpenCode, Pi, etc.) and imports no Pi runtime code. Token values are never logged. + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; +const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/; + +/** Substitute `$VAR` / `${VAR}` references. Returns undefined if any var is unset. */ +function substituteEnv(value: string, env: NodeJS.ProcessEnv): string | undefined { + let missing = false; + const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => { + const resolved = env[name]; + if (resolved === undefined) { + missing = true; + return ""; + } + return resolved; + }); + return missing ? undefined : result; +} + +/** + * Resolve a refresh-token expression to its literal value (may run a `!command`). + * Returns undefined when it can't be resolved. + */ +export function resolveRefreshTokenExpression( + value: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (value.startsWith("!")) { + const command = value.slice(1).trim(); + if (!command) { + return undefined; + } + try { + const output = execSync(command, { encoding: "utf8", env }).trim(); + return output.length > 0 ? output : undefined; + } catch { + return undefined; + } + } + if (ENV_REFERENCE_DETECT.test(value)) { + const substituted = substituteEnv(value, env); + return substituted && substituted.length > 0 ? substituted : undefined; + } + return value.length > 0 ? value : undefined; +} + +/** + * Cheap check: could this refresh-token expression yield a value without running a + * command? `!command` is assumed runnable; env refs require their vars to be set. + */ +export function isRefreshTokenExpressionConfigured( + value: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (value.startsWith("!")) { + return true; + } + if (ENV_REFERENCE_DETECT.test(value)) { + return substituteEnv(value, env) !== undefined; + } + return value.length > 0; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts new file mode 100644 index 0000000000..c3d0e6a270 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + hasStoredOAuthCredential, + loginAndStoreCodex, + loginAndStoreCodexBrowser, + paseoAgentAuthStoragePath, +} from "./oauth-store.js"; + +describe("oauth-store", () => { + let home: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-")); + env = { PASEO_HOME: home }; + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("derives the store path from PASEO_HOME", () => { + expect(paseoAgentAuthStoragePath(env)).toBe(join(home, "paseo-agent", "auth.json")); + }); + + it("reports no stored credential before login", () => { + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); + }); + + it("runs the Pi login helper and persists a Paseo-owned credential", async () => { + const loginCalls: string[] = []; + const deviceCodes: unknown[] = []; + const login = async (opts: { onDeviceCode: (info: unknown) => void }) => { + loginCalls.push("called"); + opts.onDeviceCode({ + userCode: "ABCD-EFGH", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + return { refresh: "rt-from-login", access: "ac", expires: 123, accountId: "acct" }; + }; + + const { path } = await loginAndStoreCodex({ + providerInstance: "chatgpt", + env, + onDeviceCode: (info) => deviceCodes.push(info), + login, + }); + + expect(loginCalls).toEqual(["called"]); + expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]); + expect(path).toBe(join(home, "paseo-agent", "auth.json")); + + // Credential persisted to the Paseo-owned store (not any foreign file). + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + const stored = JSON.parse(readFileSync(path, "utf8")); + expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-login" }); + + // Stored private (0600). + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("keys the credential by provider instance name", async () => { + const login = async () => ({ refresh: "rt", access: "", expires: 0 }); + await loginAndStoreCodex({ + providerInstance: "work-chatgpt", + env, + onDeviceCode: () => {}, + login, + }); + expect(hasStoredOAuthCredential("work-chatgpt", env)).toBe(true); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); + }); + + it("browser login surfaces the auth URL and persists a Paseo-owned credential", async () => { + const authUrls: Array<[string, string | undefined]> = []; + const loginCalls: string[] = []; + // Fake Pi browser-login helper: emits an auth URL, returns credentials. + const login = async (opts: { onAuth: (info: { url: string }) => void }) => { + loginCalls.push("called"); + opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" }); + return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" }; + }; + + const { path } = await loginAndStoreCodexBrowser({ + providerInstance: "chatgpt", + env, + onAuthUrl: (url, instructions) => authUrls.push([url, instructions]), + login, + }); + + expect(loginCalls).toEqual(["called"]); + expect(authUrls).toEqual([["https://auth.openai.com/oauth/authorize?x=1", undefined]]); + expect(path).toBe(join(home, "paseo-agent", "auth.json")); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + const stored = JSON.parse(readFileSync(path, "utf8")); + expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-browser" }); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("browser login falls back to manual code entry only when the callback can't complete", async () => { + const prompts: string[] = []; + const promptForCode = async (message: string) => { + prompts.push(message); + return "pasted-code"; + }; + // Fake helper that cannot complete via callback and invokes onPrompt. + const login = async (opts: { onPrompt: (p: { message: string }) => Promise }) => { + const code = await opts.onPrompt({ message: "Paste the code:" }); + expect(code).toBe("pasted-code"); + return { refresh: "rt-manual", access: "", expires: 0 }; + }; + + await loginAndStoreCodexBrowser({ + providerInstance: "chatgpt", + env, + onAuthUrl: () => {}, + promptForCode, + login, + }); + + expect(prompts).toEqual(["Paste the code:"]); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts new file mode 100644 index 0000000000..596aa354c6 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { OAuthCredentials } from "@earendil-works/pi-ai"; +import { loginOpenAICodex, loginOpenAICodexDeviceCode } from "@earendil-works/pi-ai/oauth"; + +// Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live +// in a Paseo-controlled file (NOT ~/.pi, ~/.codex, OpenCode, or any other tool's +// store) and are managed through Pi's own AuthStorage, so Pi refreshes tokens and +// persists rotation back into Paseo's file. The login flows reuse Pi's OAuth helpers +// (browser PKCE/callback by default, device-code as a headless fallback) — Paseo does +// not reimplement the OAuth protocol. + +export interface CodexDeviceCodeInfo { + userCode: string; + verificationUri: string; + intervalSeconds: number; + expiresInSeconds: number; +} + +type DeviceCodeLogin = (options: { + onDeviceCode: (info: CodexDeviceCodeInfo) => void; + signal?: AbortSignal; +}) => Promise; + +type BrowserLogin = (options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: { message: string }) => Promise; + onProgress?: (message: string) => void; +}) => Promise; + +/** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ +export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { + const base = env.PASEO_HOME ?? join(homedir(), ".paseo"); + return join(base, "paseo-agent", "auth.json"); +} + +/** + * Pi AuthStorage backed by the Paseo-owned file. Pi creates the parent dir (0700) and + * the file (0600) and re-chmods on every write, so refreshed tokens stay private. + */ +export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env): AuthStorage { + return AuthStorage.create(paseoAgentAuthStoragePath(env)); +} + +/** + * Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists + * for a provider instance. Used for availability without constructing AuthStorage. + */ +export function hasStoredOAuthCredential( + providerInstance: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const path = paseoAgentAuthStoragePath(env); + if (!existsSync(path)) { + return false; + } + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof parsed !== "object" || parsed === null) { + return false; + } + const entry = (parsed as Record)[providerInstance]; + return ( + typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "oauth" + ); + } catch { + return false; + } +} + +/** + * Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential + * into the Paseo-owned store under `providerInstance`. The `login` dependency defaults + * to Pi's helper and is injectable for tests (no network). Never reads foreign files. + */ +export async function loginAndStoreCodex(options: { + providerInstance: string; + onDeviceCode: (info: CodexDeviceCodeInfo) => void; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + login?: DeviceCodeLogin; +}): Promise<{ path: string }> { + const login = options.login ?? (loginOpenAICodexDeviceCode as DeviceCodeLogin); + const credentials = await login({ onDeviceCode: options.onDeviceCode, signal: options.signal }); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); + return { path }; +} + +/** + * Run Pi's ChatGPT/Codex **browser** OAuth login (PKCE + local callback on + * 127.0.0.1:1455) and persist the resulting credential into the Paseo-owned store. + * This is the default, first-class login UX. `onAuthUrl` receives the authorization + * URL (the caller opens it / prints it); `promptForCode` is a fallback used only if + * the browser callback can't complete (manual code paste). The `login` dependency + * defaults to Pi's helper and is injectable for tests. Never reads foreign files. + */ +export async function loginAndStoreCodexBrowser(options: { + providerInstance: string; + onAuthUrl: (url: string, instructions?: string) => void; + promptForCode?: (message: string) => Promise; + onProgress?: (message: string) => void; + env?: NodeJS.ProcessEnv; + login?: BrowserLogin; +}): Promise<{ path: string }> { + const login = options.login ?? (loginOpenAICodex as BrowserLogin); + const credentials = await login({ + onAuth: (info) => options.onAuthUrl(info.url, info.instructions), + onProgress: options.onProgress, + onPrompt: async (prompt) => { + if (!options.promptForCode) { + throw new Error("Browser login did not complete and no manual code entry was available."); + } + return options.promptForCode(prompt.message); + }, + }); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); + return { path }; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts new file mode 100644 index 0000000000..4b396f8a5b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -0,0 +1,205 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + type CreatePaseoAgentSessionOptions, + type PaseoAgentInferenceProvider, + createPaseoAgentSession, +} from "./pi-services.js"; + +function codexInferenceProvider(): PaseoAgentInferenceProvider { + return { + name: "chatgpt", + config: { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [ + { + id: "gpt-5.3-codex", + name: "gpt-5.3-codex", + api: "openai-codex-responses", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }; +} + +const FAKE_PROVIDER = "paseo-test-openrouter"; +const FAKE_MODEL_ID = "test-model"; + +function fakeInferenceProvider(): PaseoAgentInferenceProvider { + return { + name: FAKE_PROVIDER, + config: { + baseUrl: "https://example.invalid/v1", + apiKey: "sk-in-memory-only", + api: "openai-completions", + models: [ + { + id: FAKE_MODEL_ID, + name: "Paseo Test Model", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }; +} + +describe("createPaseoAgentSession (no-discovery spike)", () => { + let cwd: string; + let agentDir: string; + let fakeHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "paseo-agent-cwd-")); + agentDir = join(mkdtempSync(join(tmpdir(), "paseo-agent-dir-")), "agent"); + fakeHome = mkdtempSync(join(tmpdir(), "paseo-agent-home-")); + // Redirect HOME so any accidental ~/.pi discovery would land in fakeHome and be detectable. + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + for (const dir of [cwd, fakeHome, agentDir]) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function baseOptions(): CreatePaseoAgentSessionOptions { + return { + cwd, + agentDir, + inferenceProviders: [fakeInferenceProvider()], + model: { provider: FAKE_PROVIDER, id: FAKE_MODEL_ID }, + }; + } + + it("creates a session from an in-memory inference provider and selects its model", async () => { + const { session, modelRegistry } = await createPaseoAgentSession(baseOptions()); + + expect(session).toBeDefined(); + expect(session.model?.provider).toBe(FAKE_PROVIDER); + expect(session.model?.id).toBe(FAKE_MODEL_ID); + // The in-memory model is the only one reachable with configured auth. + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === FAKE_PROVIDER && m.id === FAKE_MODEL_ID)).toBe( + true, + ); + }); + + it("performs no Pi resource discovery", async () => { + const { resourceLoader } = await createPaseoAgentSession(baseOptions()); + + expect(resourceLoader.getSkills().skills).toHaveLength(0); + expect(resourceLoader.getExtensions().extensions).toHaveLength(0); + expect(resourceLoader.getPrompts().prompts).toHaveLength(0); + }); + + it("uses an in-memory session manager with no on-disk session file", async () => { + const { sessionManager } = await createPaseoAgentSession(baseOptions()); + + expect(sessionManager.getSessionFile()).toBeUndefined(); + }); + + it("touches no ~/.pi config and writes nothing to the isolated agentDir", async () => { + await createPaseoAgentSession(baseOptions()); + + // No discovery against the redirected home directory: if Pi resolved its + // default agentDir (~/.pi/agent) it would create or read it under fakeHome. + expect(existsSync(join(fakeHome, ".pi"))).toBe(false); + // Nothing persisted to the Paseo-owned isolated agentDir. + const agentDirContents = existsSync(agentDir) ? readdirSync(agentDir) : []; + expect(agentDirContents).toHaveLength(0); + // No session/auth/model files leaked into the cwd either. + expect(existsSync(join(cwd, ".pi"))).toBe(false); + }); + + it("rejects a model that no inference provider registered", async () => { + await expect( + createPaseoAgentSession({ + ...baseOptions(), + inferenceProviders: [], + }), + ).rejects.toThrow(/not registered/); + }); + + it("activates supplied custom tools alongside the built-in tools", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + }); + + const active = session.getActiveToolNames(); + expect(active).toContain("paseo__demo"); + // Built-in tools remain active too. + expect(active).toContain("bash"); + }); + + it("registers a codex provider and seeds the advanced refresh-token override", async () => { + const codex = codexInferenceProvider(); + const { session, modelRegistry } = await createPaseoAgentSession({ + cwd, + agentDir, + model: { provider: "chatgpt", id: "gpt-5.3-codex" }, + inferenceProviders: [ + { ...codex, oauth: { kind: "openai-codex", refreshToken: "rt-test-only" } }, + ], + }); + + expect(session.model?.provider).toBe("chatgpt"); + expect(modelRegistry.find("chatgpt", "gpt-5.3-codex")?.api).toBe("openai-codex-responses"); + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + }); + + it("loads a codex credential from a Paseo-owned AuthStorage (product path)", async () => { + // Simulate the result of `paseo login chatgpt`: a credential already in the store. + const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json"); + const authStorage = AuthStorage.create(authPath); + authStorage.set("chatgpt", { type: "oauth", access: "", refresh: "rt-stored", expires: 0 }); + + const { modelRegistry } = await createPaseoAgentSession({ + cwd, + agentDir, + authStorage, + model: { provider: "chatgpt", id: "gpt-5.3-codex" }, + // No oauth.refreshToken marker — the credential comes from the Paseo store. + inferenceProviders: [{ ...codexInferenceProvider(), oauth: { kind: "openai-codex" } }], + }); + + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + rmSync(authPath, { force: true }); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts new file mode 100644 index 0000000000..a48d8f8379 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -0,0 +1,195 @@ +import { + AuthStorage, + type AgentSession as PiAgentSession, + DefaultResourceLoader, + ModelRegistry, + type ResourceLoader, + SessionManager, + SettingsManager, + type ToolDefinition, + createAgentSession, +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; + +// Re-export the Pi tool contract so the MCP bridge can build custom tools without +// importing the Pi SDK type names itself. +export type { ToolDefinition }; + +/** Shape a Pi custom tool's `execute` must return (subset of Pi's AgentToolResult). */ +export interface AgentToolResultLike { + content: (TextContent | ImageContent)[]; + details: unknown; + terminate?: boolean; +} + +// The single seam between Paseo and Pi's in-process harness. Every `@earendil-works/*` +// import and all no-discovery service construction lives here so the rest of the +// Paseo Agent provider never touches Pi's disk-backed config, auth, or sessions. + +// ProviderConfigInput is not re-exported from the package index, so derive it from +// the public `registerProvider` signature. +export type PiProviderConfig = Parameters[1]; +type PiAuthData = Parameters[0]; +type PiSettings = Parameters[0]; + +/** OAuth wiring for an inference provider (currently only ChatGPT/Codex). */ +export interface PaseoAgentOAuth { + kind: "openai-codex"; + /** + * Advanced/manual override: an already-resolved refresh token to seed into the auth + * store. Omitted on the product path, where the credential already lives in the + * Paseo-owned store (populated by login). + */ + refreshToken?: string; +} + +export interface PaseoAgentInferenceProvider { + /** Instance name, e.g. "openrouter-main". Used as the Pi provider key. */ + name: string; + /** Typed Pi provider config: baseUrl, apiKey, models, api, etc. */ + config: PiProviderConfig; + /** When present, register an OAuth provider and seed an in-memory credential. */ + oauth?: PaseoAgentOAuth; +} + +export interface PaseoAgentModelReference { + provider: string; + id: string; +} + +export interface CreatePaseoAgentSessionOptions { + /** Working directory for the agent. */ + cwd: string; + /** + * Isolated, Paseo-owned global config directory. Never `~/.pi`. Used only to + * satisfy Pi's path math; all services below are in-memory so nothing is read + * from or written to it during creation. + */ + agentDir: string; + /** Inference providers (model backends) registered entirely in memory. */ + inferenceProviders: PaseoAgentInferenceProvider[]; + /** Explicit model selection. When omitted, Pi falls back to its own resolution. */ + model?: PaseoAgentModelReference; + thinkingLevel?: ThinkingLevel; + /** In-memory credential seed, if any provider auth is keyed by AuthStorage. */ + auth?: PiAuthData; + /** + * Pi AuthStorage to use. Defaults to a fresh in-memory store. The Paseo Agent + * provider passes a file-backed, Paseo-owned store for OAuth providers so Pi can + * refresh tokens and persist rotation. Any oauth markers carrying a refresh token + * are still seeded into whichever store is used. + */ + authStorage?: AuthStorage; + /** In-memory settings overrides. Empty by default. */ + settings?: PiSettings; + /** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */ + customTools?: ToolDefinition[]; +} + +export interface PaseoAgentSessionHandle { + session: PiAgentSession; + modelRegistry: ModelRegistry; + resourceLoader: ResourceLoader; + sessionManager: SessionManager; +} + +/** + * Build a fully Paseo-controlled Pi `ResourceLoader` that performs no discovery. + * + * Discovery only happens inside `reload()`; the constructor initialises valid empty + * state. We never call `reload()`, and the `no*` flags ensure that even an accidental + * reload would not scan `~/.pi`, the project, or the cwd. + */ +function createNoDiscoveryResourceLoader(options: { + cwd: string; + agentDir: string; + settingsManager: SettingsManager; +}): ResourceLoader { + return new DefaultResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager: options.settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); +} + +/** + * Create a Pi agent session through the high-level `createAgentSession` API with + * every service supplied in-memory and no Pi config discovery. + */ +export async function createPaseoAgentSession( + options: CreatePaseoAgentSessionOptions, +): Promise { + // Use the caller's Paseo-owned store when provided (so Pi refreshes + persists token + // rotation there), else a fresh in-memory store. + const authStorage = options.authStorage ?? AuthStorage.inMemory({ ...options.auth }); + + // Seed any oauth marker that carries a refresh token (the advanced/manual override). + // The product path leaves this empty — the credential is already in the Paseo store. + // Empty `access` + `expires: 0` forces a refresh on the first request. + for (const provider of options.inferenceProviders) { + if (provider.oauth?.kind === "openai-codex" && provider.oauth.refreshToken) { + authStorage.set(provider.name, { + type: "oauth", + access: "", + refresh: provider.oauth.refreshToken, + expires: 0, + }); + } + } + + const modelRegistry = ModelRegistry.inMemory(authStorage); + + for (const provider of options.inferenceProviders) { + const config = + provider.oauth?.kind === "openai-codex" + ? { ...provider.config, oauth: openaiCodexOAuthProvider } + : provider.config; + modelRegistry.registerProvider(provider.name, config); + } + + const settingsManager = SettingsManager.inMemory(options.settings ?? {}); + const sessionManager = SessionManager.inMemory(options.cwd); + const resourceLoader = createNoDiscoveryResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + }); + + const model = options.model + ? modelRegistry.find(options.model.provider, options.model.id) + : undefined; + if (options.model && !model) { + throw new Error( + `Paseo Agent: model ${options.model.provider}/${options.model.id} is not registered by any inference provider`, + ); + } + + const { session } = await createAgentSession({ + cwd: options.cwd, + agentDir: options.agentDir, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + resourceLoader, + ...(model ? { model } : {}), + ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}), + ...(options.customTools ? { customTools: options.customTools } : {}), + }); + + // Custom (MCP) tools are registered but not active by default — only the built-in + // tool set is. Activate them so the model can actually call them. + if (options.customTools && options.customTools.length > 0) { + const customToolNames = options.customTools.map((tool) => tool.name); + session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]); + } + + return { session, modelRegistry, resourceLoader, sessionManager }; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0534af4db6..f2eab3e0a8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -144,6 +144,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; +import type { PaseoAgentConfig } from "./agent/providers/paseo-agent/config.js"; import type { PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; @@ -373,6 +374,7 @@ export interface PaseoDaemonConfig { }>; }; providerOverrides?: Record; + paseoAgentConfig?: PaseoAgentConfig; log?: PersistedConfig["log"]; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; pushNotificationSender?: PushNotificationSender; @@ -739,6 +741,7 @@ export async function createPaseoDaemon( logger: providerSnapshotLogger, runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, + paseoAgentConfig: config.paseoAgentConfig, workspaceGitService, managedProcesses, isDev: config.isDev === true, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index be9e68efc0..a8a245dbd4 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -505,6 +505,7 @@ export function loadConfig( agentProviderSettings: extractAgentProviderSettings(providerOverrides), metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, + paseoAgentConfig: persisted.agents?.paseo, log: resolveLogConfigFromEnv(env, persisted), }; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..221a4b7da2 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -48,6 +48,14 @@ export { type SherpaLoaderEnvResolution, } from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; +// Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store +export { + loginAndStoreCodexBrowser, + loginAndStoreCodex, + paseoAgentAuthStoragePath, + type CodexDeviceCodeInfo, +} from "./agent/providers/paseo-agent/oauth-store.js"; + // Provider binary resolution export { type ProviderOverride, diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index da846342d1..714322fd59 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -241,6 +241,30 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { ], }); }); + + test("accepts the dedicated agents.paseo inference provider config", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + paseo: { + defaultModel: "openrouter-main/anthropic/claude", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "anthropic/claude", label: "Claude" }], + }, + }, + }, + }, + }, + }); + + expect(parsed.agents?.paseo?.defaultModel).toBe("openrouter-main/anthropic/claude"); + expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter"); + }); }); describe("provider overrides (new format)", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5bc4f3cc96..668302542e 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -8,6 +8,7 @@ import { ProviderOverridesSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { PaseoAgentConfigSchema } from "./agent/providers/paseo-agent/config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; @@ -299,6 +300,7 @@ export const PersistedConfigSchema = z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), + paseo: PaseoAgentConfigSchema.optional(), }) .strict() .optional(), diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 41e2aceb6a..8e9e3e5994 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -321,6 +321,104 @@ }, "additionalProperties": false } + }, + "paseo": { + "type": "object", + "properties": { + "defaultModel": { + "type": "string", + "minLength": 1 + }, + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom" + ] + }, + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "format": "uri" + }, + "api": { + "type": "string", + "minLength": 1 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "authHeader": { + "type": "boolean" + }, + "refreshToken": { + "type": "string", + "minLength": 1 + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "api": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "type": "boolean" + }, + "contextWindow": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxTokens": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["id"], + "additionalProperties": false + }, + "minItems": 1 + } + }, + "required": ["models"], + "additionalProperties": false + } + }, + "required": ["type", "options"], + "additionalProperties": false + } + } + }, + "additionalProperties": false } }, "additionalProperties": false