mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc47701ad | ||
|
|
6a2b5ea5ea | ||
|
|
269aa53c27 | ||
|
|
03b45f8e11 | ||
|
|
02ec891fe4 | ||
|
|
5d67032ef6 | ||
|
|
d6e4e5f3ce | ||
|
|
2ba7562dff | ||
|
|
363e76858f | ||
|
|
27e713671d | ||
|
|
4e5737169f | ||
|
|
fce3d14a83 | ||
|
|
343dd2d10d | ||
|
|
a410617d4a | ||
|
|
4a8a841f72 | ||
|
|
7a6d2d86d7 | ||
|
|
341af162b0 | ||
|
|
96067da1d3 | ||
|
|
888f28990b | ||
|
|
b1abec4512 | ||
|
|
1a084eb8b0 | ||
|
|
42ab7927f2 | ||
|
|
56d908c6dc | ||
|
|
2cfb29f1f0 | ||
|
|
c3827466eb | ||
|
|
c52482368b | ||
|
|
8089c802f0 | ||
|
|
3e535aa2d7 | ||
|
|
4063671e3f | ||
|
|
9665ba07a3 | ||
|
|
4086f65096 | ||
|
|
44afecc366 | ||
|
|
c9cd414571 |
@@ -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`): model-provider catalog, generic config, OAuth store, CLI/app setup |
|
||||
| [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 |
|
||||
|
||||
+9
-4
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
@@ -17,11 +17,13 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
|
||||
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
|
||||
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
@@ -39,5 +41,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
|
||||
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
|
||||
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# Paseo Agent provider
|
||||
|
||||
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
|
||||
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
|
||||
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
|
||||
|
||||
Use it like any other agent provider:
|
||||
|
||||
```bash
|
||||
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
|
||||
```
|
||||
|
||||
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
|
||||
instance and the `openai/gpt-4o-mini` model exposed by that instance.
|
||||
|
||||
> 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 stale `dist` may reject or omit `agents.paseo` behavior. Always pass
|
||||
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
|
||||
> daemon on `:6767`.
|
||||
|
||||
## Timeline and persistence
|
||||
|
||||
Paseo Agent does not write or resume Pi CLI session files such as
|
||||
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
|
||||
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
|
||||
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
|
||||
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
|
||||
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
|
||||
|
||||
## Provider catalog
|
||||
|
||||
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
|
||||
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
|
||||
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
|
||||
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
|
||||
thinking maps, and OAuth registry data at runtime.
|
||||
|
||||
The current catalog contains four entries:
|
||||
|
||||
| id | Pi provider | default models | auth source |
|
||||
| ------------- | -------------- | -------------- | --------------------------------------------- |
|
||||
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
|
||||
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
|
||||
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
|
||||
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
|
||||
|
||||
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
|
||||
Users can store the OpenRouter credential first, but they must choose explicit model ids
|
||||
before running Paseo Agent through that provider. The other catalog entries expose Pi's
|
||||
bundled model list unless an instance sets `options.models`.
|
||||
|
||||
## Config shape
|
||||
|
||||
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
|
||||
keys; provider types are catalog ids. Use the catalog id as the default instance name,
|
||||
or create several instances of the same type with different models, keys, or endpoint
|
||||
overrides.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {
|
||||
"openrouter-main": {
|
||||
"type": "openrouter",
|
||||
"options": {
|
||||
"apiKey": "$OPENROUTER_API_KEY",
|
||||
"models": [
|
||||
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
|
||||
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
|
||||
],
|
||||
},
|
||||
},
|
||||
"chatgpt": {
|
||||
"type": "chatgpt",
|
||||
},
|
||||
"kimi": {
|
||||
"type": "kimi",
|
||||
"options": {
|
||||
"apiKey": "$KIMI_API_KEY",
|
||||
"models": [{ "id": "k2p7" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Most options are overrides over Pi-derived provider data:
|
||||
|
||||
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
|
||||
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
|
||||
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
|
||||
mirrors Pi's config-value semantics: literals and commands count as configured; env
|
||||
references count only when every referenced env var is set in the daemon environment.
|
||||
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
|
||||
config.
|
||||
- `models[]` is an instance override. Omit it to use that entry's default policy, which
|
||||
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
|
||||
when a single backend serves mixed protocols or when Pi has no data for a custom id.
|
||||
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
|
||||
|
||||
Env references make config portable: `config.json` can be copied between machines while
|
||||
the secret stays in that machine's daemon environment or keychain command.
|
||||
|
||||
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
|
||||
explicit session model wins, then the selected agent definition's model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
|
||||
|
||||
## Authentication
|
||||
|
||||
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
|
||||
Redacted provider responses include an optional `auth` state:
|
||||
|
||||
- `Connected` means the key or credential expression resolves locally. It does not make a
|
||||
network call, so a fake literal key still reports connected until a real session uses it.
|
||||
- `Needs attention` means the instance exists but the auth expression does not currently
|
||||
resolve, the OAuth store binding does not match, or another auth precondition is missing.
|
||||
- `not configured` is used by older/no-auth responses.
|
||||
|
||||
The redacted provider `available` flag mirrors local credential availability. The Paseo
|
||||
Agent runtime still needs at least one exposed model before it can start a session.
|
||||
|
||||
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
|
||||
output.
|
||||
|
||||
### OAuth store
|
||||
|
||||
OAuth credentials live in Paseo's store, not in another tool's auth file:
|
||||
|
||||
```text
|
||||
$PASEO_HOME/paseo-agent/auth.json
|
||||
```
|
||||
|
||||
The store is created through Pi's `AuthStorage`. The parent directory is private and the
|
||||
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
|
||||
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
|
||||
rotation back into the same Paseo-owned file.
|
||||
|
||||
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
|
||||
catalog flow or configured base URL changes, the old credential is left on disk but the
|
||||
provider reports `Needs attention` until it is authorized again. This prevents a token
|
||||
for one OAuth target from silently being used against another.
|
||||
|
||||
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
|
||||
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
|
||||
The current catalog only uses `openai-codex` for `chatgpt`.
|
||||
|
||||
## CLI setup
|
||||
|
||||
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
|
||||
isolated daemon.
|
||||
|
||||
Commands:
|
||||
|
||||
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
|
||||
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
|
||||
`--device-code`, `--json`, and `--host` as needed.
|
||||
- `paseo provider ls` lists configured instances and redacted auth state.
|
||||
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
|
||||
it pointed at that instance.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$OPENROUTER_API_KEY" |
|
||||
paseo provider add \
|
||||
openrouter \
|
||||
--api-key-stdin \
|
||||
--model openai/gpt-4o-mini \
|
||||
--host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add kimi \
|
||||
--model k2p7 \
|
||||
--host 127.0.0.1:7911
|
||||
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
|
||||
API-key providers it writes the configured key expression into `config.json`. For OAuth
|
||||
providers it first writes the provider config, then stores the OAuth credential in the
|
||||
daemon's Paseo-owned auth store.
|
||||
|
||||
`provider add` is idempotent for the same instance name: running it again updates the
|
||||
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
|
||||
provider instance and clears `defaultModel` if it pointed at the removed instance.
|
||||
|
||||
## App setup
|
||||
|
||||
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
|
||||
settings, then the Paseo Agent provider section. The app fetches the catalog from the
|
||||
connected daemon, shows catalog entries in the picker, and renders either an API-key
|
||||
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
|
||||
daemon-run starts: browser redirect for the local fast path and device code for
|
||||
remote-safe authorization. Relay connections present device code first; direct TCP does
|
||||
not prove same-machine by itself, so both options remain available.
|
||||
|
||||
Configured rows show the redacted provider state from the daemon: label, provider type,
|
||||
models, availability, and auth state. The app gates this UI on
|
||||
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
|
||||
instead of trying to synthesize the feature through older RPCs.
|
||||
|
||||
## Wire surface
|
||||
|
||||
The catalog surface is gated by:
|
||||
|
||||
```text
|
||||
server_info.features.paseoAgentCatalog
|
||||
```
|
||||
|
||||
RPC names use dotted namespaces:
|
||||
|
||||
| request | response |
|
||||
| --------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
|
||||
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
|
||||
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
|
||||
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
|
||||
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
|
||||
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
|
||||
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
|
||||
|
||||
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
|
||||
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
|
||||
complete the localhost redirect on the daemon host. Browser mode returns
|
||||
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
|
||||
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
|
||||
stores the credential, and returns the redacted `auth` state. Device-code mode returns
|
||||
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
|
||||
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
|
||||
provider authorizes.
|
||||
|
||||
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
|
||||
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
|
||||
new catalog entries, and new clients can show "update host" only when the daemon lacks the
|
||||
catalog feature.
|
||||
|
||||
## Adding a catalog entry
|
||||
|
||||
Adding a new model-provider type should be a data change:
|
||||
|
||||
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
|
||||
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
|
||||
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
|
||||
should not expose its full model list by default.
|
||||
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
|
||||
resolution simple.
|
||||
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
|
||||
and CLI/app rendering if the new entry exercises a new shape.
|
||||
|
||||
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
|
||||
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
|
||||
addressing.
|
||||
|
||||
## 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 `<serverName>__<toolName>`; 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`.
|
||||
|
||||
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
|
||||
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
|
||||
|
||||
## Agent definitions
|
||||
|
||||
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
|
||||
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
|
||||
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
|
||||
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Example `$PASEO_HOME/agents/orchestrator.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Orchestrator
|
||||
description: Coordinates work through Paseo-managed agents
|
||||
prompt: extend
|
||||
mcp: [paseo]
|
||||
model: openrouter-main/openai/gpt-4o-mini
|
||||
tools: [read, grep, paseo__list_agents, paseo__create_agent]
|
||||
permissions:
|
||||
- tool: paseo__archive_*
|
||||
action: deny
|
||||
---
|
||||
|
||||
!{{./partials/collaboration.md}}
|
||||
|
||||
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
|
||||
summarize handoffs clearly.
|
||||
|
||||
!{{./partials/review-rules.md}}
|
||||
```
|
||||
|
||||
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
|
||||
the append list. `prompt: override` uses the agent body as the custom base prompt, so
|
||||
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
|
||||
appended after the agent, and the daemon-level append prompt is appended last.
|
||||
|
||||
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
|
||||
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
|
||||
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
|
||||
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
|
||||
an explicit session model wins, then the selected agent model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
|
||||
Paths are relative to the file containing the directive and are confined to
|
||||
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
|
||||
chains, oversized definitions, and frontmatter inside partials are rejected.
|
||||
|
||||
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
|
||||
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
|
||||
an ordered first-match policy for active tool calls. The first matching `tool` pattern
|
||||
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
|
||||
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
|
||||
|
||||
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
|
||||
MCP injection still supplies the actual server; if an agent declares an MCP server that
|
||||
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
|
||||
@@ -45,6 +45,7 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
|
||||
|
||||
@@ -52,6 +52,7 @@ buildNpmPackage rec {
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+2877
-31
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
type PaseoAgentDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "connect"
|
||||
| "removePaseoAgentProvider"
|
||||
| "setPaseoAgentProvider"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
>;
|
||||
|
||||
interface ApiKeyProviderInput {
|
||||
catalogId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface OAuthProviderInput {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
interface ExpectedProvider {
|
||||
name: string;
|
||||
providerLabel: string;
|
||||
auth: "Connected" | "Needs attention";
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
|
||||
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
|
||||
}
|
||||
|
||||
export async function openPaseoAgentSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
|
||||
const sheet = page.getByTestId("paseo-agent-settings-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill(provider.apiKey);
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function startOAuthProviderSignIn(
|
||||
page: Page,
|
||||
provider: OAuthProviderInput,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sign in with browser", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectModelProviderListed(
|
||||
page: Page,
|
||||
expected: ExpectedProvider,
|
||||
): Promise<void> {
|
||||
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
|
||||
await expect(
|
||||
page.getByRole("listitem", {
|
||||
name: new RegExp(
|
||||
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
|
||||
),
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function seedChatGptProvider(providerName: string): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
await client.setPaseoAgentProvider({
|
||||
name: providerName,
|
||||
providerType: "chatgpt",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
||||
},
|
||||
});
|
||||
await client.storePaseoAgentOAuthCredential({
|
||||
name: providerName,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fake-access-token",
|
||||
refresh: "fake-refresh-token",
|
||||
expires: 4_102_444_800,
|
||||
futureField: { passthrough: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
for (const name of providerNames) {
|
||||
await client.removePaseoAgentProvider(name);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
addApiKeyProvider,
|
||||
cleanupPaseoAgentProviders,
|
||||
expectModelProviderListed,
|
||||
openPaseoAgentSettings,
|
||||
seedChatGptProvider,
|
||||
startOAuthProviderSignIn,
|
||||
} from "./helpers/paseo-agent";
|
||||
|
||||
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
|
||||
const CLOSE_PROVIDER = "phase-e-close-ui";
|
||||
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
|
||||
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
|
||||
const RENAME_PROVIDER = "phase-e-rename-ui";
|
||||
|
||||
test.describe("Paseo Agent provider configuration", () => {
|
||||
const providerNamesToCleanup = new Set<string>();
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPaseoAgentProviders(providerNamesToCleanup);
|
||||
providerNamesToCleanup.clear();
|
||||
});
|
||||
|
||||
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await addApiKeyProvider(page, {
|
||||
catalogId: "openrouter",
|
||||
apiKey: "sk-or-phase-e-write-only",
|
||||
});
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill("sk-or-default-name");
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await startOAuthProviderSignIn(page, {
|
||||
catalogId: "chatgpt",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows user-facing provider picker rows", async ({ page }) => {
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
|
||||
const picker = page.getByTestId("paseo-agent-provider-picker");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("paseo-agent-catalog-entry-openrouter")
|
||||
.getByText("API key", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
|
||||
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
|
||||
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CHATGPT_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: CHATGPT_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a configured provider row without changing its stored credential key", async ({
|
||||
page,
|
||||
}) => {
|
||||
providerNamesToCleanup.add(RENAME_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(RENAME_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
|
||||
await page.getByLabel("Provider name").fill("Work account");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: "Work account",
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CLOSE_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CLOSE_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await expectModelProviderListed(page, {
|
||||
name: CLOSE_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
|
||||
await page.getByLabel("Close", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenRouterIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
createPaseoAgentProviderInput,
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
nextPaseoAgentProviderName,
|
||||
parsePaseoAgentModelIds,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
preferredPaseoAgentOAuthMode,
|
||||
} from "./paseo-agent-settings-sheet-model";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("paseo-agent-settings-sheet-model", () => {
|
||||
it("recognizes supported catalog auth manifests", () => {
|
||||
const apiKeyEntry = catalogEntry({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
},
|
||||
});
|
||||
const oauthEntry = catalogEntry({
|
||||
auth: { kind: "oauth", flow: "alpha-oauth" },
|
||||
});
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
});
|
||||
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
|
||||
kind: "oauth",
|
||||
flow: "alpha-oauth",
|
||||
});
|
||||
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
|
||||
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unknown catalog auth kinds visible but unsupported", () => {
|
||||
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
|
||||
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
|
||||
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers device-code OAuth only for a relay connection", () => {
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
|
||||
});
|
||||
|
||||
it("parses model ids from comma and newline separated input", () => {
|
||||
expect(
|
||||
parsePaseoAgentModelIds(`
|
||||
alpha/fast, beta/steady
|
||||
alpha/fast
|
||||
gamma/deep
|
||||
`),
|
||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
||||
});
|
||||
|
||||
it("uses the catalog label for the first provider instance", () => {
|
||||
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
|
||||
"Acme Cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("numbers later provider instances from the catalog label", () => {
|
||||
expect(
|
||||
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
|
||||
providerConfig({ name: "Acme Cloud" }),
|
||||
providerConfig({ name: "Acme Cloud (1)" }),
|
||||
]),
|
||||
).toBe("Acme Cloud (2)");
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ id: "catalog-beta" }),
|
||||
name: " beta-main ",
|
||||
apiKey: " beta-secret ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-beta",
|
||||
options: {
|
||||
apiKey: "beta-secret",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with custom model ids", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
modelIds: ["alpha/fast", "beta/steady"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits model overrides when the catalog has no defaults", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with an env reference for an empty key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
|
||||
name: "beta-main",
|
||||
apiKey: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$BETA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an oauth provider payload without key material", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
|
||||
name: "alpha-login",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-login",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses catalog labels and generic auth badges for instance rows", () => {
|
||||
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
|
||||
expect(
|
||||
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
|
||||
).toBe("custom-alpha");
|
||||
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
|
||||
label: "Connected",
|
||||
variant: "success",
|
||||
});
|
||||
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
|
||||
label: "Needs attention",
|
||||
variant: "error",
|
||||
});
|
||||
expect(paseoAgentAuthBadge(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
export interface PaseoAgentApiKeyAuthManifest {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthManifest {
|
||||
kind: "oauth";
|
||||
flow: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentAuthBadge {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
}
|
||||
|
||||
export type PaseoAgentOAuthMode = "browser" | "device_code";
|
||||
|
||||
export interface PaseoAgentOAuthConnectionSignal {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function preferredPaseoAgentOAuthMode(
|
||||
activeConnection: PaseoAgentOAuthConnectionSignal | null,
|
||||
): PaseoAgentOAuthMode {
|
||||
return activeConnection?.type === "relay" ? "device_code" : "browser";
|
||||
}
|
||||
|
||||
export function getPaseoAgentApiKeyAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentApiKeyAuthManifest | null {
|
||||
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar: entry.auth.envVar,
|
||||
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
|
||||
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
|
||||
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaseoAgentOAuthAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentOAuthAuthManifest | null {
|
||||
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { kind: "oauth", flow: entry.auth.flow };
|
||||
}
|
||||
|
||||
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
|
||||
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
|
||||
}
|
||||
|
||||
export function paseoAgentProviderLabel(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
catalogEntry: PaseoAgentCatalogEntry | undefined,
|
||||
): string {
|
||||
return catalogEntry?.label ?? provider.providerType;
|
||||
}
|
||||
|
||||
export function paseoAgentAuthBadge(
|
||||
auth: RedactedPaseoAgentProviderConfig["auth"],
|
||||
): PaseoAgentAuthBadge | null {
|
||||
if (!auth || auth.kind === "none") {
|
||||
return null;
|
||||
}
|
||||
return auth.configured
|
||||
? { label: "Connected", variant: "success" }
|
||||
: { label: "Needs attention", variant: "error" };
|
||||
}
|
||||
|
||||
export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const part of raw.split(/[\n,]/)) {
|
||||
const id = part.trim();
|
||||
if (id.length > 0 && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function nextPaseoAgentProviderName(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
providers: RedactedPaseoAgentProviderConfig[],
|
||||
): string {
|
||||
const baseName = entry.label.trim() || entry.id;
|
||||
const existingNames = new Set(providers.map((provider) => provider.name));
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${baseName} (${index})`;
|
||||
if (!existingNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPaseoAgentProviderInput(input: {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
apiKey?: string;
|
||||
modelIds?: string[];
|
||||
}): PaseoAgentSetProviderInput {
|
||||
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
|
||||
const trimmedKey = input.apiKey?.trim() ?? "";
|
||||
let apiKey: string | undefined;
|
||||
if (apiKeyAuth) {
|
||||
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
|
||||
}
|
||||
const models =
|
||||
input.modelIds && input.modelIds.length > 0
|
||||
? input.modelIds.map((id) => ({ id }))
|
||||
: input.entry.models.map((model) => ({ ...model }));
|
||||
|
||||
return {
|
||||
name: input.name.trim(),
|
||||
providerType: input.entry.id,
|
||||
options: {
|
||||
...(models.length > 0 ? { models } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PaseoAgentOAuthCompleteResult,
|
||||
PaseoAgentOAuthStartResult,
|
||||
PaseoAgentSetProviderInput,
|
||||
} from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
interface PaseoAgentProvidersHookMock {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
|
||||
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
|
||||
current: {
|
||||
supported: true,
|
||||
catalogSupported: true,
|
||||
providers: [] as RedactedPaseoAgentProviderConfig[],
|
||||
catalog: [] as PaseoAgentCatalogEntry[],
|
||||
defaultModel: null as string | null,
|
||||
isLoading: false,
|
||||
isCatalogLoading: false,
|
||||
error: null as string | null,
|
||||
catalogError: null as string | null,
|
||||
refresh: vi.fn(async () => undefined),
|
||||
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
|
||||
startOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthStartResult> => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
completeOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hookState: initialHookState,
|
||||
hostRuntimeSnapshot: {
|
||||
current: {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
} as {
|
||||
activeConnection: { type: string; endpoint: string; display: string } | null;
|
||||
} | null,
|
||||
},
|
||||
openExternalUrls: [] as string[],
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderRadius: { md: 6, lg: 8 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
border: "#444",
|
||||
destructive: "#f00",
|
||||
statusSuccess: "#0f0",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({
|
||||
children,
|
||||
testID,
|
||||
role,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
role?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": testID,
|
||||
role: role ?? accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
|
||||
React.createElement("span", { "data-testid": testID }, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Bot: icon("Bot"),
|
||||
Plus: icon("Plus"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
header,
|
||||
children,
|
||||
footer,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; back?: { label?: string; onPress: () => void } };
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.back ? (
|
||||
<button type="button" onClick={header.back.onPress}>
|
||||
{header.back.label ?? "Back"}
|
||||
</button>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</section>
|
||||
) : null,
|
||||
AdaptiveTextInput: ({
|
||||
value,
|
||||
onChangeText,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
placeholder,
|
||||
secureTextEntry,
|
||||
editable = true,
|
||||
multiline,
|
||||
}: {
|
||||
value?: string;
|
||||
onChangeText?: (value: string) => void;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
editable?: boolean;
|
||||
multiline?: boolean;
|
||||
}) => {
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
|
||||
onChangeText?.(event.currentTarget.value);
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
disabled: !editable,
|
||||
onChange: handleChange,
|
||||
placeholder,
|
||||
value: value ?? "",
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return React.createElement("textarea", inputProps);
|
||||
}
|
||||
|
||||
return React.createElement("input", {
|
||||
...inputProps,
|
||||
type: secureTextEntry ? "password" : "text",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icon-name", () => ({
|
||||
resolveProviderIconName: (provider: string) =>
|
||||
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/external-link", () => ({
|
||||
ExternalLink: ({
|
||||
href,
|
||||
label,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) => (
|
||||
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
|
||||
{label}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/status-badge", () => ({
|
||||
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
|
||||
usePaseoAgentProviders: () => hookState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: async (url: string) => {
|
||||
openExternalUrls.push(url);
|
||||
},
|
||||
}));
|
||||
|
||||
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
iconName: "known-icon",
|
||||
docsUrl: "https://alpha.example.test/docs",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
auth: { kind: "api_key", configured: true },
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
|
||||
}
|
||||
|
||||
function resetHookState() {
|
||||
hookState.current.supported = true;
|
||||
hookState.current.catalogSupported = true;
|
||||
hookState.current.providers = [];
|
||||
hookState.current.catalog = [];
|
||||
hookState.current.defaultModel = null;
|
||||
hookState.current.isLoading = false;
|
||||
hookState.current.isCatalogLoading = false;
|
||||
hookState.current.error = null;
|
||||
hookState.current.catalogError = null;
|
||||
hookState.current.refresh = vi.fn(async () => undefined);
|
||||
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}));
|
||||
hookState.current.completeOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}));
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
};
|
||||
openExternalUrls.splice(0);
|
||||
}
|
||||
|
||||
describe("PaseoAgentSettingsSheet", () => {
|
||||
beforeEach(() => {
|
||||
resetHookState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the catalog feature-gate message exactly once", () => {
|
||||
hookState.current.catalogSupported = false;
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ label: "Alpha Provider" }),
|
||||
catalogEntry({
|
||||
id: "catalog-future",
|
||||
label: "Future Provider",
|
||||
auth: { kind: "future_auth", prompt: "not yet" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
|
||||
expect(screen.getByText("Alpha Provider")).toBeTruthy();
|
||||
expect(screen.getByText("Future Provider")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
|
||||
"https://alpha.example.test/docs",
|
||||
);
|
||||
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("submits an api-key provider with an explicit key", async () => {
|
||||
hookState.current.catalog = [catalogEntry({ models: [] })];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
expect(screen.queryByLabelText("Provider name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Models")).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText("API key"), {
|
||||
target: { value: " alpha-secret " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an empty api-key field as a host env reference", async () => {
|
||||
hookState.current.catalog = [catalogEntry({})];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://login.example.test/oauth/authorize?state=abc",
|
||||
instructions: "Open the sign-in page",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
|
||||
});
|
||||
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
|
||||
"https://login.example.test/oauth/authorize?state=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a device-code oauth authorization and completes it", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "CODE-123",
|
||||
verificationUri: "https://login.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 600,
|
||||
instructions: "Enter the code",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
|
||||
|
||||
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
|
||||
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Login",
|
||||
providerType: "catalog-login",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
|
||||
});
|
||||
});
|
||||
|
||||
it("offers device-code first over relay while keeping browser available", () => {
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
|
||||
};
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
|
||||
const actionButtons = screen
|
||||
.getByTestId("paseo-agent-oauth-sign-in")
|
||||
.querySelectorAll("button");
|
||||
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
|
||||
"Providers",
|
||||
"Cancel",
|
||||
"Use a code instead",
|
||||
"Sign in with browser",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows auth-state badges from configured instances", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
|
||||
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
|
||||
];
|
||||
hookState.current.providers = [
|
||||
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
|
||||
providerConfig({
|
||||
name: "beta",
|
||||
providerType: "catalog-beta",
|
||||
auth: { kind: "api_key", configured: false },
|
||||
}),
|
||||
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
expect(screen.getByText("Needs attention")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,17 @@ 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("resolves Paseo Agent model-provider catalog icon names", () => {
|
||||
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
|
||||
expect(resolveProviderIconName("openrouter")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "openrouter",
|
||||
});
|
||||
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
|
||||
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OpenRouterIcon } from "@/components/icons/openrouter-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";
|
||||
@@ -25,7 +27,10 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
kiro: PackagePlus,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
openai: CodexIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
|
||||
paseo: PaseoLogo as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
const PASEO_AGENT_PROVIDER = "paseo";
|
||||
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
@@ -16,6 +19,10 @@ export function ProviderSettingsHost() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === PASEO_AGENT_PROVIDER) {
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentSetProviderRequest,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export function paseoAgentProvidersQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-providers", serverId] as const;
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-catalog", serverId] as const;
|
||||
}
|
||||
|
||||
function describeQueryError(error: unknown): string | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
||||
export type PaseoAgentRenameProviderInput = Omit<
|
||||
PaseoAgentRenameProviderRequest,
|
||||
"type" | "requestId"
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
||||
|
||||
interface UsePaseoAgentProvidersResult {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
renameProvider: (
|
||||
input: PaseoAgentRenameProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
|
||||
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
|
||||
);
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
|
||||
const catalogSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
|
||||
);
|
||||
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
|
||||
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentProviders();
|
||||
},
|
||||
});
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: catalogQueryKey,
|
||||
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentCatalog();
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
|
||||
}, [catalogQueryKey, queryClient, queryKey]);
|
||||
|
||||
const error = query.data?.error ?? describeQueryError(query.error);
|
||||
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
|
||||
|
||||
const setProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentSetProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.setPaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: setProviderAsync } = setProviderMutation;
|
||||
|
||||
const setProvider = useCallback(
|
||||
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
|
||||
[setProviderAsync],
|
||||
);
|
||||
|
||||
const renameProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.renamePaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
|
||||
|
||||
const renameProvider = useCallback(
|
||||
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
|
||||
[renameProviderAsync],
|
||||
);
|
||||
|
||||
const startOAuthMutation = useMutation({
|
||||
mutationFn: async (input: { name: string; mode?: string }) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
|
||||
|
||||
const startOAuth = useCallback(
|
||||
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
|
||||
[startOAuthAsync],
|
||||
);
|
||||
|
||||
const completeOAuthMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.completePaseoAgentOAuth(name);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
|
||||
|
||||
const completeOAuth = useCallback(
|
||||
(name: string) => completeOAuthAsync(name),
|
||||
[completeOAuthAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
supported,
|
||||
catalogSupported,
|
||||
providers: query.data?.providers ?? [],
|
||||
catalog: catalogQuery.data?.catalog ?? [],
|
||||
defaultModel: query.data?.defaultModel ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isCatalogLoading: catalogQuery.isLoading,
|
||||
error,
|
||||
catalogError,
|
||||
refresh,
|
||||
setProvider,
|
||||
renameProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
};
|
||||
}
|
||||
@@ -33,10 +33,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
try {
|
||||
const serverInfo = await client.waitForServerInfo(PAIRING_DAEMON_RPC_TIMEOUT_MS);
|
||||
if (serverInfo.features?.daemonStatusRpc === true) {
|
||||
const offer = await client.getDaemonPairingOffer({
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -46,11 +45,11 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
options,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -156,11 +157,9 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: "Claude", binary: "claude" },
|
||||
{ label: "Codex", binary: "codex" },
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter(
|
||||
(provider) => provider.voice?.enabled === true && provider.defaultModeId !== null,
|
||||
).map((provider) => ({ label: provider.label, binary: provider.id }));
|
||||
|
||||
async function checkProviderBinary(
|
||||
binary: string,
|
||||
|
||||
@@ -50,10 +50,7 @@ export function addLoopRunOptions(command: Command): Command {
|
||||
.argument("<prompt>", "Prompt for each fresh worker iteration")
|
||||
.option("--provider <provider>", "Default provider for worker and verifier agents")
|
||||
.option("--model <model>", "Default model for worker and verifier agents")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--mode <mode>", "Provider-specific mode for the worker agent")
|
||||
.option("--verify-provider <provider>", "Provider for the verifier agent")
|
||||
.option("--verify-model <model>", "Model for the verifier agent")
|
||||
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runAddCommand } from "./add.js";
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
api: string;
|
||||
baseUrl: string;
|
||||
auth: Record<string, unknown>;
|
||||
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
|
||||
}
|
||||
|
||||
interface RecordingClientInput {
|
||||
catalog: CatalogEntry[];
|
||||
features?: Record<string, unknown>;
|
||||
setProvider?: (input: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => Promise<unknown>;
|
||||
startOAuth?: (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) => Promise<unknown>;
|
||||
completeOAuth?: (name: string) => Promise<unknown>;
|
||||
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function createClient(input: RecordingClientInput) {
|
||||
return {
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: input.features ?? { paseoAgentCatalog: true },
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: input.catalog,
|
||||
error: null,
|
||||
}),
|
||||
setPaseoAgentProvider: async (providerInput: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => {
|
||||
if (input.setProvider) {
|
||||
return input.setProvider(providerInput);
|
||||
}
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: providerInput.name,
|
||||
providerType: providerInput.providerType,
|
||||
models: providerInput.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
startPaseoAgentOAuth: async (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) =>
|
||||
input.startOAuth?.(name, options) ?? {
|
||||
requestId: "oauth-start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
completePaseoAgentOAuth: async (name: string) =>
|
||||
input.completeOAuth?.(name) ?? {
|
||||
requestId: "oauth-complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async (credentialInput: {
|
||||
name: string;
|
||||
credential: unknown;
|
||||
}) =>
|
||||
input.storeCredential?.(credentialInput) ?? {
|
||||
requestId: "oauth-store-1",
|
||||
success: true,
|
||||
name: credentialInput.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
hint: "Create an Alpha key before continuing.",
|
||||
keyUrl: "https://alpha.example.test/keys",
|
||||
placeholder: "Alpha API key",
|
||||
},
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function oauthEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider add", () => {
|
||||
it("configures an API-key provider from a hidden prompt without echoing the key", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const prompts: string[] = [];
|
||||
const output: string[] = [];
|
||||
const result = await runAddCommand(
|
||||
"alpha-key",
|
||||
{ host: "localhost:7777", name: "alpha-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => output.push(message),
|
||||
promptSecret: async (message) => {
|
||||
prompts.push(message);
|
||||
return "redaction-sentinel";
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "redaction-sentinel",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]);
|
||||
expect(output.join("\n")).toContain("Create an Alpha key");
|
||||
expect(output.join("\n")).toContain("https://alpha.example.test/keys");
|
||||
expect(render(result, { format: "json" })).not.toContain("redaction-sentinel");
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("alpha-main");
|
||||
});
|
||||
|
||||
it("stores an environment reference when the API-key prompt is empty", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const result = await runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => "",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: false, source: "env" },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data.auth).toBe("Needs attention");
|
||||
});
|
||||
|
||||
it("reads an API key from stdin for scripts", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
models: [{ id: "one" }, { id: "two" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("configures an API-key provider without model defaults", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data).toMatchObject({
|
||||
name: "alpha-key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
|
||||
const order: string[] = [];
|
||||
const stored: unknown[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
|
||||
const result = await runAddCommand(
|
||||
"beta-oauth",
|
||||
{ host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => order.push(`write:${message}`),
|
||||
openBrowser: (url) => {
|
||||
openedUrls.push(url);
|
||||
return true;
|
||||
},
|
||||
loginBrowserCredential: async (options) => {
|
||||
expect(options.flow).toBe("beta-flow");
|
||||
order.push("browser-login");
|
||||
options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser.");
|
||||
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("manual prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
setProvider: async (input) => {
|
||||
order.push("set-provider");
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
storeCredential: async (input) => {
|
||||
stored.push(input);
|
||||
return {
|
||||
requestId: "store-1",
|
||||
success: true,
|
||||
name: input.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual([
|
||||
"set-provider",
|
||||
"browser-login",
|
||||
"write:Authorize in the browser.",
|
||||
"write: https://auth.example.test/browser",
|
||||
"write:Waiting for you to approve in the browser...",
|
||||
"write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).",
|
||||
]);
|
||||
expect(stored).toEqual([
|
||||
{
|
||||
name: "beta-main",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(openedUrls).toEqual(["https://auth.example.test/browser"]);
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
expect(order.join("\n")).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("uses daemon-run OAuth when --device-code is passed", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => {
|
||||
throw new Error("browser should not be opened for --device-code");
|
||||
},
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run for --device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("ABCD-EFGH");
|
||||
expect(output.join("\n")).toContain("https://auth.example.test/device");
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
});
|
||||
|
||||
it("falls back to daemon-run OAuth when the browser cannot open", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
await runAddCommand("beta-oauth", {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => false,
|
||||
loginBrowserCredential: async (options) => {
|
||||
options.onAuthUrl("https://auth.example.test/browser");
|
||||
throw new Error("onAuthUrl should have switched to device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "WXYZ-1234",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("Browser could not be opened");
|
||||
expect(output.join("\n")).toContain("WXYZ-1234");
|
||||
});
|
||||
|
||||
it("updates the same instance on repeated add calls", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const client = createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const dependencies = {
|
||||
promptSecret: async () => "secret",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => client,
|
||||
};
|
||||
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
|
||||
expect(setCalls).toHaveLength(2);
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("mentions known provider ids for an unknown catalog id", async () => {
|
||||
await expect(
|
||||
runAddCommand("missing-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
|
||||
setProvider: async () => {
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message:
|
||||
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the catalog feature flag before reading the catalog", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => {
|
||||
calls.push("catalog");
|
||||
throw new Error("catalog should not be read");
|
||||
},
|
||||
setPaseoAgentProvider: async () => {
|
||||
calls.push("set");
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
startPaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
completePaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the user choose a provider when no id is passed", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
await runAddCommand(undefined, {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
promptText: async (message) => {
|
||||
expect(message).toBe("Select provider:");
|
||||
return "2";
|
||||
},
|
||||
promptSecret: async () => "chosen-secret",
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [
|
||||
apiKeyEntry({ id: "first-key", label: "First Key" }),
|
||||
apiKeyEntry({ id: "second-key", label: "Second Key" }),
|
||||
],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(output.join("\n")).toContain("First Key (first-key)");
|
||||
expect(output.join("\n")).toContain("Second Key (second-key)");
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "second-key", providerType: "second-key" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentProviderAuthState,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { loginOAuthBrowser } from "@getpaseo/server";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { openBrowserUrl } from "../../utils/open-browser.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface ProviderAddOptions extends CommandOptions {
|
||||
name?: string;
|
||||
apiKeyStdin?: boolean;
|
||||
deviceCode?: boolean;
|
||||
model?: string[];
|
||||
}
|
||||
|
||||
interface ProviderModelInput {
|
||||
id: string;
|
||||
label?: string;
|
||||
api?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
interface ProviderConfiguredItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface ProviderAddClient extends Pick<
|
||||
DaemonClient,
|
||||
| "waitForServerInfo"
|
||||
| "getPaseoAgentCatalog"
|
||||
| "setPaseoAgentProvider"
|
||||
| "startPaseoAgentOAuth"
|
||||
| "completePaseoAgentOAuth"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
| "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderAddDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderAddClient>;
|
||||
readStdin: () => Promise<string>;
|
||||
promptText: (message: string) => Promise<string>;
|
||||
promptSecret: (message: string) => Promise<string>;
|
||||
loginBrowserCredential: typeof loginOAuthBrowser;
|
||||
openBrowser: (url: string) => boolean;
|
||||
write: (message: string) => void;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderAddDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
readStdin,
|
||||
promptText,
|
||||
promptSecret,
|
||||
loginBrowserCredential: loginOAuthBrowser,
|
||||
openBrowser: openBrowserUrl,
|
||||
write: (message) => console.error(message),
|
||||
};
|
||||
|
||||
export const providerConfiguredSchema: OutputSchema<ProviderConfiguredItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
process.stdin.setEncoding("utf8");
|
||||
let value = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
value += chunk;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function promptText(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
const text = String(chunk);
|
||||
if (text.includes(message)) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
}) as Writable & { isTTY?: boolean };
|
||||
output.isTTY = true;
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
|
||||
const auth = entry.auth;
|
||||
const value = auth[field];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function requireAuthField(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
field: string,
|
||||
description: string,
|
||||
): string {
|
||||
const value = authField(entry, field);
|
||||
if (value) return value;
|
||||
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "envVar", "an API key environment variable");
|
||||
}
|
||||
|
||||
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "flow", "an OAuth flow");
|
||||
}
|
||||
|
||||
function normalizeModels(rawModels: string[] | undefined): string[] {
|
||||
return (rawModels ?? [])
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
|
||||
return entry.models.map((model) => ({
|
||||
id: model.id,
|
||||
...(model.label ? { label: model.label } : {}),
|
||||
...(model.api ? { api: model.api } : {}),
|
||||
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
||||
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
||||
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveModels(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
): ProviderModelInput[] | undefined {
|
||||
const modelIds = normalizeModels(options.model);
|
||||
if (modelIds.length > 0) {
|
||||
return modelIds.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
const models = catalogModels(entry);
|
||||
if (models.length > 0) {
|
||||
return models;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function selectCatalogEntry(
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (catalog.length === 0) {
|
||||
throw {
|
||||
code: "EMPTY_PROVIDER_CATALOG",
|
||||
message: "The Paseo daemon returned an empty provider catalog.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
dependencies.write("Available model providers:");
|
||||
catalog.forEach((entry, index) => {
|
||||
dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`);
|
||||
});
|
||||
const answer = await dependencies.promptText("Select provider:");
|
||||
const selectedIndex = Number(answer);
|
||||
const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined;
|
||||
const byId = catalog.find((entry) => entry.id === answer);
|
||||
const selected = byIndex ?? byId;
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
throw {
|
||||
code: "INVALID_PROVIDER_SELECTION",
|
||||
message: `Invalid provider selection: ${answer}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
async function resolveEntry(
|
||||
id: string | undefined,
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (!id) {
|
||||
return selectCatalogEntry(catalog, dependencies);
|
||||
}
|
||||
|
||||
const entry = catalog.find((candidate) => candidate.id === id);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
|
||||
throw {
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function toConfiguredItem(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): ProviderConfiguredItem {
|
||||
return {
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: entry.label,
|
||||
auth: formatAuthState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
};
|
||||
}
|
||||
|
||||
function formatDaemonTarget(host: string | undefined): string {
|
||||
if (!host) {
|
||||
return "local daemon";
|
||||
}
|
||||
try {
|
||||
if (host.startsWith("tcp://")) {
|
||||
const url = new URL(host);
|
||||
url.searchParams.delete("password");
|
||||
return `selected daemon (${url.toString()})`;
|
||||
}
|
||||
} catch {
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
|
||||
async function resolveApiKey(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<string> {
|
||||
const envVar = apiKeyEnvVar(entry);
|
||||
if (options.apiKeyStdin) {
|
||||
const value = (await dependencies.readStdin()).trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
throw {
|
||||
code: "MISSING_API_KEY",
|
||||
message: "No API key was read from stdin.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const hint = authField(entry, "hint");
|
||||
const keyUrl = authField(entry, "keyUrl");
|
||||
if (hint) {
|
||||
dependencies.write(hint);
|
||||
}
|
||||
if (keyUrl) {
|
||||
dependencies.write(`API key URL: ${keyUrl}`);
|
||||
}
|
||||
const placeholder = authField(entry, "placeholder") ?? "API key";
|
||||
const value = await dependencies.promptSecret(
|
||||
`Enter ${placeholder} (leave empty to use $${envVar}):`,
|
||||
);
|
||||
return value || `$${envVar}`;
|
||||
}
|
||||
|
||||
function isBrowserOpenError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as CommandError).code === "BROWSER_OPEN_FAILED"
|
||||
);
|
||||
}
|
||||
|
||||
function printOAuthAuthorization(
|
||||
authorization: Awaited<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["authorization"],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): void {
|
||||
if (!authorization) {
|
||||
dependencies.write("Authorization completed; waiting for the daemon to store credentials...");
|
||||
return;
|
||||
}
|
||||
if (authorization.instructions) {
|
||||
dependencies.write(authorization.instructions);
|
||||
}
|
||||
if (authorization.verificationUri) {
|
||||
dependencies.write(`Open: ${authorization.verificationUri}`);
|
||||
}
|
||||
if (authorization.userCode) {
|
||||
dependencies.write(`Code: ${authorization.userCode}`);
|
||||
}
|
||||
if (authorization.url) {
|
||||
dependencies.write(`Open: ${authorization.url}`);
|
||||
}
|
||||
if (authorization.expiresInSeconds) {
|
||||
dependencies.write(
|
||||
`Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`,
|
||||
);
|
||||
}
|
||||
dependencies.write("Waiting for authorization...");
|
||||
}
|
||||
|
||||
async function runDaemonOAuth(
|
||||
client: ProviderAddClient,
|
||||
name: string,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
|
||||
if (!started.success) {
|
||||
throw {
|
||||
code: "OAUTH_START_FAILED",
|
||||
message: started.error ?? "Daemon rejected the OAuth start request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
printOAuthAuthorization(started.authorization, dependencies);
|
||||
const completed = await client.completePaseoAgentOAuth(name);
|
||||
if (!completed.success) {
|
||||
throw {
|
||||
code: "OAUTH_COMPLETE_FAILED",
|
||||
message: completed.error ?? "Daemon did not complete OAuth.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return completed.auth;
|
||||
}
|
||||
|
||||
async function runBrowserOAuth(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const target = formatDaemonTarget(options.host);
|
||||
const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({
|
||||
flow: oauthFlow(entry),
|
||||
onAuthUrl: (url, instructions) => {
|
||||
const opened = dependencies.openBrowser(url);
|
||||
if (!opened) {
|
||||
throw {
|
||||
code: "BROWSER_OPEN_FAILED",
|
||||
message: "Browser could not be opened.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
|
||||
dependencies.write(` ${url}`);
|
||||
dependencies.write("Waiting for you to approve in the browser...");
|
||||
},
|
||||
onProgress: (message) => dependencies.write(message),
|
||||
promptForCode: dependencies.promptText,
|
||||
});
|
||||
const result = await client.storePaseoAgentOAuthCredential({ name, credential });
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "OAUTH_STORE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the OAuth credential.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(`Credential accepted by ${target}.`);
|
||||
return result.auth;
|
||||
}
|
||||
|
||||
async function configureProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<RedactedPaseoAgentProviderConfig> {
|
||||
const models = resolveModels(entry, options);
|
||||
const apiKey =
|
||||
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
|
||||
const result = await client.setPaseoAgentProvider({
|
||||
name,
|
||||
providerType: entry.id,
|
||||
options: {
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(models ? { models } : {}),
|
||||
},
|
||||
});
|
||||
if (!result.success || !result.provider) {
|
||||
throw {
|
||||
code: "PROVIDER_CONFIG_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider config.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return result.provider;
|
||||
}
|
||||
|
||||
async function authenticateOAuthProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
if (options.deviceCode) {
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
try {
|
||||
return await runBrowserOAuth(client, entry, name, options, dependencies);
|
||||
} catch (error) {
|
||||
if (!isBrowserOpenError(error)) {
|
||||
throw error;
|
||||
}
|
||||
dependencies.write("Browser could not be opened; using device-code authorization.");
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAddCommand(
|
||||
id: string | undefined,
|
||||
options: ProviderAddOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderAddDependencies> = {},
|
||||
): Promise<SingleResult<ProviderConfiguredItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const entry = await resolveEntry(id, catalogResult.catalog, deps);
|
||||
const name = options.name?.trim() || entry.id;
|
||||
const provider = await configureProvider(client, entry, name, options, deps);
|
||||
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const auth =
|
||||
entry.auth.kind === "oauth"
|
||||
? await authenticateOAuthProvider(client, entry, name, options, deps)
|
||||
: provider.auth;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
|
||||
schema: providerConfiguredSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function addProviderAddOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Configure a Paseo Agent model provider")
|
||||
.argument("[id]", "Catalog provider id; omit to choose interactively")
|
||||
.option("--name <instanceName>", "Provider instance name (default: provider id)")
|
||||
.option(
|
||||
"--model <id>",
|
||||
"Model ID to expose (repeatable, comma-separated; defaults to catalog models)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
function createServerInfo(features: ServerInfoStatusPayload["features"]): ServerInfoStatusPayload {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider feature gate", () => {
|
||||
it("waits for delayed server_info before allowing catalog commands", async () => {
|
||||
let resolveServerInfo: ((serverInfo: ServerInfoStatusPayload) => void) | null = null;
|
||||
|
||||
const allowed = requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () =>
|
||||
new Promise<ServerInfoStatusPayload>((resolve) => {
|
||||
resolveServerInfo = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveServerInfo).not.toBeNull();
|
||||
resolveServerInfo?.(createServerInfo({ paseoAgentCatalog: true }));
|
||||
|
||||
await expect(allowed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces the server_info wait timeout", async () => {
|
||||
const timeoutError = new Error("Timed out waiting for server_info status message (5ms)");
|
||||
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => {
|
||||
throw timeoutError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Timed out waiting for server_info status message (5ms)");
|
||||
});
|
||||
|
||||
it("requires the catalog feature when server_info arrives without it", async () => {
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => createServerInfo({ paseoAgentCatalog: false }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CommandError } from "../../output/index.js";
|
||||
|
||||
export interface PaseoAgentCatalogFeatureClient extends Pick<DaemonClient, "waitForServerInfo"> {}
|
||||
|
||||
export async function requirePaseoAgentCatalogFeature(
|
||||
client: PaseoAgentCatalogFeatureClient,
|
||||
): Promise<void> {
|
||||
const serverInfo = await client.waitForServerInfo();
|
||||
if (serverInfo.features?.paseoAgentCatalog === true) {
|
||||
return;
|
||||
}
|
||||
throw {
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
@@ -1,23 +1,53 @@
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js";
|
||||
import { runRmCommand, type ProviderRmDependencies } from "./rm.js";
|
||||
import type { ProviderListItem, ProviderLsDependencies } from "./ls.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createProviderCommand(): Command {
|
||||
const provider = new Command("provider").description("Manage agent providers");
|
||||
export function createProviderCommand(
|
||||
dependencies: Partial<
|
||||
ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies
|
||||
> = {},
|
||||
): Command {
|
||||
const provider = new Command("provider").description(
|
||||
"Manage Paseo Agent model providers and agent provider models",
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider.command("ls").description("List available providers and status"),
|
||||
).action(withOutput(runLsCommand));
|
||||
provider.command("ls").description("List configured Paseo Agent model providers"),
|
||||
).action(
|
||||
withOutput<ProviderListItem, []>((options, command) =>
|
||||
runLsCommand(options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("models")
|
||||
.description("List models for a provider")
|
||||
.argument("<provider>", "Provider name (claude, codex, opencode)")
|
||||
.argument("<provider>", "Provider name")
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
|
||||
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
|
||||
(id, options, command) => runAddCommand(id, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("rm")
|
||||
.description("Remove a Paseo Agent model provider")
|
||||
.argument("<name>", "Provider instance name"),
|
||||
).action(
|
||||
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
|
||||
(name, options, command) => runRmCommand(name, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRmCommand } from "./rm.js";
|
||||
|
||||
function createServerInfo() {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentCatalog: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider ls", () => {
|
||||
it("renders an empty configured-provider table with headers", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
|
||||
expect(render(result, { format: "json" })).toBe("[]");
|
||||
});
|
||||
|
||||
it("lists configured model providers with catalog labels and auth states", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [
|
||||
{
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-model" }],
|
||||
},
|
||||
{
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
models: [{ id: "alpha-model" }],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
models: [{ id: "beta-model" }],
|
||||
auth: { kind: "oauth", configured: false, hint: "sign in again" },
|
||||
available: false,
|
||||
error: "auth missing",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
models: [{ id: "manual-model" }],
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "alpha-model",
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
auth: "Needs attention",
|
||||
available: "no",
|
||||
models: "beta-model",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
label: "manual-type",
|
||||
auth: "not configured",
|
||||
available: "yes",
|
||||
models: "manual-model",
|
||||
},
|
||||
]);
|
||||
|
||||
const table = render(result, { format: "table", noColor: true });
|
||||
expect(table).toContain("Connected");
|
||||
expect(table).toContain("Needs attention");
|
||||
expect(table).toContain("not configured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider rm", () => {
|
||||
it("removes a configured model provider", async () => {
|
||||
const removedNames: string[] = [];
|
||||
|
||||
const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
removePaseoAgentProvider: async (name: string) => {
|
||||
removedNames.push(name);
|
||||
return {
|
||||
requestId: "remove-1",
|
||||
success: true,
|
||||
removed: true,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(removedNames).toEqual(["alpha-main"]);
|
||||
expect(result.data).toEqual({ name: "alpha-main", removed: "yes" });
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,57 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import {
|
||||
renderTable,
|
||||
renderTableHeader,
|
||||
type CommandOptions,
|
||||
type ListResult,
|
||||
type OutputSchema,
|
||||
} from "../../output/index.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
export interface ProviderListItem {
|
||||
provider: ProviderSnapshotEntry["provider"];
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
status: string;
|
||||
enabled: "Enabled" | "Disabled";
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||
defaultMode: def.defaultModeId ?? "-",
|
||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||
}));
|
||||
interface ProviderLsClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close"
|
||||
> {}
|
||||
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
export interface ProviderLsDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderLsClient>;
|
||||
}
|
||||
|
||||
/** Schema for provider ls output */
|
||||
const defaultDependencies: ProviderLsDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "provider",
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{ header: "LABEL", field: "label", width: 16 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 12,
|
||||
color: (value) => {
|
||||
if (value === "available") return "green";
|
||||
if (value === "unavailable") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: "ENABLED", field: "enabled", width: 10 },
|
||||
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
|
||||
{ header: "MODES", field: "modes", width: 30 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 30 },
|
||||
],
|
||||
renderHuman: (result, options) => {
|
||||
if (result.type === "list" && result.data.length === 0) {
|
||||
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
|
||||
}
|
||||
return renderTable(result, options);
|
||||
},
|
||||
};
|
||||
|
||||
export type ProviderLsResult = ListResult<ProviderListItem>;
|
||||
@@ -55,40 +60,54 @@ export interface ProviderLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
function authState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string {
|
||||
return catalog.find((entry) => entry.id === providerType)?.label ?? providerType;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderLsDependencies> = {},
|
||||
): Promise<ProviderLsResult> {
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
};
|
||||
}
|
||||
const providersResult = await client.getPaseoAgentProviders();
|
||||
if (providersResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_LIST_FAILED",
|
||||
message: providersResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: snapshot.entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
enabled: !entry.enabled ? "Disabled" : "Enabled",
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
data: providersResult.providers.map((provider) => ({
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: catalogLabel(catalogResult.catalog, provider.providerType),
|
||||
auth: authState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
interface ProviderRmOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface ProviderRemoveItem {
|
||||
name: string;
|
||||
removed: string;
|
||||
}
|
||||
|
||||
interface ProviderRmClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "removePaseoAgentProvider" | "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderRmDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderRmClient>;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderRmDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "REMOVED", field: "removed", width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runRmCommand(
|
||||
name: string,
|
||||
options: ProviderRmOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderRmDependencies> = {},
|
||||
): Promise<SingleResult<ProviderRemoveItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const result = await client.removePaseoAgentProvider(name);
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "PROVIDER_REMOVE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider removal request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
name,
|
||||
removed: result.removed ? "yes" : "no",
|
||||
},
|
||||
schema: providerRemoveSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,7 @@ export function createScheduleCommand(): Command {
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--mode <mode>", "Provider-specific mode")
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { browserOpenCommand, openBrowserUrl } from "./open-browser.js";
|
||||
|
||||
describe("browserOpenCommand", () => {
|
||||
it("opens Windows URLs without cmd.exe shell parsing", () => {
|
||||
const url =
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback";
|
||||
|
||||
expect(browserOpenCommand(url, "win32")).toEqual({
|
||||
command: "rundll32.exe",
|
||||
args: ["url.dll,FileProtocolHandler", url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps macOS opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "darwin")).toEqual({
|
||||
command: "open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Linux opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "linux")).toEqual({
|
||||
command: "xdg-open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBrowserUrl", () => {
|
||||
it("spawns the resolved opener without launching a real browser", () => {
|
||||
const spawned: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: { stdio: "ignore"; detached: true };
|
||||
}> = [];
|
||||
const child = {
|
||||
on: () => child,
|
||||
unref: () => {},
|
||||
};
|
||||
|
||||
const opened = openBrowserUrl(
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
{
|
||||
platform: "win32",
|
||||
spawn: (command, args, options) => {
|
||||
spawned.push({ command, args, options });
|
||||
return child;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(opened).toBe(true);
|
||||
expect(spawned).toEqual([
|
||||
{
|
||||
command: "rundll32.exe",
|
||||
args: [
|
||||
"url.dll,FileProtocolHandler",
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
],
|
||||
options: { stdio: "ignore", detached: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns false when spawning the opener throws", () => {
|
||||
const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", {
|
||||
platform: "linux",
|
||||
spawn: () => {
|
||||
throw new Error("spawn failed");
|
||||
},
|
||||
});
|
||||
|
||||
expect(opened).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
interface BrowserOpenCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type BrowserOpenSpawn = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: "ignore"; detached: true },
|
||||
) => Pick<ChildProcessWithoutNullStreams, "on" | "unref">;
|
||||
|
||||
interface BrowserOpenDependencies {
|
||||
platform?: NodeJS.Platform;
|
||||
spawn?: BrowserOpenSpawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function browserOpenCommand(
|
||||
url: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): BrowserOpenCommand {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return { command: "open", args: [url] };
|
||||
case "win32":
|
||||
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
||||
default:
|
||||
return { command: "xdg-open", args: [url] };
|
||||
}
|
||||
}
|
||||
|
||||
export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean {
|
||||
const spawnBrowser = dependencies.spawn ?? spawn;
|
||||
const { command, args } = browserOpenCommand(url, dependencies.platform);
|
||||
|
||||
try {
|
||||
const child = spawnBrowser(command, args, { stdio: "ignore", detached: true });
|
||||
child.on("error", () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@
|
||||
/**
|
||||
* Phase 15: Provider Command Tests
|
||||
*
|
||||
* Tests provider commands for listing providers and models.
|
||||
* Provider ls data is static, while provider models are fetched via daemon integration.
|
||||
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
|
||||
* Tests provider commands for configured Paseo Agent model providers and agent
|
||||
* provider model listing. This test uses an isolated daemon to avoid coupling to
|
||||
* a user's long-running daemon.
|
||||
*
|
||||
* Tests:
|
||||
* - provider --help shows subcommands
|
||||
* - provider ls lists all providers
|
||||
* - provider ls --json outputs valid JSON
|
||||
* - provider ls --quiet outputs provider names only
|
||||
* - provider ls on a fresh daemon prints an empty configured-provider table
|
||||
* - provider add stores an API-key model provider without network validation
|
||||
* - repeated provider add updates the same provider instance
|
||||
* - provider add rejects unknown catalog ids with known ids
|
||||
* - provider rm removes a configured model provider
|
||||
* - provider add uses catalog default models when present
|
||||
* - provider models claude lists claude models
|
||||
* - provider models codex lists codex models
|
||||
* - provider models opencode lists opencode models
|
||||
@@ -20,14 +23,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createE2ETestContext,
|
||||
createTempDirs,
|
||||
runPaseoCli,
|
||||
startTestDaemon,
|
||||
} from "./helpers/test-daemon.ts";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Provider Commands ===\n");
|
||||
|
||||
@@ -38,10 +34,26 @@ interface ProviderModel {
|
||||
}
|
||||
|
||||
interface ProviderListRow {
|
||||
provider: string;
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
status: string;
|
||||
enabled: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface CliResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function assertExitCode(result: CliResult, expected: number, message: string): void {
|
||||
assert.strictEqual(result.exitCode, expected, `${message}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
function assertNonZeroExitCode(result: CliResult, message: string): void {
|
||||
assert.notStrictEqual(result.exitCode, 0, `${message}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
@@ -131,6 +143,24 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
||||
return attemptRun(1);
|
||||
}
|
||||
|
||||
function parseProviderListJson(stdout: string): ProviderListRow[] {
|
||||
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
|
||||
assert(Array.isArray(data), "provider ls --json output should be an array");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getProviderRows(): Promise<ProviderListRow[]> {
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(result, 0, "provider ls --json should exit 0");
|
||||
return parseProviderListJson(result.stdout);
|
||||
}
|
||||
|
||||
function assertProviderTableHeader(stdout: string): void {
|
||||
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
|
||||
assert(stdout.includes(header), `provider ls table should include ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaudeModels(data: ProviderModel[]): void {
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
@@ -165,144 +195,137 @@ try {
|
||||
{
|
||||
console.log("Test 1: provider --help shows subcommands");
|
||||
const result = await ctx.paseo(["provider", "--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||
assertExitCode(result, 0, "provider --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
||||
assert(result.stdout.includes("add"), "help should mention add");
|
||||
assert(result.stdout.includes("rm"), "help should mention rm");
|
||||
assert(result.stdout.includes("models"), "help should mention models");
|
||||
console.log("✓ provider --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: provider ls lists all providers
|
||||
// Test 2: provider ls on a fresh daemon shows no configured providers
|
||||
{
|
||||
console.log("Test 2: provider ls lists all providers");
|
||||
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
|
||||
const result = await ctx.paseo(["provider", "ls"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
|
||||
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
|
||||
assert(
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
assertExitCode(result, 0, "provider ls should exit 0");
|
||||
assertProviderTableHeader(result.stdout);
|
||||
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
|
||||
|
||||
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(jsonResult, 0, "provider ls --json should exit 0");
|
||||
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
|
||||
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
|
||||
}
|
||||
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
// Test 3: provider add openrouter stores a dummy key without network validation
|
||||
{
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "codex"),
|
||||
"should include codex",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
|
||||
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
|
||||
assert(result.stdout.includes("yes"), "add output should show the provider as available");
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
|
||||
assert.deepStrictEqual(rows[0], {
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
label: "OpenRouter",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
|
||||
}
|
||||
|
||||
// Test 4: provider ls includes disabled providers
|
||||
// Test 4: provider add is idempotent for the same instance name
|
||||
{
|
||||
console.log("Test 4: provider ls includes disabled providers");
|
||||
const { paseoHome, workDir } = await createTempDirs();
|
||||
await writeFile(
|
||||
join(paseoHome, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
console.log("Test 4: provider add is idempotent for the same instance name");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key-2\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
|
||||
assert.strictEqual(rows[0]?.name, "openrouter");
|
||||
assert.strictEqual(rows[0]?.label, "OpenRouter");
|
||||
console.log("✓ provider add is idempotent for the same instance name\n");
|
||||
}
|
||||
|
||||
// Test 5: provider add rejects unknown catalog ids with known ids
|
||||
{
|
||||
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
|
||||
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
|
||||
stdin: "dummy-key\n",
|
||||
});
|
||||
assertNonZeroExitCode(result, "provider add should fail for unknown ids");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("nonsense-id"), "error should mention the requested id");
|
||||
assert(output.includes("Known provider ids"), "error should mention known provider ids");
|
||||
assert(output.includes("openrouter"), "known ids should include openrouter");
|
||||
assert(output.includes("kimi"), "known ids should include kimi");
|
||||
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
|
||||
}
|
||||
|
||||
// Test 6: provider rm removes a configured provider
|
||||
{
|
||||
console.log("Test 6: provider rm removes a configured provider");
|
||||
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
|
||||
assertExitCode(result, 0, "provider rm should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
|
||||
assert(result.stdout.includes("yes"), "rm output should report removal");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
|
||||
const table = await ctx.paseo(["provider", "ls"]);
|
||||
assertExitCode(table, 0, "provider ls should stay successful after removal");
|
||||
assertProviderTableHeader(table.stdout);
|
||||
console.log("✓ provider rm removes a configured provider\n");
|
||||
}
|
||||
|
||||
// Test 7: provider add uses catalog default models when present
|
||||
{
|
||||
console.log("Test 7: provider add uses catalog default models when present");
|
||||
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
|
||||
stdin: "dummy-kimi-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add kimi should exit 0");
|
||||
assert(result.stdout.includes("kimi"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
const kimi = rows.find((row) => row.name === "kimi");
|
||||
assert(kimi, "provider ls should include the kimi instance");
|
||||
assert.strictEqual(kimi.label, "Kimi Coding Plan");
|
||||
assert.strictEqual(kimi.auth, "Connected");
|
||||
assert.strictEqual(kimi.available, "yes");
|
||||
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
|
||||
assert(
|
||||
kimi.models
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean).length > 0,
|
||||
"kimi should list at least one catalog-derived model id",
|
||||
);
|
||||
|
||||
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
|
||||
try {
|
||||
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
|
||||
const claude = data.find((p) => p.provider === "claude");
|
||||
assert(claude, "disabled claude provider should stay in provider ls");
|
||||
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
|
||||
|
||||
const opencode = data.find((p) => p.provider === "opencode");
|
||||
assert(opencode, "enabled opencode provider should stay in provider ls");
|
||||
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
|
||||
|
||||
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
|
||||
assert.notStrictEqual(
|
||||
modelsResult.exitCode,
|
||||
0,
|
||||
"provider models should fail for disabled providers",
|
||||
);
|
||||
const output = modelsResult.stdout + modelsResult.stderr;
|
||||
assert(
|
||||
output.includes("Provider claude is disabled"),
|
||||
"provider models should surface the daemon disabled error",
|
||||
);
|
||||
assert(
|
||||
!output.includes("claude-sonnet"),
|
||||
"provider models should not print fallback models for disabled providers",
|
||||
);
|
||||
} finally {
|
||||
await disabledCtx.stop();
|
||||
}
|
||||
console.log("✓ provider ls includes disabled providers\n");
|
||||
console.log("✓ provider add uses catalog default models when present\n");
|
||||
}
|
||||
|
||||
// Test 5: provider ls --quiet outputs provider names only
|
||||
// Test 8: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 5: provider ls --quiet outputs provider names only");
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
// Test 6: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 6: provider models claude lists canonical model aliases");
|
||||
console.log("Test 8: provider models claude lists canonical model aliases");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assertClaudeModels(data);
|
||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
||||
}
|
||||
|
||||
// Test 7: provider models codex includes concrete codex model IDs
|
||||
// Test 9: provider models codex includes concrete codex model IDs
|
||||
{
|
||||
console.log("Test 7: provider models codex includes concrete codex model IDs");
|
||||
console.log("Test 9: provider models codex includes concrete codex model IDs");
|
||||
const data = await runProviderModelsJson("codex");
|
||||
assert(data.length >= 1, "codex model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -322,9 +345,9 @@ try {
|
||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
||||
}
|
||||
|
||||
// Test 8: provider models opencode returns namespaced model IDs
|
||||
// Test 10: provider models opencode returns namespaced model IDs
|
||||
{
|
||||
console.log("Test 8: provider models opencode returns namespaced model IDs");
|
||||
console.log("Test 10: provider models opencode returns namespaced model IDs");
|
||||
const data = await runProviderModelsJson("opencode");
|
||||
assert(data.length >= 1, "opencode model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -343,11 +366,11 @@ try {
|
||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
||||
}
|
||||
|
||||
// Test 9: provider models unknown fails with error
|
||||
// Test 11: provider models unknown fails with error
|
||||
{
|
||||
console.log("Test 9: provider models unknown fails with error");
|
||||
console.log("Test 11: provider models unknown fails with error");
|
||||
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||
assertNonZeroExitCode(result, "should fail for unknown provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
|
||||
@@ -356,9 +379,9 @@ try {
|
||||
console.log("✓ provider models unknown fails with error\n");
|
||||
}
|
||||
|
||||
// Test 10: provider models --json outputs valid JSON
|
||||
// Test 12: provider models --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 10: provider models --json outputs valid JSON");
|
||||
console.log("Test 12: provider models --json outputs valid JSON");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(
|
||||
@@ -371,15 +394,15 @@ try {
|
||||
console.log("✓ provider models --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 11: provider models --quiet outputs model IDs only
|
||||
// Test 13: provider models --quiet outputs model IDs only
|
||||
{
|
||||
console.log("Test 11: provider models --quiet outputs model IDs only");
|
||||
console.log("Test 13: provider models --quiet outputs model IDs only");
|
||||
assert(
|
||||
claudeModelIdsFromJson.length > 0,
|
||||
"claude model IDs should be captured from --json output",
|
||||
);
|
||||
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
assertExitCode(result, 0, "provider models claude --quiet should exit 0");
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
assert.strictEqual(
|
||||
lines.length,
|
||||
|
||||
@@ -43,6 +43,13 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
const TEST_DAEMON_HOST = "127.0.0.1";
|
||||
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
||||
// local developer credentials cannot hide CI-missing-auth failures.
|
||||
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||
@@ -59,6 +66,14 @@ function createOutputCapture(): OutputCapture {
|
||||
return { value: "", truncated: false };
|
||||
}
|
||||
|
||||
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const scrubbed = { ...env };
|
||||
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
||||
delete scrubbed[key];
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
|
||||
const next = target.value + chunk.toString();
|
||||
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
|
||||
@@ -234,7 +249,7 @@ export async function startTestDaemon(options?: {
|
||||
|
||||
// Start daemon process using tsx to run TypeScript directly
|
||||
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
|
||||
env: {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
@@ -242,7 +257,7 @@ export async function startTestDaemon(options?: {
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
},
|
||||
}),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
@@ -336,6 +351,7 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -346,15 +362,15 @@ export async function runPaseoCli(
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
|
||||
env: {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
...options?.env,
|
||||
},
|
||||
}),
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
@@ -369,6 +385,10 @@ export async function runPaseoCli(
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
if (options?.stdin !== undefined) {
|
||||
proc.stdin?.end(options.stdin);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, "SIGKILL");
|
||||
@@ -406,7 +426,7 @@ export async function createE2ETestContext(options?: {
|
||||
/** Run a paseo CLI command against this daemon */
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -418,7 +438,7 @@ export async function createE2ETestContext(options?: {
|
||||
|
||||
const paseo = (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
) => runPaseoCli(ctx, args, opts);
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,44 @@ const TEST_ENV_DEFAULTS = {
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
||||
// local developer credentials cannot hide CI-missing-auth failures.
|
||||
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const scrubbed = { ...env };
|
||||
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
||||
delete scrubbed[key];
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function testCliEnv(port: number): NodeJS.ProcessEnv {
|
||||
return scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
||||
});
|
||||
}
|
||||
|
||||
function testDaemonEnv(port: number, paseoHome: string): NodeJS.ProcessEnv {
|
||||
return scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
||||
CI: "true",
|
||||
});
|
||||
}
|
||||
|
||||
function killPidTree(pid: number, signal: NodeJS.Signals): void {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
@@ -85,7 +123,7 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
|
||||
*/
|
||||
async function probeDaemon(port: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
|
||||
const result = await $({ env: testCliEnv(port) })`paseo agent ls`.nothrow();
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -110,8 +148,9 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
|
||||
*/
|
||||
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
|
||||
$.verbose = false;
|
||||
const daemon =
|
||||
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
|
||||
const daemon = $({
|
||||
env: testDaemonEnv(port, paseoHome),
|
||||
})`paseo daemon start --foreground`.nothrow();
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -125,7 +164,7 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
// Helper to run CLI commands against test daemon
|
||||
const paseo = (args: string[]): ProcessPromise => {
|
||||
$.verbose = false;
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
|
||||
return $({ env: testCliEnv(port) })`paseo ${args}`.nothrow();
|
||||
};
|
||||
|
||||
// Cleanup function
|
||||
|
||||
@@ -75,6 +75,30 @@ function createMockTransport() {
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
sendServerInfo: (input: { serverId?: string; features?: Record<string, boolean> } = {}) => {
|
||||
onMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: input.serverId ?? `srv_test_${serverInfoOrdinal++}`,
|
||||
hostname: null,
|
||||
version: null,
|
||||
...(input.features ? { features: input.features } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
triggerOpenWithoutServerInfo: (options?: { preserveSent?: boolean }) => {
|
||||
onOpen();
|
||||
if (!options?.preserveSent) {
|
||||
// Ignore HELLO handshake payloads in assertions.
|
||||
sent.length = 0;
|
||||
}
|
||||
},
|
||||
triggerOpen: (options?: { preserveSent?: boolean }) => {
|
||||
onOpen();
|
||||
if (!options?.preserveSent) {
|
||||
@@ -191,6 +215,57 @@ test("advertises consumer-provided browser automation capabilities", async () =>
|
||||
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForServerInfo resolves when server_info arrives after the socket opens", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "server_info_wait_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const serverInfoPromise = client.waitForServerInfo(100);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpenWithoutServerInfo();
|
||||
setTimeout(() => {
|
||||
mock.sendServerInfo({
|
||||
serverId: "srv_delayed_server_info",
|
||||
features: { paseoAgentCatalog: true },
|
||||
});
|
||||
}, 25);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(serverInfoPromise).resolves.toMatchObject({
|
||||
status: "server_info",
|
||||
serverId: "srv_delayed_server_info",
|
||||
features: { paseoAgentCatalog: true },
|
||||
});
|
||||
await connectPromise;
|
||||
});
|
||||
|
||||
test("waitForServerInfo rejects clearly when server_info never arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "server_info_timeout_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const serverInfoPromise = client.waitForServerInfo(50);
|
||||
const rejection = expect(serverInfoPromise).rejects.toThrow(
|
||||
"Timed out waiting for server_info status message (50ms)",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
const noopLogger: Logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
|
||||
@@ -94,7 +94,21 @@ import type {
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
MutableDaemonConfig,
|
||||
MutableDaemonConfigPatch,
|
||||
PaseoAgentGetCatalogResponse,
|
||||
PaseoAgentGetProvidersResponse,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentOAuthStoreCredentialResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentRenameProviderResponse,
|
||||
PaseoAgentRemoveProviderResponse,
|
||||
PaseoAgentSetProviderRequest,
|
||||
PaseoAgentSetProviderResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
||||
import {
|
||||
@@ -828,6 +842,7 @@ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
||||
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_SERVER_INFO_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
||||
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
@@ -3861,6 +3876,119 @@ export class DaemonClient {
|
||||
this.sendSessionMessageStrict(response);
|
||||
}
|
||||
|
||||
async getPaseoAgentProviders(
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentGetProvidersResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.get_providers.request",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getPaseoAgentCatalog(requestId?: string): Promise<PaseoAgentGetCatalogResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async setPaseoAgentProvider(
|
||||
input: Omit<PaseoAgentSetProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentSetProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
...(input.displayName ? { displayName: input.displayName } : {}),
|
||||
options: input.options,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async removePaseoAgentProvider(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentRemoveProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.remove_provider.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async renamePaseoAgentProvider(
|
||||
input: Omit<PaseoAgentRenameProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentRenameProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async startPaseoAgentOAuth(
|
||||
name: string,
|
||||
options?: string | { mode?: string; requestId?: string },
|
||||
): Promise<PaseoAgentOAuthStartResponse["payload"]> {
|
||||
const requestId = typeof options === "string" ? options : options?.requestId;
|
||||
const mode = typeof options === "string" ? undefined : options?.mode;
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
name,
|
||||
...(mode ? { mode } : {}),
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async completePaseoAgentOAuth(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentOAuthCompleteResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async storePaseoAgentOAuthCredential(input: {
|
||||
name: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
requestId?: string;
|
||||
}): Promise<PaseoAgentOAuthStoreCredentialResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
name: input.name,
|
||||
credential: input.credential,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4634,6 +4762,29 @@ export class DaemonClient {
|
||||
return this.lastServerInfoMessage;
|
||||
}
|
||||
|
||||
async waitForServerInfo(
|
||||
timeoutMs = DEFAULT_SERVER_INFO_TIMEOUT_MS,
|
||||
): Promise<ServerInfoStatusPayload> {
|
||||
if (this.lastServerInfoMessage) {
|
||||
return this.lastServerInfoMessage;
|
||||
}
|
||||
|
||||
const { promise } = this.waitForWithCancel<ServerInfoStatusPayload>(
|
||||
(msg) => {
|
||||
if (msg.type !== "status") {
|
||||
return null;
|
||||
}
|
||||
return parseServerInfoStatusPayload(msg.payload);
|
||||
},
|
||||
timeoutMs,
|
||||
{
|
||||
timeoutMessage: `Timed out waiting for server_info status message (${timeoutMs}ms)`,
|
||||
},
|
||||
);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
private resolveTransportUrlForAttempt(): string {
|
||||
return this.config.url;
|
||||
}
|
||||
@@ -5151,10 +5302,12 @@ export class DaemonClient {
|
||||
private waitForWithCancel<T>(
|
||||
predicate: (msg: SessionOutboundMessage) => T | null,
|
||||
timeout = 30000,
|
||||
_options?: { skipQueue?: boolean },
|
||||
options?: { skipQueue?: boolean; timeoutMessage?: string },
|
||||
): WaitHandle<T> {
|
||||
// Capture stack trace at call site, not inside setTimeout
|
||||
const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
|
||||
const timeoutError = new Error(
|
||||
options?.timeoutMessage ?? `Timeout waiting for message (${timeout}ms)`,
|
||||
);
|
||||
|
||||
let waiter: Waiter<T> | null = null;
|
||||
let settled = false;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
describe("Paseo Agent config RPC schemas", () => {
|
||||
test("parses provider config requests with providerType outside the message type field", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-openrouter",
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("config.paseo_agent.set_provider.request");
|
||||
expect(parsed.providerType).toBe("openrouter");
|
||||
});
|
||||
|
||||
test("parses provider config requests without model overrides", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-openrouter",
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.options.models).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses a provider type this client has never heard of (new daemon, old client)", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: "req-get-future",
|
||||
defaultModel: null,
|
||||
providers: [
|
||||
{
|
||||
name: "kimi-main",
|
||||
providerType: "kimi-coding",
|
||||
models: [{ id: "kimi-k3" }],
|
||||
auth: { kind: "future_auth_kind", configured: true, source: "future_source" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.providers[0]?.providerType).toBe("kimi-coding");
|
||||
});
|
||||
|
||||
test("parses redacted provider responses without raw secret fields", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: "req-get",
|
||||
defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
providers: [
|
||||
{
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.providers[0]?.providerType).toBe("openrouter");
|
||||
expect(JSON.stringify(parsed)).not.toContain("apiKey");
|
||||
});
|
||||
|
||||
test("parses get_catalog request and response with forward-tolerant entries", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
requestId: "req-catalog",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: "req-catalog",
|
||||
catalog: [
|
||||
{
|
||||
id: "future-provider",
|
||||
label: "Future Provider",
|
||||
iconName: "sparkles",
|
||||
docsUrl: "https://docs.example.test/provider",
|
||||
api: "future-api",
|
||||
baseUrl: "https://api.example.test",
|
||||
headers: { "User-Agent": "PaseoTest/1" },
|
||||
compat: { minHost: "0.1.104" },
|
||||
auth: { kind: "future_oauth", flow: "future-flow", extraAuthField: true },
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
label: "Future Model",
|
||||
futureModelField: "kept",
|
||||
},
|
||||
],
|
||||
futureEntryField: { keep: true },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.type).toBe("config.paseo_agent.get_catalog.request");
|
||||
expect(response.payload.catalog[0]?.auth.kind).toBe("future_oauth");
|
||||
expect(response.payload.catalog[0]?.futureEntryField).toEqual({ keep: true });
|
||||
expect(response.payload.catalog[0]?.models[0]?.futureModelField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start request and device-code response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "req-oauth-start",
|
||||
name: "subscription",
|
||||
mode: "device_code",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
futureField: "kept",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(request.mode).toBe("device_code");
|
||||
expect(response.payload.authorization?.kind).toBe("device_code");
|
||||
expect(response.payload.authorization?.futureField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start auth-url response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start-url",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://auth.example.test/oauth",
|
||||
instructions: "Open this URL to continue.",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.authorization?.url).toBe("https://auth.example.test/oauth");
|
||||
});
|
||||
|
||||
test("parses oauth.complete request and response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
requestId: "req-oauth-complete",
|
||||
name: "subscription",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-complete",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(response.payload.auth?.configured).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves future OAuth credential fields on inbound schema parse", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
requestId: "req-oauth",
|
||||
name: "subscription",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
accountId: "acct_123",
|
||||
futureField: { keep: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.credential.futureField).toEqual({ keep: true });
|
||||
});
|
||||
|
||||
test("parses oauth.store_credential response without credential material", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: "req-oauth",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.name).toBe("subscription");
|
||||
expect(JSON.stringify(parsed)).not.toContain("access-token");
|
||||
expect(JSON.stringify(parsed)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("parses provider rename request and response with display name", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
requestId: "req-rename-provider",
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: "req-rename-provider",
|
||||
success: true,
|
||||
provider: {
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
providerType: "chatgpt",
|
||||
models: [{ id: "gpt-5.4-mini" }],
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.displayName).toBe("Work account");
|
||||
expect(response.payload.provider?.displayName).toBe("Work account");
|
||||
});
|
||||
|
||||
test("parses ChatGPT provider config separately from credential storage", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-chatgpt",
|
||||
name: "chatgpt",
|
||||
providerType: "openai-codex",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.providerType).toBe("openai-codex");
|
||||
expect(JSON.stringify(parsed)).not.toContain("access-token");
|
||||
});
|
||||
});
|
||||
@@ -1922,6 +1922,151 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// Open on the wire on purpose: the daemon's paseo-agent config schema owns the
|
||||
// closed set of known types. A new type added daemon-side must not break an
|
||||
// older client's envelope parse (protocol contract: never narrow, old clients
|
||||
// keep parsing new daemons).
|
||||
const PaseoAgentProviderTypeSchema = z.string().min(1);
|
||||
|
||||
const PaseoAgentProviderModelConfigSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentSetProviderOptionsSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
models: z.array(PaseoAgentProviderModelConfigSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentOAuthCredentialSchema = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
access: z.string(),
|
||||
refresh: z.string(),
|
||||
expires: z.number(),
|
||||
accountId: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogAuthSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentCatalogEntrySchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
iconName: z.string().min(1).optional(),
|
||||
docsUrl: z.string().optional(),
|
||||
api: z.string().min(1),
|
||||
baseUrl: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: z.record(z.string(), z.unknown()).optional(),
|
||||
auth: PaseoAgentCatalogAuthSchema,
|
||||
models: z.array(PaseoAgentCatalogModelSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentProviderAuthStateSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
configured: z.boolean(),
|
||||
source: z.string().min(1).optional(),
|
||||
hint: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const RedactedPaseoAgentProviderConfigSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
models: z.array(PaseoAgentProviderModelConfigSchema),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
available: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentGetProvidersRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_providers.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
options: PaseoAgentSetProviderOptionsSchema,
|
||||
});
|
||||
|
||||
export const PaseoAgentRemoveProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.remove_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStartRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
mode: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
credential: PaseoAgentOAuthCredentialSchema,
|
||||
});
|
||||
|
||||
export const ListCommandsRequestSchema = z.object({
|
||||
type: z.literal("list_commands_request"),
|
||||
agentId: z.string(),
|
||||
@@ -2073,6 +2218,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListProviderModesRequestMessageSchema,
|
||||
ListProviderFeaturesRequestMessageSchema,
|
||||
PaseoAgentGetProvidersRequestSchema,
|
||||
PaseoAgentGetCatalogRequestSchema,
|
||||
PaseoAgentSetProviderRequestSchema,
|
||||
PaseoAgentRemoveProviderRequestSchema,
|
||||
PaseoAgentRenameProviderRequestSchema,
|
||||
PaseoAgentOAuthStartRequestSchema,
|
||||
PaseoAgentOAuthCompleteRequestSchema,
|
||||
PaseoAgentOAuthStoreCredentialRequestSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
@@ -2364,6 +2517,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
|
||||
paseoAgentConfig: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104
|
||||
paseoAgentCatalog: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3870,6 +4027,100 @@ export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetProvidersResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_providers.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
defaultModel: z.string().nullable(),
|
||||
providers: z.array(RedactedPaseoAgentProviderConfigSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
catalog: z.array(PaseoAgentCatalogEntrySchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
provider: RedactedPaseoAgentProviderConfigSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentRemoveProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.remove_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
removed: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
provider: RedactedPaseoAgentProviderConfigSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaseoAgentOAuthStartAuthorizationSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
url: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
userCode: z.string().optional(),
|
||||
verificationUri: z.string().optional(),
|
||||
intervalSeconds: z.number().optional(),
|
||||
expiresInSeconds: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentOAuthStartResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
authorization: PaseoAgentOAuthStartAuthorizationSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const ProviderAvailabilitySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
available: z.boolean(),
|
||||
@@ -4265,6 +4516,14 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
PaseoAgentGetProvidersResponseSchema,
|
||||
PaseoAgentGetCatalogResponseSchema,
|
||||
PaseoAgentSetProviderResponseSchema,
|
||||
PaseoAgentRemoveProviderResponseSchema,
|
||||
PaseoAgentRenameProviderResponseSchema,
|
||||
PaseoAgentOAuthStartResponseSchema,
|
||||
PaseoAgentOAuthCompleteResponseSchema,
|
||||
PaseoAgentOAuthStoreCredentialResponseSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
@@ -4397,6 +4656,26 @@ export type ListProviderModesResponseMessage = z.infer<
|
||||
export type ListProviderFeaturesResponseMessage = z.infer<
|
||||
typeof ListProviderFeaturesResponseMessageSchema
|
||||
>;
|
||||
export type RedactedPaseoAgentProviderConfig = z.infer<
|
||||
typeof RedactedPaseoAgentProviderConfigSchema
|
||||
>;
|
||||
export type PaseoAgentProviderAuthState = z.infer<typeof PaseoAgentProviderAuthStateSchema>;
|
||||
export type PaseoAgentOAuthCredential = z.infer<typeof PaseoAgentOAuthCredentialSchema>;
|
||||
export type PaseoAgentCatalogEntry = z.infer<typeof PaseoAgentCatalogEntrySchema>;
|
||||
export type PaseoAgentGetProvidersResponse = z.infer<typeof PaseoAgentGetProvidersResponseSchema>;
|
||||
export type PaseoAgentGetCatalogResponse = z.infer<typeof PaseoAgentGetCatalogResponseSchema>;
|
||||
export type PaseoAgentSetProviderResponse = z.infer<typeof PaseoAgentSetProviderResponseSchema>;
|
||||
export type PaseoAgentRemoveProviderResponse = z.infer<
|
||||
typeof PaseoAgentRemoveProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentRenameProviderResponse = z.infer<
|
||||
typeof PaseoAgentRenameProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResponse = z.infer<typeof PaseoAgentOAuthStartResponseSchema>;
|
||||
export type PaseoAgentOAuthCompleteResponse = z.infer<typeof PaseoAgentOAuthCompleteResponseSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialResponse = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialResponseSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
|
||||
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
|
||||
@@ -4473,6 +4752,16 @@ export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRe
|
||||
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||
typeof ListProviderFeaturesRequestMessageSchema
|
||||
>;
|
||||
export type PaseoAgentGetProvidersRequest = z.infer<typeof PaseoAgentGetProvidersRequestSchema>;
|
||||
export type PaseoAgentGetCatalogRequest = z.infer<typeof PaseoAgentGetCatalogRequestSchema>;
|
||||
export type PaseoAgentSetProviderRequest = z.infer<typeof PaseoAgentSetProviderRequestSchema>;
|
||||
export type PaseoAgentRemoveProviderRequest = z.infer<typeof PaseoAgentRemoveProviderRequestSchema>;
|
||||
export type PaseoAgentRenameProviderRequest = z.infer<typeof PaseoAgentRenameProviderRequestSchema>;
|
||||
export type PaseoAgentOAuthStartRequest = z.infer<typeof PaseoAgentOAuthStartRequestSchema>;
|
||||
export type PaseoAgentOAuthCompleteRequest = z.infer<typeof PaseoAgentOAuthCompleteRequestSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialRequest = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialRequestSchema
|
||||
>;
|
||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||
typeof ListAvailableProvidersRequestMessageSchema
|
||||
>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,10 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
|
||||
"kiro",
|
||||
"minimax",
|
||||
"omp",
|
||||
"openai",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"paseo",
|
||||
"pi",
|
||||
];
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"@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",
|
||||
"@earendil-works/pi-tui": "0.77.0",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
@@ -95,6 +99,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1085,7 +1085,8 @@ export class AgentManager {
|
||||
this.foregroundRuns.clearAgent(agentId, existing);
|
||||
await this.closeReloadedSession(existing.session, agentId);
|
||||
|
||||
if (rehydrateFromDisk) {
|
||||
const canRehydrateFromProviderPersistence = rehydrateFromDisk && Boolean(handle);
|
||||
if (canRehydrateFromProviderPersistence) {
|
||||
// Wipe both durable and in-memory timeline so registerSession mints a
|
||||
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
|
||||
// provider history into an empty timeline.
|
||||
@@ -1100,7 +1101,7 @@ export class AgentManager {
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
|
||||
historyPrimed: canRehydrateFromProviderPersistence ? false : preservedHistoryPrimed,
|
||||
lastUsage: preservedLastUsage,
|
||||
lastError: preservedLastError,
|
||||
attention: preservedAttention,
|
||||
@@ -1812,11 +1813,7 @@ export class AgentManager {
|
||||
nextLifecycle = "idle";
|
||||
}
|
||||
mutableAgent.lifecycle = nextLifecycle;
|
||||
const persistenceHandle =
|
||||
mutableAgent.session.describePersistence() ??
|
||||
(mutableAgent.runtimeInfo?.sessionId
|
||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||
: null);
|
||||
const persistenceHandle = this.describePersistenceHandle(mutableAgent);
|
||||
if (persistenceHandle) {
|
||||
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
||||
}
|
||||
@@ -2832,11 +2829,11 @@ export class AgentManager {
|
||||
newInfo.sessionId !== agent.runtimeInfo?.sessionId ||
|
||||
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
||||
agent.runtimeInfo = newInfo;
|
||||
if (!agent.persistence && newInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
||||
agent.cwd,
|
||||
);
|
||||
if (!agent.persistence) {
|
||||
const persistenceHandle = this.describePersistenceHandle(agent);
|
||||
if (persistenceHandle) {
|
||||
agent.persistence = attachPersistenceCwd(persistenceHandle, agent.cwd);
|
||||
}
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
if (changed && options?.emit !== false) {
|
||||
@@ -3128,7 +3125,7 @@ export class AgentManager {
|
||||
|
||||
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
const handle = agent.session.describePersistence();
|
||||
const handle = this.describePersistenceHandle(agent);
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
@@ -3138,6 +3135,19 @@ export class AgentManager {
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private describePersistenceHandle(agent: ActiveManagedAgent): AgentPersistenceHandle | null {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
return handle;
|
||||
}
|
||||
if (!agent.capabilities.supportsSessionPersistence) {
|
||||
return null;
|
||||
}
|
||||
return agent.runtimeInfo?.sessionId
|
||||
? { provider: agent.provider, sessionId: agent.runtimeInfo.sessionId }
|
||||
: null;
|
||||
}
|
||||
|
||||
private async onStreamTimelineEvent(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
@@ -3719,11 +3729,13 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
|
||||
const client = this.clients.get(storedConfig.provider);
|
||||
const mcpBaseUrl = client && this.shouldEnablePaseoTools(client) ? this.mcpBaseUrl : null;
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
||||
withRuntimePaseoMcpServer({
|
||||
config: storedConfig,
|
||||
agentId,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
mcpBaseUrl,
|
||||
mcpAuthToken: this.mcpAuthToken,
|
||||
}),
|
||||
);
|
||||
@@ -3756,7 +3768,7 @@ export class AgentManager {
|
||||
},
|
||||
};
|
||||
if (
|
||||
this.paseoToolsEnabled &&
|
||||
this.shouldEnablePaseoTools(client) &&
|
||||
client.capabilities.supportsNativePaseoTools &&
|
||||
this.paseoToolCatalogFactory
|
||||
) {
|
||||
@@ -3765,6 +3777,10 @@ export class AgentManager {
|
||||
return context;
|
||||
}
|
||||
|
||||
private shouldEnablePaseoTools(client: AgentClient): boolean {
|
||||
return this.paseoToolsEnabled || client.capabilities.requiresPaseoTools === true;
|
||||
}
|
||||
|
||||
private resolveProviderLaunchConfig(
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
|
||||
@@ -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,17 @@ export interface BuildProviderRegistryOptions {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
isDev?: boolean;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* Opaque Paseo Agent config blob. The registry only forwards it to the
|
||||
* paseo-agent client factory; it never reads the nested model providers.
|
||||
*/
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
}
|
||||
|
||||
interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses"
|
||||
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome"
|
||||
> {
|
||||
providerParams?: unknown;
|
||||
customProvider?: {
|
||||
@@ -159,6 +167,12 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
}),
|
||||
paseo: (logger, _runtimeSettings, options) =>
|
||||
new PaseoAgentClient({
|
||||
logger,
|
||||
config: options?.paseoAgentConfig ?? {},
|
||||
paseoHome: options?.paseoHome,
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
};
|
||||
@@ -567,7 +581,10 @@ function createResolvedProviderClient(
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
|
||||
options: Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome"
|
||||
>,
|
||||
isDev: boolean,
|
||||
): Map<string, ResolvedProvider> {
|
||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||
@@ -598,6 +615,8 @@ function buildResolvedBuiltinProviders(
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
managedProcesses: options.managedProcesses,
|
||||
providerParams: override?.params,
|
||||
paseoAgentConfig: options.paseoAgentConfig,
|
||||
paseoHome: options.paseoHome,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -717,6 +736,8 @@ export function buildProviderRegistry(
|
||||
{
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
managedProcesses: options?.managedProcesses,
|
||||
paseoAgentConfig: options?.paseoAgentConfig,
|
||||
paseoHome: options?.paseoHome,
|
||||
},
|
||||
options?.isDev === true,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
@@ -1018,6 +1019,105 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager applyPaseoAgentConfig", () => {
|
||||
test("keeps unrelated provider loading state when Paseo Agent config changes", async () => {
|
||||
let resolveFetchStarted: (() => void) | undefined;
|
||||
let releaseFetch: (() => void) | undefined;
|
||||
const fetchStarted = new Promise<void>((resolveStarted) => {
|
||||
resolveFetchStarted = resolveStarted;
|
||||
});
|
||||
const fetchRelease = new Promise<void>((resolveRelease) => {
|
||||
releaseFetch = resolveRelease;
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
},
|
||||
extraClients: {
|
||||
claude: createExtraClient("claude", {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async fetchCatalog() {
|
||||
resolveFetchStarted?.();
|
||||
await fetchRelease;
|
||||
return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
paseoAgentConfig: {},
|
||||
});
|
||||
try {
|
||||
manager.getSnapshot();
|
||||
await fetchStarted;
|
||||
|
||||
manager.applyPaseoAgentConfig({
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.getSnapshot().find((entry) => entry.provider === "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
});
|
||||
} finally {
|
||||
releaseFetch?.();
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes Paseo Agent models without daemon restart", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
claude: { enabled: false },
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
},
|
||||
paseoAgentConfig: {},
|
||||
});
|
||||
try {
|
||||
manager.applyPaseoAgentConfig({
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = await manager.listModels({ provider: "paseo", wait: true });
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "paseo",
|
||||
id: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
label: "Claude",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager lifecycle", () => {
|
||||
test("on/off attaches and detaches change listeners", () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
|
||||
@@ -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,8 @@ export interface ProviderSnapshotManagerOptions {
|
||||
extraClients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
refreshTimeoutMs?: number;
|
||||
diagnosticTimeoutMs?: number;
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
interface ProviderSnapshotRefreshOptions {
|
||||
@@ -161,9 +164,11 @@ export class ProviderSnapshotManager {
|
||||
private readonly managedProcesses?: ManagedProcessRegistry;
|
||||
private readonly isDev: boolean;
|
||||
private readonly extraClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||
private readonly paseoHome: string | undefined;
|
||||
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly baseProviderOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private paseoAgentConfig: PaseoAgentConfig | undefined;
|
||||
private providerRegistry: Record<AgentProvider, ProviderDefinition>;
|
||||
private providerClients: Record<AgentProvider, AgentClient>;
|
||||
|
||||
@@ -173,9 +178,11 @@ export class ProviderSnapshotManager {
|
||||
this.managedProcesses = options.managedProcesses;
|
||||
this.isDev = options.isDev === true;
|
||||
this.extraClients = options.extraClients ?? {};
|
||||
this.paseoHome = options.paseoHome;
|
||||
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,
|
||||
@@ -374,16 +381,12 @@ export class ProviderSnapshotManager {
|
||||
this.baseProviderOverrides,
|
||||
mutableProviders,
|
||||
);
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
return this.rebuildRegistryAndReconcileSnapshots();
|
||||
}
|
||||
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
|
||||
return this.getAgentManagerProviderState();
|
||||
applyPaseoAgentConfig(config: PaseoAgentConfig | undefined): AgentManagerProviderState {
|
||||
this.paseoAgentConfig = config;
|
||||
return this.rebuildRegistryAndReconcileSnapshots();
|
||||
}
|
||||
|
||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||
@@ -421,6 +424,8 @@ export class ProviderSnapshotManager {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
managedProcesses: this.managedProcesses,
|
||||
isDev: this.isDev,
|
||||
paseoAgentConfig: this.paseoAgentConfig,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
|
||||
for (const [provider, client] of Object.entries(this.extraClients) as Array<
|
||||
@@ -442,6 +447,19 @@ export class ProviderSnapshotManager {
|
||||
return registry;
|
||||
}
|
||||
|
||||
private rebuildRegistryAndReconcileSnapshots(): AgentManagerProviderState {
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
|
||||
return this.getAgentManagerProviderState();
|
||||
}
|
||||
|
||||
private resolveParent(parent: ManagedAgent): AgentCreateConfigParent {
|
||||
const definition = this.requireProvider(parent.provider);
|
||||
return {
|
||||
@@ -567,7 +585,7 @@ export class ProviderSnapshotManager {
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
};
|
||||
|
||||
if (!definition?.enabled || !current || current.status === "loading") {
|
||||
if (!definition?.enabled) {
|
||||
entries.set(provider, {
|
||||
...metadata,
|
||||
status: "unavailable",
|
||||
@@ -576,6 +594,14 @@ export class ProviderSnapshotManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current || current.status === "loading") {
|
||||
entries.set(provider, {
|
||||
...metadata,
|
||||
status: "loading",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.set(provider, {
|
||||
...current,
|
||||
...metadata,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createToolPermissionPolicy, evaluateToolPermission } from "./agent-permissions.js";
|
||||
|
||||
describe("Paseo Agent tool permissions", () => {
|
||||
it("uses the first matching rule", () => {
|
||||
const allowFirst = createToolPermissionPolicy([
|
||||
{ tool: "bash", action: "allow" },
|
||||
{ tool: "bash", action: "deny" },
|
||||
]);
|
||||
const denyFirst = createToolPermissionPolicy([
|
||||
{ tool: "bash", action: "deny" },
|
||||
{ tool: "bash", action: "allow" },
|
||||
]);
|
||||
|
||||
expect(evaluateToolPermission(allowFirst, "bash")).toBe("allow");
|
||||
expect(evaluateToolPermission(denyFirst, "bash")).toBe("deny");
|
||||
});
|
||||
|
||||
it("matches wildcard tool names", () => {
|
||||
const policy = createToolPermissionPolicy([{ tool: "paseo__archive_*", action: "deny" }]);
|
||||
|
||||
expect(evaluateToolPermission(policy, "paseo__archive_agent")).toBe("deny");
|
||||
expect(evaluateToolPermission(policy, "paseo__list_agents")).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows tools when no rule matches", () => {
|
||||
const policy = createToolPermissionPolicy([{ tool: "read", action: "deny" }]);
|
||||
|
||||
expect(evaluateToolPermission(policy, "bash")).toBe("allow");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const ToolPermissionActionSchema = z.enum(["allow", "deny"]);
|
||||
|
||||
export const ToolPermissionRuleSchema = z
|
||||
.object({
|
||||
tool: z.string().min(1),
|
||||
action: ToolPermissionActionSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ToolPermissionAction = z.infer<typeof ToolPermissionActionSchema>;
|
||||
export type ToolPermissionRule = z.infer<typeof ToolPermissionRuleSchema>;
|
||||
|
||||
export interface CompiledToolPermissionRule {
|
||||
rule: ToolPermissionRule;
|
||||
matches(toolName: string): boolean;
|
||||
}
|
||||
|
||||
export interface ToolPermissionPolicy {
|
||||
rules: ToolPermissionRule[];
|
||||
compiledRules: CompiledToolPermissionRule[];
|
||||
}
|
||||
|
||||
export function createToolPermissionPolicy(
|
||||
rules: ToolPermissionRule[] | undefined,
|
||||
): ToolPermissionPolicy {
|
||||
const normalizedRules = rules ?? [];
|
||||
return {
|
||||
rules: normalizedRules,
|
||||
compiledRules: normalizedRules.map((rule) => ({
|
||||
rule,
|
||||
matches: compileToolPattern(rule.tool),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(
|
||||
policy: ToolPermissionPolicy | undefined,
|
||||
toolName: string,
|
||||
): ToolPermissionAction {
|
||||
for (const compiled of policy?.compiledRules ?? []) {
|
||||
if (compiled.matches(toolName)) {
|
||||
return compiled.rule.action;
|
||||
}
|
||||
}
|
||||
return "allow";
|
||||
}
|
||||
|
||||
function compileToolPattern(pattern: string): (toolName: string) => boolean {
|
||||
if (pattern === "*") {
|
||||
return () => true;
|
||||
}
|
||||
if (!pattern.includes("*")) {
|
||||
return (toolName) => toolName === pattern;
|
||||
}
|
||||
const matcher = new RegExp(`^${wildcardPatternToRegExp(pattern)}$`);
|
||||
return (toolName) => matcher.test(toolName);
|
||||
}
|
||||
|
||||
function wildcardPatternToRegExp(pattern: string): string {
|
||||
return pattern.split("*").map(escapeRegExp).join(".*");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import type { Logger } from "pino";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { PaseoAgentClient, PaseoAgentSession } from "./agent.js";
|
||||
import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js";
|
||||
import { storeOAuthCredential } from "./oauth-store.js";
|
||||
import type { PaseoAgentSessionHandle } from "./pi-services.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>): AgentSessionConfig {
|
||||
return { provider: "paseo", cwd: process.cwd(), ...overrides };
|
||||
}
|
||||
|
||||
function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; message: string }> } {
|
||||
const warnings: Array<{ data: unknown; message: string }> = [];
|
||||
const logger = {
|
||||
warnings,
|
||||
child: () => logger,
|
||||
debug: () => {},
|
||||
warn: (data: unknown, message: string) => {
|
||||
warnings.push({ data, message });
|
||||
},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
} as Logger & { warnings: Array<{ data: unknown; message: string }> };
|
||||
return logger;
|
||||
}
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class FakeInProcessPiSession {
|
||||
readonly sessionId = "pi-session-1";
|
||||
readonly thinkingLevel = "medium";
|
||||
readonly model = { provider: "openrouter-main", id: "test-model" };
|
||||
readonly messages: Array<{ role: string; content: unknown }> = [];
|
||||
readonly agent = { state: { errorMessage: "" } };
|
||||
abortCalls = 0;
|
||||
disposeCalls = 0;
|
||||
promptCalls: Array<{ text: string; options: unknown }> = [];
|
||||
promptDeferred = deferred();
|
||||
private readonly subscribers = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
subscribe(callback: (event: AgentSessionEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(text: string, options?: unknown): Promise<void> {
|
||||
this.promptCalls.push({ text, options });
|
||||
await this.promptDeferred.promise;
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortCalls += 1;
|
||||
const error = new Error("Request was aborted");
|
||||
error.name = "AbortError";
|
||||
this.promptDeferred.reject(error);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeCalls += 1;
|
||||
}
|
||||
|
||||
getSessionStats() {
|
||||
return {
|
||||
sessionFile: undefined,
|
||||
sessionId: this.sessionId,
|
||||
userMessages: 1,
|
||||
assistantMessages: 1,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
totalMessages: 2,
|
||||
tokens: { input: 3, output: 5, cacheRead: 2, cacheWrite: 0, total: 10 },
|
||||
cost: 0.01,
|
||||
contextUsage: { contextWindow: 200000, tokens: 1234, percentage: 0.6 },
|
||||
};
|
||||
}
|
||||
|
||||
setThinkingLevel(): void {}
|
||||
|
||||
async setModel(): Promise<void> {}
|
||||
|
||||
emit(event: AgentSessionEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPaseoProviderSession(): {
|
||||
fakePi: FakeInProcessPiSession;
|
||||
session: PaseoAgentSession;
|
||||
events: AgentStreamEvent[];
|
||||
mcpBridge: { closeCalls: number };
|
||||
} {
|
||||
const fakePi = new FakeInProcessPiSession();
|
||||
const handle = {
|
||||
session: fakePi,
|
||||
modelRegistry: { find: () => fakePi.model },
|
||||
resourceLoader: {},
|
||||
sessionManager: {},
|
||||
} as unknown as PaseoAgentSessionHandle;
|
||||
const mcpBridge = {
|
||||
tools: [],
|
||||
closeCalls: 0,
|
||||
async close() {
|
||||
this.closeCalls += 1;
|
||||
},
|
||||
};
|
||||
const session = new PaseoAgentSession(
|
||||
handle,
|
||||
sessionConfig(),
|
||||
mcpBridge as unknown as ConstructorParameters<typeof PaseoAgentSession>[2],
|
||||
null,
|
||||
[],
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
return { fakePi, session, events, mcpBridge };
|
||||
}
|
||||
|
||||
describe("PaseoAgentClient", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is available only when config has a usable model 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("checks OAuth credentials in the configured Paseo home", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
const wrongHome = mkdtempSync(join(tmpdir(), "paseo-agent-wrong-home-"));
|
||||
tempDirs.push(paseoHome, wrongHome);
|
||||
const previousPaseoHome = process.env.PASEO_HOME;
|
||||
process.env.PASEO_HOME = wrongHome;
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.com/backend-api" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
expect(await client.isAvailable()).toBe(true);
|
||||
} finally {
|
||||
if (previousPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = previousPaseoHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat an endpoint-mismatched OAuth credential as available", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
expect(await client.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.fetchCatalog({
|
||||
scope: "workspace",
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the selected agent as a model default", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
model: openrouter-main/b
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "orchestrator",
|
||||
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, paseoHome });
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the selected agent model over the configured default model", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
model: openrouter-main/b
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "orchestrator",
|
||||
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, paseoHome });
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the requested mode as the selected agent definition", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(join(paseoHome, "agents", "builder.md"), "---\nname: Builder\n---\nBuild.");
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "reviewer.md"),
|
||||
"---\nmodel: openrouter-main/b\n---\nReview.",
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "builder",
|
||||
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, paseoHome });
|
||||
|
||||
const { modes } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: process.cwd(),
|
||||
force: false,
|
||||
});
|
||||
expect(modes).toEqual([
|
||||
{ id: "builder", label: "Builder" },
|
||||
{ id: "reviewer", label: "reviewer" },
|
||||
]);
|
||||
|
||||
const session = await client.createSession(sessionConfig({ modeId: "reviewer" }));
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.modeId).toBe("reviewer");
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
await expect(session.getCurrentMode()).resolves.toBe("reviewer");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when the configured profile expects a missing MCP server", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
mcp: [paseo, paseo]
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const logger = createRecordingLogger();
|
||||
const client = new PaseoAgentClient({
|
||||
logger,
|
||||
config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultAgent: "orchestrator" }),
|
||||
paseoHome,
|
||||
});
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
expect(logger.warnings).toEqual([
|
||||
{
|
||||
data: expect.objectContaining({ mcpServer: "paseo" }),
|
||||
message: expect.stringMatching(/expects an MCP server/i),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaseoAgentSession runtime events", () => {
|
||||
it("runs through a representative Pi event sequence", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("hello");
|
||||
|
||||
await Promise.resolve();
|
||||
fakePi.emit({ type: "agent_start" });
|
||||
fakePi.emit({ type: "turn_start" });
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "pwd" },
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
result: { output: "/tmp" },
|
||||
isError: false,
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "done" },
|
||||
});
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: false });
|
||||
fakePi.promptDeferred.resolve();
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
sessionId: "pi-session-1",
|
||||
finalText: "done",
|
||||
usage: {
|
||||
inputTokens: 3,
|
||||
cachedInputTokens: 2,
|
||||
outputTokens: 5,
|
||||
totalCostUsd: 0.01,
|
||||
contextWindowMaxTokens: 200000,
|
||||
contextWindowUsedTokens: 1234,
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: "hello",
|
||||
messageId: expect.any(String),
|
||||
},
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "pwd", output: undefined, exitCode: undefined },
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { type: "shell", command: "pwd", output: "/tmp", exitCode: null },
|
||||
error: null,
|
||||
},
|
||||
{ type: "assistant_message", text: "done" },
|
||||
],
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"timeline",
|
||||
"thread_started",
|
||||
"turn_started",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"turn_completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the active turn open when Pi agent_end says it will retry", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("retry please");
|
||||
|
||||
await Promise.resolve();
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "first attempt " },
|
||||
});
|
||||
fakePi.agent.state.errorMessage = "transient overflow";
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: true });
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
fakePi.agent.state.errorMessage = "";
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "retry success" },
|
||||
});
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: false });
|
||||
fakePi.promptDeferred.resolve();
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
finalText: "first attempt retry success",
|
||||
});
|
||||
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps interrupt aborts to clean turn cancellation", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("cancel me");
|
||||
|
||||
await Promise.resolve();
|
||||
await session.interrupt();
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: "pi-session-1",
|
||||
finalText: "",
|
||||
timeline: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: "cancel me",
|
||||
messageId: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(fakePi.abortCalls).toBe(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "paseo",
|
||||
turnId: expect.any(String),
|
||||
reason: "interrupted",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts an active turn before close and close is idempotent", async () => {
|
||||
const { fakePi, session, events, mcpBridge } = createPaseoProviderSession();
|
||||
await session.startTurn("close me");
|
||||
|
||||
await Promise.all([session.close(), session.close()]);
|
||||
|
||||
expect(fakePi.abortCalls).toBe(1);
|
||||
expect(fakePi.disposeCalls).toBe(1);
|
||||
expect(mcpBridge.closeCalls).toBe(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "paseo",
|
||||
turnId: expect.any(String),
|
||||
reason: "interrupted",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,731 @@
|
||||
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 {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type FetchCatalogOptions,
|
||||
type ProviderCatalog,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||
import {
|
||||
PASEO_AGENT_PROVIDER,
|
||||
type PaseoAgentConfig,
|
||||
listPaseoAgentModels,
|
||||
paseoAgentHasUsableModel,
|
||||
paseoAgentModelProviders,
|
||||
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 {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
hasStoredOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js";
|
||||
import { createToolPermissionPolicy } from "./agent-permissions.js";
|
||||
import {
|
||||
composePromptParts,
|
||||
listAgentDefinitionIds,
|
||||
loadAgentDefinition,
|
||||
type ResolvedAgentDefinition,
|
||||
} from "./prompt-profiles.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,
|
||||
requiresPaseoTools: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const THINKING_LEVELS: ReadonlySet<ThinkingLevel> = new Set<ThinkingLevel>([
|
||||
"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");
|
||||
}
|
||||
|
||||
function envForPaseoHome(paseoHome: string | undefined): NodeJS.ProcessEnv {
|
||||
return paseoHome ? { ...process.env, PASEO_HOME: paseoHome } : process.env;
|
||||
}
|
||||
|
||||
function errorToMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error));
|
||||
}
|
||||
|
||||
function oauthCredentialBindings(
|
||||
providers: Awaited<ReturnType<typeof paseoAgentModelProviders>>,
|
||||
): Record<string, OAuthCredentialBinding> {
|
||||
const bindings: Record<string, OAuthCredentialBinding> = {};
|
||||
for (const provider of providers) {
|
||||
if (provider.oauth) {
|
||||
bindings[provider.name] = {
|
||||
flow: provider.oauth.flow,
|
||||
baseUrl: provider.config.baseUrl ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
interface PaseoAgentClientOptions {
|
||||
logger: Logger;
|
||||
config: PaseoAgentConfig;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
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<string, PiTrackedToolCall>();
|
||||
private activeTurnId: string | null = null;
|
||||
private lastThinkingOptionId: string | null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly handle: PaseoAgentSessionHandle,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly mcpBridge: McpToolBridge,
|
||||
private readonly agentId: string | null,
|
||||
private readonly availableAgents: AgentMode[],
|
||||
) {
|
||||
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 clearActiveTurn(): string | null {
|
||||
const turnId = this.activeTurnId;
|
||||
this.activeTurnId = null;
|
||||
this.activeToolCalls.clear();
|
||||
return turnId;
|
||||
}
|
||||
|
||||
private emitActiveTurnCanceled(reason: string): boolean {
|
||||
const turnId = this.clearActiveTurn();
|
||||
if (!turnId) {
|
||||
return false;
|
||||
}
|
||||
this.emit({
|
||||
type: "turn_canceled",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private emitToolCall(
|
||||
toolCallId: string,
|
||||
toolCall: PiTrackedToolCall,
|
||||
status: "running" | "completed" | "failed",
|
||||
result: ReturnType<typeof parseToolResult>,
|
||||
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":
|
||||
this.handleMessageUpdate(event, turnId);
|
||||
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":
|
||||
this.handleAgentEnd(event);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageUpdate(
|
||||
event: Extract<AgentSessionEvent, { type: "message_update" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
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 ?? "" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
|
||||
if (event.willRetry) {
|
||||
return;
|
||||
}
|
||||
const usage = toAgentUsage(this.piSession.getSessionStats());
|
||||
const currentTurnId = this.clearActiveTurn() ?? undefined;
|
||||
if (!currentTurnId) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private emitSubmittedUserMessage(text: string, messageId: string, turnId: string): void {
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "user_message", text, messageId },
|
||||
});
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return runProviderTurn({
|
||||
prompt,
|
||||
runOptions: options,
|
||||
startTurn: (p, o) => this.startTurn(p, o),
|
||||
subscribe: (callback) => this.subscribe(callback),
|
||||
getSessionId: () => this.piSession.sessionId,
|
||||
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
const messageId = options?.messageId ?? randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
this.emitSubmittedUserMessage(payload.text, messageId, turnId);
|
||||
|
||||
void this.piSession
|
||||
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (this.activeTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
this.emitActiveTurnCanceled(errorToMessage(error));
|
||||
return;
|
||||
}
|
||||
const failedTurnId = this.clearActiveTurn() ?? turnId;
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId: failedTurnId,
|
||||
error: errorToMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
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<AgentRuntimeInfo> {
|
||||
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: this.agentId,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return this.availableAgents;
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
if (modeId === this.agentId) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Paseo Agent selection is fixed when the session starts");
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
_response: AgentPermissionResponse,
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
const canceled = this.emitActiveTurnCanceled("interrupted");
|
||||
try {
|
||||
await this.piSession.abort();
|
||||
} catch (error) {
|
||||
if (!canceled || !isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closePromise ??= (async () => {
|
||||
try {
|
||||
if (this.activeTurnId) {
|
||||
await this.interrupt();
|
||||
}
|
||||
} finally {
|
||||
this.piSession.dispose();
|
||||
await this.mcpBridge.close();
|
||||
}
|
||||
})();
|
||||
await this.closePromise;
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
private readonly paseoHome: string | undefined;
|
||||
|
||||
constructor(options: PaseoAgentClientOptions) {
|
||||
this.logger = options.logger;
|
||||
this.config = options.config;
|
||||
this.paseoHome = options.paseoHome;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const modelProviders = await paseoAgentModelProviders(this.config);
|
||||
if (modelProviders.length === 0) {
|
||||
throw new Error(
|
||||
"Paseo Agent has no configured model providers. Add agents.paseo.providers to your Paseo config.",
|
||||
);
|
||||
}
|
||||
|
||||
const availableAgents = this.loadAvailableAgentModes();
|
||||
const agent = this.loadSelectedAgent(config.modeId);
|
||||
this.verifyExpectedMcpServers(agent, config.mcpServers);
|
||||
const model = resolvePaseoAgentModel(this.config, config.model, modelProviders, agent?.model);
|
||||
const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined;
|
||||
const composedPrompt = composePromptParts({
|
||||
agent,
|
||||
systemPrompt: config.systemPrompt,
|
||||
daemonAppendSystemPrompt: config.daemonAppendSystemPrompt,
|
||||
});
|
||||
this.logger.debug(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
model: model ? `${model.provider}/${model.id}` : null,
|
||||
agent: agent?.id ?? null,
|
||||
},
|
||||
"Creating Paseo Agent session",
|
||||
);
|
||||
|
||||
// Catalog OAuth providers use a Paseo-owned, file-backed AuthStorage so Pi reads
|
||||
// the stored credential and persists refreshed tokens (rotation) back to it.
|
||||
const usesOAuth = modelProviders.some((provider) => provider.oauth);
|
||||
const authStorage = usesOAuth
|
||||
? createBoundPaseoAgentAuthStorage(
|
||||
oauthCredentialBindings(modelProviders),
|
||||
envForPaseoHome(this.paseoHome),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools.
|
||||
const permissionPolicy = createToolPermissionPolicy(agent?.permissions);
|
||||
const mcpBridge = await createMcpToolBridge({
|
||||
mcpServers: config.mcpServers,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const handle = await createPaseoAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir: resolveIsolatedAgentDir(),
|
||||
modelProviders,
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
...(authStorage ? { authStorage } : {}),
|
||||
...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}),
|
||||
...(agent?.tools ? { tools: agent.tools } : {}),
|
||||
permissionPolicy,
|
||||
...(composedPrompt ? { composedPrompt } : {}),
|
||||
});
|
||||
return new PaseoAgentSession(handle, config, mcpBridge, agent?.id ?? null, availableAgents);
|
||||
} catch (error) {
|
||||
await mcpBridge.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("Paseo Agent does not support session resume in this prototype");
|
||||
}
|
||||
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: listPaseoAgentModels(this.config),
|
||||
modes: await this.loadAvailableAgentModes(),
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const env = envForPaseoHome(this.paseoHome);
|
||||
return paseoAgentHasUsableModel(this.config, env, (providerInstance, binding) =>
|
||||
hasStoredOAuthCredential(providerInstance, env, binding),
|
||||
);
|
||||
}
|
||||
|
||||
private loadSelectedAgent(requestedAgentId: string | undefined): ResolvedAgentDefinition | null {
|
||||
const selectedAgentId =
|
||||
requestedAgentId ?? this.config.defaultAgent ?? this.config.defaultProfile;
|
||||
if (!this.paseoHome || !selectedAgentId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const agent = loadAgentDefinition(this.paseoHome, selectedAgentId);
|
||||
if (!agent) {
|
||||
this.logger.warn(
|
||||
{ provider: PASEO_AGENT_PROVIDER, agent: selectedAgentId },
|
||||
"Configured Paseo Agent definition was not found",
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: selectedAgentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Configured Paseo Agent definition could not be loaded",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadAvailableAgentModes(): AgentMode[] {
|
||||
const paseoHome = this.paseoHome;
|
||||
if (!paseoHome) {
|
||||
return [];
|
||||
}
|
||||
return listAgentDefinitionIds(paseoHome).flatMap((agentId): AgentMode[] => {
|
||||
try {
|
||||
const agent = loadAgentDefinition(paseoHome, agentId);
|
||||
if (!agent) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: agent.id,
|
||||
label: agent.frontmatter.name ?? agent.id,
|
||||
...(agent.frontmatter.description
|
||||
? { description: agent.frontmatter.description }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Paseo Agent definition could not be listed",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private verifyExpectedMcpServers(
|
||||
agent: ResolvedAgentDefinition | null,
|
||||
configuredServers: AgentSessionConfig["mcpServers"],
|
||||
): void {
|
||||
for (const serverName of new Set(agent?.expectedMcpServers ?? [])) {
|
||||
if (!configuredServers?.[serverName]) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: agent?.id ?? null,
|
||||
mcpServer: serverName,
|
||||
},
|
||||
"Paseo Agent definition expects an MCP server that is not configured for this session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { KnownProvider } from "@earendil-works/pi-ai";
|
||||
|
||||
export interface PaseoAgentKeyAuthHint {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthHint {
|
||||
kind: "oauth";
|
||||
flow?: string;
|
||||
}
|
||||
|
||||
export type PaseoAgentCatalogAuthHint = PaseoAgentKeyAuthHint | PaseoAgentOAuthAuthHint;
|
||||
|
||||
export interface PaseoAgentCatalogRef {
|
||||
id: string;
|
||||
piProvider: KnownProvider;
|
||||
label: string;
|
||||
iconName?: string;
|
||||
docsUrl?: string;
|
||||
auth?: PaseoAgentCatalogAuthHint;
|
||||
defaultModels?: boolean;
|
||||
}
|
||||
|
||||
export const PASEO_AGENT_PROVIDER_CATALOG = [
|
||||
{
|
||||
id: "openrouter",
|
||||
piProvider: "openrouter",
|
||||
label: "OpenRouter",
|
||||
iconName: "openrouter",
|
||||
auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" },
|
||||
defaultModels: false,
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
piProvider: "openai-codex",
|
||||
label: "ChatGPT",
|
||||
iconName: "openai",
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
piProvider: "kimi-coding",
|
||||
label: "Kimi Coding Plan",
|
||||
iconName: "kimi",
|
||||
auth: { kind: "api_key", envVar: "KIMI_API_KEY" },
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
piProvider: "opencode-go",
|
||||
label: "OpenCode Go",
|
||||
iconName: "opencode",
|
||||
auth: { kind: "api_key", envVar: "OPENCODE_API_KEY" },
|
||||
},
|
||||
] as const satisfies readonly PaseoAgentCatalogRef[];
|
||||
|
||||
const PASEO_AGENT_PROVIDER_ALIASES: Record<string, string> = {
|
||||
"openai-codex": "chatgpt",
|
||||
};
|
||||
|
||||
export function resolvePaseoAgentCatalogEntry(
|
||||
providerType: string,
|
||||
): PaseoAgentCatalogRef | undefined {
|
||||
const canonicalId = PASEO_AGENT_PROVIDER_ALIASES[providerType] ?? providerType;
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.find((entry) => entry.id === canonicalId);
|
||||
}
|
||||
|
||||
export function knownPaseoAgentCatalogIds(): string[] {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
export function unknownPaseoAgentProviderTypeMessage(providerType: string): string {
|
||||
return `Unknown model provider type "${providerType}". Known provider ids: ${knownPaseoAgentCatalogIds().join(", ")}. Update the host if this provider is newer than it.`;
|
||||
}
|
||||
|
||||
export function requirePaseoAgentCatalogEntry(providerType: string): PaseoAgentCatalogRef {
|
||||
const entry = resolvePaseoAgentCatalogEntry(providerType);
|
||||
if (!entry) {
|
||||
throw new Error(unknownPaseoAgentProviderTypeMessage(providerType));
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js";
|
||||
import { PaseoAgentConfigService } from "./config-service.js";
|
||||
import { PaseoAgentConfigSchema } from "./config.js";
|
||||
import { paseoAgentAuthStoragePath, storeOAuthCredential } from "./oauth-store.js";
|
||||
|
||||
function piCatalogModels(provider: Parameters<typeof getModels>[0]) {
|
||||
return getModels(provider).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
api: model.api,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("PaseoAgentConfigService", () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "paseo-agent-config-service-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("persists an OpenRouter provider and returns only redacted auth state", () => {
|
||||
const onConfigChanged = vi.fn();
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
onConfigChanged,
|
||||
});
|
||||
|
||||
const provider = service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
headers: { Authorization: "Bearer header-secret" },
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(home);
|
||||
expect(persisted.agents?.paseo?.providers?.["openrouter-main"]).toMatchObject({
|
||||
type: "openrouter",
|
||||
options: { apiKey: "sk-secret-openrouter" },
|
||||
});
|
||||
expect(provider).toMatchObject({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
});
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("sk-secret-openrouter");
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("header-secret");
|
||||
expect(onConfigChanged).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports API-key default env auth state without resolving the key value", () => {
|
||||
const missingService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home },
|
||||
});
|
||||
missingService.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(missingService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: false,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
|
||||
const presentService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home, OPENROUTER_API_KEY: "sk-env-secret" },
|
||||
});
|
||||
|
||||
expect(presentService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: true,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: true,
|
||||
});
|
||||
expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret");
|
||||
});
|
||||
|
||||
test("reports configured API-key providers as available even without exposed models", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
service.setProvider({
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
models: [],
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an unknown provider type with a clear error and persists nothing", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
service.setProvider({
|
||||
name: "future-main",
|
||||
providerType: "kimi-coding",
|
||||
options: { apiKey: "sk-test", models: [{ id: "kimi-k3" }] },
|
||||
}),
|
||||
).toThrow(
|
||||
/Unknown model provider type "kimi-coding". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("schema accepts an unknown type structurally and the service rejects it", () => {
|
||||
const parsed = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
future: { type: "future-provider", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
expect(parsed.providers?.future?.type).toBe("future-provider");
|
||||
savePersistedConfig(
|
||||
home,
|
||||
{
|
||||
agents: { paseo: parsed },
|
||||
},
|
||||
createTestLogger(),
|
||||
);
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(() => service.getProviders()).toThrow(
|
||||
/Unknown model provider type "future-provider". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
});
|
||||
|
||||
test("maps the legacy provider type alias to the catalog id on write", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const provider = service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "openai-codex",
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(provider.providerType).toBe("chatgpt");
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.type).toBe("chatgpt");
|
||||
});
|
||||
|
||||
test("does not persist Pi catalog defaults as instance model overrides", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const chatgptCatalog = service.getCatalog().find((entry) => entry.id === "chatgpt");
|
||||
if (!chatgptCatalog) {
|
||||
throw new Error("missing chatgpt catalog entry");
|
||||
}
|
||||
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: { models: chatgptCatalog.models },
|
||||
});
|
||||
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.options.models).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves shared config fields when writing agents.paseo", () => {
|
||||
const logger = createTestLogger();
|
||||
savePersistedConfig(
|
||||
home,
|
||||
{
|
||||
daemon: { appendSystemPrompt: "Keep existing daemon settings." },
|
||||
app: { baseUrl: "http://localhost:8081" },
|
||||
agents: {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger,
|
||||
});
|
||||
|
||||
service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(home);
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Keep existing daemon settings.");
|
||||
expect(persisted.app?.baseUrl).toBe("http://localhost:8081");
|
||||
expect(persisted.agents?.providers?.gemini).toMatchObject({
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
});
|
||||
expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe(
|
||||
"sk-secret-openrouter",
|
||||
);
|
||||
});
|
||||
|
||||
test("stores OAuth credentials in the Paseo-owned auth store with future fields intact", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
|
||||
service.storeOAuthCredential("chatgpt", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
accountId: "acct_123",
|
||||
futureField: { keep: true },
|
||||
});
|
||||
|
||||
const authPath = paseoAgentAuthStoragePath({ PASEO_HOME: home });
|
||||
const stored = JSON.parse(readFileSync(authPath, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
futureField: { keep: true },
|
||||
});
|
||||
expect(authPath).toBe(join(home, "paseo-agent", "auth.json"));
|
||||
});
|
||||
|
||||
test("reports OAuth auth as stored without returning tokens", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
service.storeOAuthCredential("chatgpt", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const providers = service.getProviders();
|
||||
expect(providers.providers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
models: piCatalogModels("openai-codex"),
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(providers)).not.toContain("access-token");
|
||||
expect(JSON.stringify(providers)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("renames the provider display name without moving stored OAuth credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "subscription",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
service.storeOAuthCredential("subscription", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const provider = service.renameProvider("subscription", "Work account");
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
});
|
||||
const stored = JSON.parse(
|
||||
readFileSync(paseoAgentAuthStoragePath({ PASEO_HOME: home }), "utf8"),
|
||||
);
|
||||
expect(Object.keys(stored)).toEqual(["subscription"]);
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
});
|
||||
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env: { PASEO_HOME: home },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("access-token");
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("removes providers and clears a default model owned by that provider", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(service.removeProvider("openrouter-main")).toBe(true);
|
||||
expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentProviderAuthState,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
type PersistedConfig,
|
||||
} from "../../../persisted-config.js";
|
||||
import {
|
||||
isPaseoAgentDefaultModelSelection,
|
||||
PaseoAgentConfigSchema,
|
||||
paseoAgentCatalogManifests,
|
||||
type PaseoAgentConfig,
|
||||
type PaseoAgentCatalogManifestEntry,
|
||||
type PaseoAgentProviderModelConfig,
|
||||
resolvePaseoAgentCatalogAuth,
|
||||
resolvePaseoAgentProviderModels,
|
||||
resolvePaseoAgentProviderSettings,
|
||||
} from "./config.js";
|
||||
import { requirePaseoAgentCatalogEntry, type PaseoAgentCatalogRef } from "./catalog.js";
|
||||
import {
|
||||
getStoredOAuthCredentialState,
|
||||
storeOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js";
|
||||
import { findEnvReferences } from "./env-references.js";
|
||||
|
||||
interface PaseoAgentConfigServiceOptions {
|
||||
paseoHome: string;
|
||||
logger: Logger;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onConfigChanged?: (config: PaseoAgentConfig | undefined) => void;
|
||||
}
|
||||
|
||||
interface SetProviderInput {
|
||||
name: string;
|
||||
providerType: string;
|
||||
displayName?: string;
|
||||
options: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
models?: PaseoAgentProviderModelConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env ?? { ...process.env, PASEO_HOME: paseoHome };
|
||||
}
|
||||
|
||||
function authStateForApiKey(
|
||||
value: string | undefined,
|
||||
fallbackEnvVar: string | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): PaseoAgentProviderAuthState {
|
||||
if (!value && fallbackEnvVar) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
configured: Boolean(env[fallbackEnvVar]),
|
||||
source: "default_env",
|
||||
hint: fallbackEnvVar,
|
||||
};
|
||||
}
|
||||
if (!value) {
|
||||
return { kind: "none", configured: false };
|
||||
}
|
||||
if (value.startsWith("!")) {
|
||||
return { kind: "api_key", configured: true, source: "command" };
|
||||
}
|
||||
const referencedVars = findEnvReferences(value);
|
||||
if (referencedVars.length > 0) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
configured: referencedVars.every((name) => Boolean(env[name])),
|
||||
source: "env",
|
||||
hint: referencedVars.join(","),
|
||||
};
|
||||
}
|
||||
return { kind: "api_key", configured: true, source: "literal" };
|
||||
}
|
||||
|
||||
function copyCatalogEntry(entry: PaseoAgentCatalogManifestEntry): PaseoAgentCatalogManifestEntry {
|
||||
return {
|
||||
...entry,
|
||||
...(entry.headers ? { headers: { ...entry.headers } } : {}),
|
||||
auth: { ...entry.auth },
|
||||
models: entry.models.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
function providerOptionsForPersist(
|
||||
options: SetProviderInput["options"],
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): SetProviderInput["options"] {
|
||||
if (!isPaseoAgentDefaultModelSelection(options.models, catalogEntry)) {
|
||||
return options;
|
||||
}
|
||||
const rest = { ...options };
|
||||
delete rest.models;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function oauthBindingForSettings(
|
||||
flow: string,
|
||||
settings: ReturnType<typeof resolvePaseoAgentProviderSettings>,
|
||||
): OAuthCredentialBinding {
|
||||
return { flow, baseUrl: settings.baseUrl };
|
||||
}
|
||||
|
||||
function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig {
|
||||
return validatePaseoAgentConfig(PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}));
|
||||
}
|
||||
|
||||
function validatePaseoAgentConfig(config: PaseoAgentConfig): PaseoAgentConfig {
|
||||
for (const entry of Object.values(config.providers ?? {})) {
|
||||
requirePaseoAgentCatalogEntry(entry.type);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function redactedProviders(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): RedactedPaseoAgentProviderConfig[] {
|
||||
return Object.entries(config.providers ?? {}).map(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
|
||||
let auth: PaseoAgentProviderAuthState;
|
||||
if (authManifest.kind === "oauth") {
|
||||
const hasRefreshToken =
|
||||
entry.options.refreshToken &&
|
||||
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env);
|
||||
if (hasRefreshToken) {
|
||||
auth = { kind: "oauth", configured: true, source: "refresh_token" };
|
||||
} else {
|
||||
const stored = getStoredOAuthCredentialState(
|
||||
name,
|
||||
env,
|
||||
oauthBindingForSettings(authManifest.flow, settings),
|
||||
);
|
||||
if (stored.present && stored.bindingMatches) {
|
||||
auth = { kind: "oauth", configured: true, source: "stored" };
|
||||
} else if (stored.present) {
|
||||
auth = {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
};
|
||||
} else {
|
||||
auth = { kind: "oauth", configured: false };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auth = authStateForApiKey(entry.options.apiKey, authManifest.envVar, env);
|
||||
}
|
||||
const provider: RedactedPaseoAgentProviderConfig = {
|
||||
name,
|
||||
providerType: catalogEntry.id,
|
||||
models: models.map((model) => Object.assign({}, model)),
|
||||
auth,
|
||||
available: auth.configured,
|
||||
error: null,
|
||||
};
|
||||
if (entry.displayName) {
|
||||
provider.displayName = entry.displayName;
|
||||
}
|
||||
provider.baseUrl = settings.baseUrl;
|
||||
provider.api = settings.api;
|
||||
return provider;
|
||||
});
|
||||
}
|
||||
|
||||
function mergePaseoAgentConfig(
|
||||
persisted: PersistedConfig,
|
||||
paseoConfig: PaseoAgentConfig | undefined,
|
||||
): PersistedConfig {
|
||||
return {
|
||||
...persisted,
|
||||
agents: {
|
||||
...persisted.agents,
|
||||
paseo: paseoConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class PaseoAgentConfigService {
|
||||
private readonly paseoHome: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly onConfigChanged: ((config: PaseoAgentConfig | undefined) => void) | undefined;
|
||||
|
||||
constructor(options: PaseoAgentConfigServiceOptions) {
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.logger = options.logger.child({ module: "paseo-agent-config-service" });
|
||||
this.env = resolveEnv(options.paseoHome, options.env);
|
||||
this.onConfigChanged = options.onConfigChanged;
|
||||
}
|
||||
|
||||
getCatalog(): PaseoAgentCatalogManifestEntry[] {
|
||||
return paseoAgentCatalogManifests().map(copyCatalogEntry);
|
||||
}
|
||||
|
||||
getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
return {
|
||||
defaultModel: config.defaultModel ?? null,
|
||||
providers: redactedProviders(config, this.env),
|
||||
};
|
||||
}
|
||||
|
||||
setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(input.providerType);
|
||||
const next = this.updateConfig((current) =>
|
||||
PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
[input.name]: {
|
||||
type: catalogEntry.id,
|
||||
...((input.displayName ?? current.providers?.[input.name]?.displayName)
|
||||
? { displayName: input.displayName ?? current.providers?.[input.name]?.displayName }
|
||||
: {}),
|
||||
options: providerOptionsForPersist(input.options, catalogEntry),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return this.requireRedactedProvider(next, input.name);
|
||||
}
|
||||
|
||||
renameProvider(name: string, displayName: string): RedactedPaseoAgentProviderConfig {
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const next = this.updateConfig((current) => {
|
||||
const provider = current.providers?.[name];
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent provider '${name}' is not configured.`);
|
||||
}
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
[name]: {
|
||||
...provider,
|
||||
displayName: trimmedDisplayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.requireRedactedProvider(next, name);
|
||||
}
|
||||
|
||||
removeProvider(name: string): boolean {
|
||||
let removed = false;
|
||||
this.updateConfig((current) => {
|
||||
const providers = { ...current.providers };
|
||||
removed = Object.prototype.hasOwnProperty.call(providers, name);
|
||||
delete providers[name];
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
...(Object.keys(providers).length > 0 ? { providers } : { providers: undefined }),
|
||||
...(current.defaultModel?.startsWith(`${name}/`) ? { defaultModel: undefined } : {}),
|
||||
});
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
getOAuthCredentialBinding(providerName: string): OAuthCredentialBinding {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
|
||||
}
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (authManifest.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
return oauthBindingForSettings(authManifest.flow, settings);
|
||||
}
|
||||
|
||||
storeOAuthCredential(
|
||||
providerName: string,
|
||||
credential: PaseoAgentOAuthCredential,
|
||||
binding: OAuthCredentialBinding | undefined = undefined,
|
||||
): PaseoAgentProviderAuthState {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
|
||||
}
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (authManifest.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
storeOAuthCredential({
|
||||
providerInstance: providerName,
|
||||
credential,
|
||||
binding: binding ?? oauthBindingForSettings(authManifest.flow, settings),
|
||||
env: this.env,
|
||||
});
|
||||
this.onConfigChanged?.(config);
|
||||
return (
|
||||
this.requireRedactedProvider(config, providerName).auth ?? {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private requireRedactedProvider(
|
||||
config: PaseoAgentConfig,
|
||||
name: string,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
const provider = redactedProviders(config, this.env).find((entry) => entry.name === name);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent provider '${name}' was not found after update.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private updateConfig(update: (current: PaseoAgentConfig) => PaseoAgentConfig): PaseoAgentConfig {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const next = validatePaseoAgentConfig(update(readPaseoAgentConfig(persisted)));
|
||||
savePersistedConfig(this.paseoHome, mergePaseoAgentConfig(persisted, next), this.logger);
|
||||
this.onConfigChanged?.(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
|
||||
import {
|
||||
PaseoAgentConfigSchema,
|
||||
encodePaseoAgentModelId,
|
||||
listPaseoAgentModels,
|
||||
paseoAgentHasUsableModel,
|
||||
paseoAgentModelProviders,
|
||||
parsePaseoAgentModelId,
|
||||
resolvePaseoAgentCatalogAuth,
|
||||
resolvePaseoAgentModel,
|
||||
type PaseoAgentConfig,
|
||||
} from "./config.js";
|
||||
import { PASEO_AGENT_PROVIDER_CATALOG } from "./catalog.js";
|
||||
|
||||
const CATALOG_AUTH_ENV_VARS = [
|
||||
...new Set([
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
...PASEO_AGENT_PROVIDER_CATALOG.flatMap((entry) =>
|
||||
entry.auth?.kind === "api_key" ? [entry.auth.envVar] : [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
function piModelIds(provider: Parameters<typeof getModels>[0]): string[] {
|
||||
return getModels(provider).map((model) => model.id);
|
||||
}
|
||||
|
||||
function deleteEnvVars(names: readonly string[]): Map<string, string | undefined> {
|
||||
const previousValues = new Map<string, string | undefined>();
|
||||
for (const name of names) {
|
||||
previousValues.set(name, process.env[name]);
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
return previousValues;
|
||||
}
|
||||
|
||||
function restoreEnvVars(previousValues: ReadonlyMap<string, string | undefined>): void {
|
||||
for (const [name, value] of previousValues) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCatalogAuthEntries(): Array<
|
||||
[string, ReturnType<typeof resolvePaseoAgentCatalogAuth>]
|
||||
> {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [
|
||||
entry.id,
|
||||
resolvePaseoAgentCatalogAuth(entry),
|
||||
]);
|
||||
}
|
||||
|
||||
function configWith(overrides?: Partial<PaseoAgentConfig>): 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("accepts unknown model provider types structurally", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
future: { type: "future-provider", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
expect(config.providers?.future?.type).toBe("future-provider");
|
||||
});
|
||||
|
||||
it("rejects an empty instance model override", () => {
|
||||
expect(() =>
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: { p: { type: "openrouter", options: { models: [] } } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a provider entry without options when the catalog supplies defaults", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: { chatgpt: { type: "chatgpt" } },
|
||||
});
|
||||
expect(config.providers?.chatgpt?.options).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts multiple entries of the same type with distinct names", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
"openrouter-a": {
|
||||
type: "openrouter",
|
||||
options: { apiKey: "sk-a", models: [{ id: "model-a" }] },
|
||||
},
|
||||
"openrouter-b": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://proxy.test/v1",
|
||||
apiKey: "sk-b",
|
||||
models: [{ id: "model-b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Object.keys(config.providers ?? {})).toEqual(["openrouter-a", "openrouter-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("resolvePaseoAgentCatalogAuth", () => {
|
||||
it("resolves every catalog entry without provider key env vars", () => {
|
||||
const previousValues = deleteEnvVars(CATALOG_AUTH_ENV_VARS);
|
||||
try {
|
||||
expect(resolveCatalogAuthEntries()).toEqual([
|
||||
["openrouter", { kind: "api_key", envVar: "OPENROUTER_API_KEY" }],
|
||||
["chatgpt", { kind: "oauth", flow: "openai-codex" }],
|
||||
["kimi", { kind: "api_key", envVar: "KIMI_API_KEY" }],
|
||||
["opencode-go", { kind: "api_key", envVar: "OPENCODE_API_KEY" }],
|
||||
]);
|
||||
} finally {
|
||||
restoreEnvVars(previousValues);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns icon names to every Paseo Agent catalog entry", () => {
|
||||
const iconNames = new Map(
|
||||
PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [entry.id, entry.iconName]),
|
||||
);
|
||||
|
||||
expect(iconNames).toEqual(
|
||||
new Map([
|
||||
["openrouter", "openrouter"],
|
||||
["chatgpt", "openai"],
|
||||
["kimi", "kimi"],
|
||||
["opencode-go", "opencode"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("uses catalog model defaults when an instance does not override them", () => {
|
||||
const models = listPaseoAgentModels(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
);
|
||||
expect(models.map((m) => m.id)).toEqual(
|
||||
piModelIds("openai-codex").map((modelId) => `chatgpt/${modelId}`),
|
||||
);
|
||||
});
|
||||
|
||||
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("paseoAgentModelProviders", () => {
|
||||
it("applies OpenRouter catalog defaults", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(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 catalog env var when no apiKey is given", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.apiKey).toBe("$OPENROUTER_API_KEY");
|
||||
});
|
||||
|
||||
it("applies the Kimi catalog API and default header", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
kimi: { type: "kimi", options: { models: [{ id: "kimi-k2" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://api.kimi.com/coding");
|
||||
expect(provider.config.apiKey).toBe("$KIMI_API_KEY");
|
||||
expect(provider.config.api).toBe("anthropic-messages");
|
||||
expect(provider.config.models?.[0]?.headers).toEqual({ "User-Agent": "KimiCLI/1.5" });
|
||||
expect(provider.config.models?.[0]?.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("applies the OpenCode Go catalog base URL", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
go: { type: "opencode-go", options: { models: [{ id: "glm-5" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/go/v1");
|
||||
expect(provider.config.apiKey).toBe("$OPENCODE_API_KEY");
|
||||
expect(provider.config.api).toBe("openai-completions");
|
||||
});
|
||||
|
||||
it("maps OAuth catalog entries to flow-based providers without an api key", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
{},
|
||||
);
|
||||
expect(provider.name).toBe("chatgpt");
|
||||
expect(provider.oauth).toEqual({ flow: "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?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
|
||||
});
|
||||
|
||||
it("lets instance models override catalog default models", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
options: { models: [{ id: "gpt-other", reasoning: false }] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(provider.config.models?.map((model) => model.id)).toEqual(["gpt-other"]);
|
||||
});
|
||||
|
||||
it("maps the legacy type alias to the catalog entry", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: { type: "openai-codex" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(provider.oauth).toEqual({ flow: "openai-codex" });
|
||||
expect(provider.config.models?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
|
||||
});
|
||||
|
||||
it("rejects unknown provider types at runtime with known ids", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
mystery: { type: "mystery", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
await expect(paseoAgentModelProviders(config)).rejects.toThrow(
|
||||
/Unknown model provider type "mystery". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paseoAgentHasUsableModel", () => {
|
||||
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 API-key provider without a configured key", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
openrouter: { type: "openrouter", options: { 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);
|
||||
});
|
||||
|
||||
it("uses the OAuth store predicate, or an advanced refresh token", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: { chatgpt: { type: "chatgpt" } },
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
|
||||
expect(paseoAgentHasUsableModel(config, {}, () => true)).toBe(true);
|
||||
|
||||
const refreshConfig = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: { type: "chatgpt", options: { refreshToken: "$OAUTH_REFRESH_TOKEN" } },
|
||||
},
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(refreshConfig, { OAUTH_REFRESH_TOKEN: "rt-env" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePaseoAgentModel", () => {
|
||||
it("prefers the explicit request, then agent model, 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, undefined, "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("uses catalog default models during implicit selection", () => {
|
||||
expect(
|
||||
resolvePaseoAgentModel(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
null,
|
||||
),
|
||||
).toEqual({ provider: "chatgpt", id: piModelIds("openai-codex")[0] });
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
import { getModels, type Api, type Model } from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import type { PaseoAgentCatalogEntry as PaseoAgentCatalogManifestEntry } from "@getpaseo/protocol/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
isRefreshTokenExpressionConfigured,
|
||||
resolveRefreshTokenExpression,
|
||||
} from "./oauth-credentials.js";
|
||||
import type { OAuthCredentialBinding } from "./oauth-store.js";
|
||||
import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js";
|
||||
import {
|
||||
PASEO_AGENT_PROVIDER_CATALOG,
|
||||
requirePaseoAgentCatalogEntry,
|
||||
type PaseoAgentCatalogRef,
|
||||
} from "./catalog.js";
|
||||
import { findEnvReferences } from "./env-references.js";
|
||||
|
||||
export const PASEO_AGENT_PROVIDER = "paseo";
|
||||
|
||||
const PaseoAgentModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
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({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
refreshToken: z.string().min(1).optional(),
|
||||
models: z.array(PaseoAgentModelSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentModelProviderSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
options: PaseoAgentProviderOptionsSchema.default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PaseoAgentConfigSchema = z
|
||||
.object({
|
||||
defaultModel: z.string().min(1).optional(),
|
||||
defaultAgent: z.string().min(1).optional(),
|
||||
defaultProfile: z.string().min(1).optional(),
|
||||
providers: z.record(z.string(), PaseoAgentModelProviderSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PaseoAgentConfig = z.infer<typeof PaseoAgentConfigSchema>;
|
||||
export type PaseoAgentModelProviderEntry = z.infer<typeof PaseoAgentModelProviderSchema>;
|
||||
export type PaseoAgentProviderModelConfig = z.infer<typeof PaseoAgentModelSchema>;
|
||||
export type { PaseoAgentCatalogManifestEntry };
|
||||
|
||||
type PiModel = Model<Api>;
|
||||
type PiModelConfig = NonNullable<PaseoAgentModelProvider["config"]["models"]>[number];
|
||||
type ProviderOptions = PaseoAgentModelProviderEntry["options"];
|
||||
|
||||
const DEFAULT_INPUT: PiModelConfig["input"] = ["text"];
|
||||
const ZERO_COST: PiModelConfig["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 16_384;
|
||||
const DEFAULT_MODEL_FIELDS = [
|
||||
"id",
|
||||
"label",
|
||||
"api",
|
||||
"reasoning",
|
||||
"contextWindow",
|
||||
"maxTokens",
|
||||
] as const;
|
||||
|
||||
type ResolvedCatalogAuth =
|
||||
| { kind: "api_key"; envVar: string; keyUrl?: string; placeholder?: string; hint?: string }
|
||||
| { kind: "oauth"; flow: string };
|
||||
|
||||
export interface ResolvedProviderSettings {
|
||||
baseUrl: string;
|
||||
api: Api;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
}
|
||||
|
||||
function entries(config: PaseoAgentConfig): [string, PaseoAgentModelProviderEntry][] {
|
||||
return Object.entries(config.providers ?? {});
|
||||
}
|
||||
|
||||
function getPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
|
||||
return getModels(catalogEntry.piProvider);
|
||||
}
|
||||
|
||||
function requirePaseoAgentPrimaryModel(catalogEntry: PaseoAgentCatalogRef): PiModel {
|
||||
const first = getPaseoAgentPiModels(catalogEntry)[0];
|
||||
if (!first) {
|
||||
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no Pi models.`);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function defaultPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
|
||||
return catalogEntry.defaultModels === false ? [] : getPaseoAgentPiModels(catalogEntry);
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentCatalogAuth(
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): ResolvedCatalogAuth {
|
||||
if (catalogEntry.auth?.kind === "oauth") {
|
||||
return { kind: "oauth", flow: catalogEntry.auth.flow ?? catalogEntry.piProvider };
|
||||
}
|
||||
|
||||
if (!catalogEntry.auth && getOAuthProvider(catalogEntry.piProvider)) {
|
||||
return { kind: "oauth", flow: catalogEntry.piProvider };
|
||||
}
|
||||
|
||||
const envVar = catalogEntry.auth?.kind === "api_key" ? catalogEntry.auth.envVar : undefined;
|
||||
if (!envVar) {
|
||||
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no auth source.`);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar,
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.keyUrl
|
||||
? { keyUrl: catalogEntry.auth.keyUrl }
|
||||
: {}),
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.placeholder
|
||||
? { placeholder: catalogEntry.auth.placeholder }
|
||||
: {}),
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.hint
|
||||
? { hint: catalogEntry.auth.hint }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentProviderSettings(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef = requirePaseoAgentCatalogEntry(entry.type),
|
||||
): ResolvedProviderSettings {
|
||||
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const apiKey = auth.kind === "api_key" ? (entry.options.apiKey ?? `$${auth.envVar}`) : undefined;
|
||||
return {
|
||||
baseUrl: entry.options.baseUrl ?? primaryModel.baseUrl,
|
||||
api: entry.options.api ?? primaryModel.api,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(entry.options.headers ? { headers: entry.options.headers } : {}),
|
||||
...(entry.options.authHeader ? { authHeader: entry.options.authHeader } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toCatalogModel(model: PiModel): PaseoAgentProviderModelConfig {
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
api: model.api,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentProviderModels(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef = requirePaseoAgentCatalogEntry(entry.type),
|
||||
): PaseoAgentProviderModelConfig[] {
|
||||
return entry.options.models ?? defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
|
||||
}
|
||||
|
||||
export function isPaseoAgentDefaultModelSelection(
|
||||
models: PaseoAgentProviderModelConfig[] | undefined,
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): boolean {
|
||||
if (!models) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaults = defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
|
||||
if (models.length !== defaults.length || defaults.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return models.every((model, index) => {
|
||||
const defaultModel = defaults[index];
|
||||
return DEFAULT_MODEL_FIELDS.every((field) => model[field] === defaultModel?.[field]);
|
||||
});
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogManifests(): PaseoAgentCatalogManifestEntry[] {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((catalogEntry: PaseoAgentCatalogRef) => {
|
||||
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
const manifest: PaseoAgentCatalogManifestEntry = {
|
||||
id: catalogEntry.id,
|
||||
label: catalogEntry.label,
|
||||
api: primaryModel.api,
|
||||
baseUrl: primaryModel.baseUrl,
|
||||
auth: resolvePaseoAgentCatalogAuth(catalogEntry),
|
||||
models: defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel),
|
||||
};
|
||||
if (catalogEntry.iconName) {
|
||||
manifest.iconName = catalogEntry.iconName;
|
||||
}
|
||||
if (catalogEntry.docsUrl) {
|
||||
manifest.docsUrl = catalogEntry.docsUrl;
|
||||
}
|
||||
if (primaryModel.headers) {
|
||||
manifest.headers = { ...primaryModel.headers };
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = findEnvReferences(value);
|
||||
if (referencedVars.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return referencedVars.every((name) => Boolean(env[name]));
|
||||
}
|
||||
|
||||
export function encodePaseoAgentModelId(providerName: string, modelId: string): string {
|
||||
return `${providerName}/${modelId}`;
|
||||
}
|
||||
|
||||
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 applyModelOverrides(
|
||||
model: PiModel,
|
||||
options: ProviderOptions,
|
||||
override?: PaseoAgentProviderModelConfig,
|
||||
): PiModelConfig {
|
||||
return {
|
||||
id: override?.id ?? model.id,
|
||||
name: override?.label ?? model.name,
|
||||
api: override?.api ?? options.api ?? model.api,
|
||||
baseUrl: options.baseUrl ?? model.baseUrl,
|
||||
reasoning: override?.reasoning ?? model.reasoning,
|
||||
...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
|
||||
input: model.input,
|
||||
cost: model.cost,
|
||||
contextWindow: override?.contextWindow ?? model.contextWindow,
|
||||
maxTokens: override?.maxTokens ?? model.maxTokens,
|
||||
...(model.headers ? { headers: { ...model.headers } } : {}),
|
||||
...(model.compat ? { compat: model.compat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function customModelFromOptions(
|
||||
model: PaseoAgentProviderModelConfig,
|
||||
options: ProviderOptions,
|
||||
fallback: PiModel,
|
||||
): PiModelConfig {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.label ?? model.id,
|
||||
api: model.api ?? options.api ?? fallback.api,
|
||||
baseUrl: options.baseUrl ?? fallback.baseUrl,
|
||||
reasoning: model.reasoning ?? false,
|
||||
input: fallback.input ?? DEFAULT_INPUT,
|
||||
cost: ZERO_COST,
|
||||
contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
...(fallback.headers ? { headers: { ...fallback.headers } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toPiModels(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): PiModelConfig[] {
|
||||
const piModels = getPaseoAgentPiModels(catalogEntry);
|
||||
const piModelsById = new Map(piModels.map((model) => [model.id, model]));
|
||||
const selectedModels = entry.options.models;
|
||||
if (!selectedModels) {
|
||||
return defaultPaseoAgentPiModels(catalogEntry).map((model) =>
|
||||
applyModelOverrides(model, entry.options),
|
||||
);
|
||||
}
|
||||
|
||||
const fallback = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
return selectedModels.map((model) => {
|
||||
const piModel = piModelsById.get(model.id);
|
||||
return piModel
|
||||
? applyModelOverrides(piModel, entry.options, model)
|
||||
: customModelFromOptions(model, entry.options, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
export async function paseoAgentModelProviders(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<PaseoAgentModelProvider[]> {
|
||||
const providers: PaseoAgentModelProvider[] = [];
|
||||
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
const models = toPiModels(entry, catalogEntry);
|
||||
const providerConfig = {
|
||||
baseUrl: settings.baseUrl,
|
||||
api: settings.api,
|
||||
...(settings.headers ? { headers: settings.headers } : {}),
|
||||
models,
|
||||
};
|
||||
|
||||
if (auth.kind === "oauth") {
|
||||
const refreshToken = entry.options.refreshToken
|
||||
? await resolveRefreshTokenExpression(entry.options.refreshToken, env)
|
||||
: undefined;
|
||||
providers.push({
|
||||
name,
|
||||
config: providerConfig,
|
||||
oauth: { flow: auth.flow, ...(refreshToken ? { refreshToken } : {}) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
providers.push({
|
||||
name,
|
||||
config: {
|
||||
...providerConfig,
|
||||
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
|
||||
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] {
|
||||
const models: AgentModelDefinition[] = [];
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
for (const model of resolvePaseoAgentProviderModels(entry, catalogEntry)) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function paseoAgentHasUsableModel(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
isOAuthAuthed: (providerInstance: string, binding: OAuthCredentialBinding) => boolean = () =>
|
||||
false,
|
||||
): boolean {
|
||||
return entries(config).some(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
|
||||
if (models.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (auth.kind === "oauth") {
|
||||
if (
|
||||
entry.options.refreshToken &&
|
||||
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isOAuthAuthed(name, {
|
||||
flow: auth.flow,
|
||||
baseUrl: resolvePaseoAgentProviderSettings(entry, catalogEntry).baseUrl,
|
||||
});
|
||||
}
|
||||
return isAuthConfigured(resolvePaseoAgentProviderSettings(entry, catalogEntry).apiKey, env);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentModel(
|
||||
config: PaseoAgentConfig,
|
||||
requestedModelId: string | null | undefined,
|
||||
registeredProviders: PaseoAgentModelProvider[] = paseoAgentModelInventory(config),
|
||||
agentDefaultModelId?: string | null,
|
||||
): PaseoAgentModelReference | undefined {
|
||||
if (requestedModelId) {
|
||||
return parsePaseoAgentModelId(requestedModelId) ?? undefined;
|
||||
}
|
||||
|
||||
for (const candidate of [agentDefaultModelId, config.defaultModel, firstModelId(config)]) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parsePaseoAgentModelId(candidate);
|
||||
if (parsed && hasRegisteredModel(registeredProviders, parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return firstRegisteredModel(registeredProviders);
|
||||
}
|
||||
|
||||
function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentModelProvider[] {
|
||||
return entries(config).map(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
return { name, config: { models: toPiModels(entry, catalogEntry) } };
|
||||
});
|
||||
}
|
||||
|
||||
function firstModelId(config: PaseoAgentConfig): string | undefined {
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const first = resolvePaseoAgentProviderModels(entry, catalogEntry)[0];
|
||||
if (first) {
|
||||
return encodePaseoAgentModelId(name, first.id);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasRegisteredModel(
|
||||
providers: PaseoAgentModelProvider[],
|
||||
model: PaseoAgentModelReference,
|
||||
): boolean {
|
||||
return providers.some(
|
||||
(provider) =>
|
||||
provider.name === model.provider &&
|
||||
provider.config.models?.some((registered) => registered.id === model.id),
|
||||
);
|
||||
}
|
||||
|
||||
function firstRegisteredModel(
|
||||
providers: PaseoAgentModelProvider[],
|
||||
): PaseoAgentModelReference | undefined {
|
||||
for (const provider of providers) {
|
||||
const first = provider.config.models?.[0];
|
||||
if (first) {
|
||||
return { provider: provider.name, id: first.id };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
|
||||
const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
|
||||
|
||||
export function findEnvReferences(value: string): string[] {
|
||||
return Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]);
|
||||
}
|
||||
|
||||
export function hasEnvReference(value: string): boolean {
|
||||
return ENV_REFERENCE_DETECT.test(value);
|
||||
}
|
||||
|
||||
export function substituteEnvReferences(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;
|
||||
}
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
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<typeof PiToolResultSchema>;
|
||||
|
||||
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
|
||||
path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
const EditToolInputSchema: z.ZodType<EditToolInput> = 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<WriteToolInput> = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
const GrepToolInputSchema: z.ZodType<GrepToolInput> = 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<LsToolInput> = 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;
|
||||
const contextWindowMaxTokens = stats.contextUsage?.contextWindow ?? undefined;
|
||||
const contextWindowUsedTokens = stats.contextUsage?.tokens ?? undefined;
|
||||
if (
|
||||
inputTokens === 0 &&
|
||||
cachedInputTokens === 0 &&
|
||||
outputTokens === 0 &&
|
||||
totalCostUsd === 0 &&
|
||||
contextWindowMaxTokens === undefined &&
|
||||
contextWindowUsedTokens === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
totalCostUsd,
|
||||
...(typeof contextWindowMaxTokens === "number" ? { contextWindowMaxTokens } : {}),
|
||||
...(typeof contextWindowUsedTokens === "number" ? { contextWindowUsedTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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<string, unknown> }[] = [];
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<McpToolInfo[]>;
|
||||
callTool(toolName: string, args: Record<string, unknown>): Promise<McpCallToolResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type McpConnectionFactory = (
|
||||
serverName: string,
|
||||
config: McpServerConfig,
|
||||
) => Promise<McpConnection>;
|
||||
|
||||
export interface McpToolBridge {
|
||||
/** Pi custom tools for every successfully-listed MCP tool. */
|
||||
tools: ToolDefinition[];
|
||||
/** Close every MCP connection. Idempotent and best-effort. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
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<McpConnection> {
|
||||
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<string, unknown>;
|
||||
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<string, McpServerConfig>;
|
||||
logger: Logger;
|
||||
connect?: McpConnectionFactory;
|
||||
}): Promise<McpToolBridge> {
|
||||
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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
expect(schema.description).toBe("the tool input");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// 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<string, TSchema> = {};
|
||||
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<string, unknown> = { ...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 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isRefreshTokenExpressionConfigured,
|
||||
resolveRefreshTokenExpression,
|
||||
} from "./oauth-credentials.js";
|
||||
|
||||
describe("resolveRefreshTokenExpression", () => {
|
||||
it("returns a literal value", async () => {
|
||||
await expect(resolveRefreshTokenExpression("rt-literal", {})).resolves.toBe("rt-literal");
|
||||
});
|
||||
|
||||
it("resolves an env reference and returns undefined when unset", async () => {
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).resolves.toBe(
|
||||
"rt-env",
|
||||
);
|
||||
await expect(
|
||||
resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" }),
|
||||
).resolves.toBe("rt-env");
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs a !command asynchronously and returns its trimmed output", async () => {
|
||||
await expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).resolves.toBe("rt-cmd");
|
||||
await expect(resolveRefreshTokenExpression("!exit 1", {})).resolves.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { hasEnvReference, substituteEnvReferences } from "./env-references.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000;
|
||||
|
||||
// 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 refresh token;
|
||||
// the normal product path writes a Paseo-owned credential store (see oauth-store.ts).
|
||||
//
|
||||
// This module deliberately does NOT read any other tool's auth files and imports no
|
||||
// Pi runtime code. Token values are never logged.
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Promise<string | undefined> {
|
||||
if (value.startsWith("!")) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return execAsync(command, {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: CREDENTIAL_COMMAND_TIMEOUT_MS,
|
||||
})
|
||||
.then(({ stdout }) => {
|
||||
const output = stdout.trim();
|
||||
return output.length > 0 ? output : undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return Promise.resolve(resolveStaticRefreshTokenExpression(value, env));
|
||||
}
|
||||
|
||||
function resolveStaticRefreshTokenExpression(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
if (hasEnvReference(value)) {
|
||||
const output = substituteEnvReferences(value, env);
|
||||
if (output) {
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
return 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 (hasEnvReference(value)) {
|
||||
return substituteEnvReferences(value, env) !== undefined;
|
||||
}
|
||||
return value.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// POSIX-only: file mode bits are not represented the same way on Windows.
|
||||
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { isPlatform } from "../../../../test-utils/platform.js";
|
||||
import { loginAndStoreOAuth, storeOAuthCredential } from "./oauth-store.js";
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => {
|
||||
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("stores login credentials in a private file", async () => {
|
||||
const login = async () => ({ refresh: "rt-from-login", access: "ac", expires: 123 });
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: "paseo-test-oauth",
|
||||
baseUrl: "https://api.example.test/oauth",
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: () => {},
|
||||
login,
|
||||
});
|
||||
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("stores explicit credentials in a private file", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: "paseo-test-oauth", baseUrl: "https://api.example.test/oauth" },
|
||||
credential: { type: "oauth", access: "ac", refresh: "rt", expires: 123 },
|
||||
});
|
||||
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai";
|
||||
import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
getStoredOAuthCredentialState,
|
||||
hasStoredOAuthCredential,
|
||||
loginAndStoreOAuth,
|
||||
loginOAuthBrowser,
|
||||
paseoAgentAuthStoragePath,
|
||||
storeOAuthCredential,
|
||||
} from "./oauth-store.js";
|
||||
|
||||
const TEST_FLOW = "paseo-test-oauth-store";
|
||||
const TEST_BASE_URL = "https://api.example.test/oauth";
|
||||
|
||||
function registerTestOAuthProvider(): void {
|
||||
const provider: OAuthProviderInterface = {
|
||||
id: TEST_FLOW,
|
||||
name: "Paseo Test OAuth",
|
||||
async login(callbacks): Promise<OAuthCredentials> {
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
return { refresh: "rt-from-registry", access: "ac", expires: 123, accountId: "acct" };
|
||||
},
|
||||
async refreshToken(credentials): Promise<OAuthCredentials> {
|
||||
return credentials;
|
||||
},
|
||||
getApiKey(credentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
registerOAuthProvider(provider);
|
||||
}
|
||||
|
||||
describe("oauth-store", () => {
|
||||
let home: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-"));
|
||||
env = { PASEO_HOME: home };
|
||||
});
|
||||
afterEach(() => {
|
||||
resetOAuthProviders();
|
||||
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("stores a protocol credential with future fields intact", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
futureField: { keep: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
|
||||
expect(
|
||||
hasStoredOAuthCredential("chatgpt", env, { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStoredOAuthCredentialState("chatgpt", env, {
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: "https://api.example.test/changed",
|
||||
}),
|
||||
).toEqual({ present: true, bindingMatches: false });
|
||||
const stored = JSON.parse(readFileSync(path, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
futureField: { keep: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides mismatched credentials from runtime auth storage without deleting them", async () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createBoundPaseoAgentAuthStorage(
|
||||
{
|
||||
chatgpt: { flow: TEST_FLOW, baseUrl: "https://api.example.test/changed" },
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(authStorage.has("chatgpt")).toBe(false);
|
||||
await expect(authStorage.getApiKey("chatgpt", { includeFallback: false })).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(readFileSync(path, "utf8")).chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the Pi registry login flow and persists a Paseo-owned credential", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const deviceCodes: unknown[] = [];
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: (info) => deviceCodes.push(info),
|
||||
});
|
||||
|
||||
expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]);
|
||||
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-from-registry" });
|
||||
});
|
||||
|
||||
it("keys the credential by provider instance name", async () => {
|
||||
const login = async () => ({ refresh: "rt", access: "", expires: 0 });
|
||||
await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
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 returns a credential without storing it", async () => {
|
||||
const authUrls: Array<[string, string | undefined]> = [];
|
||||
const loginCalls: string[] = [];
|
||||
const login = async (opts: { onAuth: (info: { url: string }) => void }) => {
|
||||
loginCalls.push("called");
|
||||
opts.onAuth({ url: "https://auth.example.test/oauth/authorize?x=1" });
|
||||
return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" };
|
||||
};
|
||||
|
||||
const credential = await loginOAuthBrowser({
|
||||
flow: TEST_FLOW,
|
||||
onAuthUrl: (url, instructions) => authUrls.push([url, instructions]),
|
||||
login,
|
||||
});
|
||||
|
||||
expect(loginCalls).toEqual(["called"]);
|
||||
expect(authUrls).toEqual([["https://auth.example.test/oauth/authorize?x=1", undefined]]);
|
||||
expect(credential).toMatchObject({ type: "oauth", refresh: "rt-browser" });
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
|
||||
});
|
||||
|
||||
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";
|
||||
};
|
||||
const login = async (opts: { onPrompt: (p: { message: string }) => Promise<string> }) => {
|
||||
const code = await opts.onPrompt({ message: "Paste the code:" });
|
||||
expect(code).toBe("pasted-code");
|
||||
return { refresh: "rt-manual", access: "", expires: 0 };
|
||||
};
|
||||
|
||||
const credential = await loginOAuthBrowser({
|
||||
flow: TEST_FLOW,
|
||||
onAuthUrl: () => {},
|
||||
promptForCode,
|
||||
login,
|
||||
});
|
||||
|
||||
expect(prompts).toEqual(["Paste the code:"]);
|
||||
expect(credential).toMatchObject({ type: "oauth", refresh: "rt-manual" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
AuthStorage,
|
||||
FileAuthStorageBackend,
|
||||
type AuthCredential,
|
||||
type AuthStorageBackend,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo as PiOAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthSelectPrompt,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages";
|
||||
|
||||
// Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live
|
||||
// in a Paseo-controlled file and are managed through Pi's own AuthStorage, so Pi
|
||||
// refreshes tokens and persists rotation back into Paseo's file. Login flows reuse
|
||||
// Pi's OAuth registry; Paseo does not reimplement OAuth protocols.
|
||||
|
||||
export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo;
|
||||
export type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
|
||||
type OAuthLoginPreference = "browser" | "device";
|
||||
|
||||
export interface OAuthCredentialBinding {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface StoredOAuthCredentialState {
|
||||
present: boolean;
|
||||
bindingMatches: boolean;
|
||||
}
|
||||
|
||||
interface BoundOAuthCredential extends PaseoAgentOAuthCredential {
|
||||
binding?: OAuthCredentialBinding;
|
||||
}
|
||||
|
||||
interface StorageLockResult<T> {
|
||||
result: T;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
export function createBoundPaseoAgentAuthStorage(
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AuthStorage {
|
||||
return AuthStorage.fromStorage(
|
||||
new BindingAwareAuthStorageBackend(paseoAgentAuthStoragePath(env), bindings),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
const state = getStoredOAuthCredentialState(providerInstance, env, binding);
|
||||
return state.present && state.bindingMatches;
|
||||
}
|
||||
|
||||
export function getStoredOAuthCredentialState(
|
||||
providerInstance: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): StoredOAuthCredentialState {
|
||||
const path = paseoAgentAuthStoragePath(env);
|
||||
if (!existsSync(path)) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
const entry = (parsed as Record<string, unknown>)[providerInstance];
|
||||
if (!isOAuthCredentialRecord(entry)) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
return { present: true, bindingMatches: !binding || bindingsEqual(entry.binding, binding) };
|
||||
} catch {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential obtained by a remote-safe client-side OAuth flow into the
|
||||
* daemon's Paseo-owned AuthStorage. The caller supplies the protocol credential
|
||||
* shape, and this helper never reads or writes foreign auth files.
|
||||
*/
|
||||
export function storeOAuthCredential(options: {
|
||||
providerInstance: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
binding: OAuthCredentialBinding;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { path: string } {
|
||||
const path = paseoAgentAuthStoragePath(options.env);
|
||||
const authStorage = AuthStorage.create(path);
|
||||
authStorage.set(options.providerInstance, {
|
||||
...options.credential,
|
||||
binding: { ...options.binding },
|
||||
});
|
||||
return { path };
|
||||
}
|
||||
|
||||
export async function loginAndStoreOAuth(options: {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
providerInstance: string;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<{ path: string }> {
|
||||
const credential = await loginOAuthDevice({
|
||||
flow: options.flow,
|
||||
onDeviceCode: options.onDeviceCode,
|
||||
signal: options.signal,
|
||||
login: options.login,
|
||||
});
|
||||
return storeOAuthCredential({
|
||||
providerInstance: options.providerInstance,
|
||||
credential,
|
||||
binding: { flow: options.flow, baseUrl: options.baseUrl },
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginOAuthDevice(options: {
|
||||
flow: string;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<PaseoAgentOAuthCredential> {
|
||||
const login = resolveOAuthLogin(options.flow, options.login);
|
||||
const credentials = await login({
|
||||
onAuth: () => {},
|
||||
onDeviceCode: options.onDeviceCode,
|
||||
onPrompt: async () => {
|
||||
throw new Error("OAuth login requested manual input, but no prompt handler is available.");
|
||||
},
|
||||
onSelect: (prompt) => selectOAuthOption(prompt, "device"),
|
||||
signal: options.signal,
|
||||
});
|
||||
return { type: "oauth", ...credentials };
|
||||
}
|
||||
|
||||
export async function loginOAuthBrowser(options: {
|
||||
flow: string;
|
||||
onAuthUrl: (url: string, instructions?: string) => void;
|
||||
promptForCode?: (message: string) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<PaseoAgentOAuthCredential> {
|
||||
const login = resolveOAuthLogin(options.flow, options.login);
|
||||
const credentials = await login({
|
||||
onAuth: (info) => options.onAuthUrl(info.url, info.instructions),
|
||||
onDeviceCode: () => {},
|
||||
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);
|
||||
},
|
||||
onSelect: (prompt) => selectOAuthOption(prompt, "browser"),
|
||||
signal: options.signal,
|
||||
});
|
||||
return { type: "oauth", ...credentials };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOAuthCredentialRecord(value: unknown): value is BoundOAuthCredential {
|
||||
return isRecord(value) && value.type === "oauth";
|
||||
}
|
||||
|
||||
function bindingsEqual(
|
||||
actual: OAuthCredentialBinding | undefined,
|
||||
expected: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
return actual?.flow === expected.flow && actual.baseUrl === expected.baseUrl;
|
||||
}
|
||||
|
||||
function credentialBinding(
|
||||
credential: AuthCredential | undefined,
|
||||
): OAuthCredentialBinding | undefined {
|
||||
if (!credential || credential.type !== "oauth") {
|
||||
return undefined;
|
||||
}
|
||||
const binding = credential.binding;
|
||||
if (!isRecord(binding)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof binding.flow === "string" && typeof binding.baseUrl === "string"
|
||||
? { flow: binding.flow, baseUrl: binding.baseUrl }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseAuthStorageData(current: string | undefined): Record<string, AuthCredential> {
|
||||
if (!current) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(current);
|
||||
return isRecord(parsed) ? (parsed as Record<string, AuthCredential>) : {};
|
||||
}
|
||||
|
||||
function serializeAuthStorageData(data: Record<string, AuthCredential>): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function filterBoundCredentials(
|
||||
data: Record<string, AuthCredential>,
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
): Record<string, AuthCredential> {
|
||||
const filtered: Record<string, AuthCredential> = {};
|
||||
for (const [provider, credential] of Object.entries(data)) {
|
||||
const binding = bindings[provider];
|
||||
if (
|
||||
!binding ||
|
||||
credential.type !== "oauth" ||
|
||||
bindingsEqual(credentialBinding(credential), binding)
|
||||
) {
|
||||
filtered[provider] = credential;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mergeBindingAwareWrite(
|
||||
original: Record<string, AuthCredential>,
|
||||
visible: Record<string, AuthCredential>,
|
||||
next: string,
|
||||
): string {
|
||||
const nextData = parseAuthStorageData(next);
|
||||
const merged: Record<string, AuthCredential> = { ...original };
|
||||
for (const provider of Object.keys(visible)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(nextData, provider)) {
|
||||
delete merged[provider];
|
||||
}
|
||||
}
|
||||
for (const [provider, credential] of Object.entries(nextData)) {
|
||||
const originalBinding = credentialBinding(original[provider]);
|
||||
if (credential.type === "oauth" && !credential.binding && originalBinding) {
|
||||
merged[provider] = { ...credential, binding: originalBinding };
|
||||
} else {
|
||||
merged[provider] = credential;
|
||||
}
|
||||
}
|
||||
return serializeAuthStorageData(merged);
|
||||
}
|
||||
|
||||
class BindingAwareAuthStorageBackend implements AuthStorageBackend {
|
||||
private readonly delegate: FileAuthStorageBackend;
|
||||
private readonly bindings: Record<string, OAuthCredentialBinding>;
|
||||
|
||||
constructor(path: string, bindings: Record<string, OAuthCredentialBinding>) {
|
||||
this.delegate = new FileAuthStorageBackend(path);
|
||||
this.bindings = bindings;
|
||||
}
|
||||
|
||||
withLock<T>(fn: (current: string | undefined) => StorageLockResult<T>): T {
|
||||
return this.delegate.withLock((current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
withLockAsync<T>(fn: (current: string | undefined) => Promise<StorageLockResult<T>>): Promise<T> {
|
||||
return this.delegate.withLockAsync(async (current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = await fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuthLogin(flow: string, login: OAuthLogin | undefined): OAuthLogin {
|
||||
if (login) {
|
||||
return login;
|
||||
}
|
||||
const provider = getOAuthProvider(flow);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
|
||||
}
|
||||
return (callbacks) => provider.login(callbacks);
|
||||
}
|
||||
|
||||
function selectOAuthOption(
|
||||
prompt: OAuthSelectPrompt,
|
||||
preference: OAuthLoginPreference,
|
||||
): Promise<string | undefined> {
|
||||
const preferred = prompt.options.find((option) =>
|
||||
option.label.toLowerCase().includes(preference),
|
||||
);
|
||||
return Promise.resolve((preferred ?? prompt.options[0])?.id);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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 type { BeforeToolCallContext } from "@earendil-works/pi-agent-core";
|
||||
import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai";
|
||||
import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createToolPermissionPolicy } from "./agent-permissions.js";
|
||||
|
||||
import {
|
||||
type CreatePaseoAgentSessionOptions,
|
||||
type PaseoAgentModelProvider,
|
||||
createPaseoAgentSession,
|
||||
} from "./pi-services.js";
|
||||
|
||||
const TEST_OAUTH_FLOW = "paseo-test-oauth";
|
||||
|
||||
function oauthModelProvider(): PaseoAgentModelProvider {
|
||||
return {
|
||||
name: "subscription",
|
||||
config: {
|
||||
baseUrl: "https://example.invalid/oauth",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "oauth-model",
|
||||
name: "OAuth Model",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerTestOAuthProvider(): void {
|
||||
const provider: OAuthProviderInterface = {
|
||||
id: TEST_OAUTH_FLOW,
|
||||
name: "Paseo Test OAuth",
|
||||
async login(): Promise<OAuthCredentials> {
|
||||
return { access: "access-from-login", refresh: "refresh-from-login", expires: Date.now() };
|
||||
},
|
||||
async refreshToken(credentials): Promise<OAuthCredentials> {
|
||||
return {
|
||||
...credentials,
|
||||
access: "access-from-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
getApiKey(credentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
registerOAuthProvider(provider);
|
||||
}
|
||||
|
||||
const FAKE_PROVIDER = "paseo-test-openrouter";
|
||||
const FAKE_MODEL_ID = "test-model";
|
||||
|
||||
function toolCallContext(toolName: string): BeforeToolCallContext {
|
||||
return {
|
||||
assistantMessage: { role: "assistant", content: [] },
|
||||
toolCall: { type: "toolCall", id: "call-1", name: toolName, arguments: {} },
|
||||
args: {},
|
||||
context: {},
|
||||
} as BeforeToolCallContext;
|
||||
}
|
||||
|
||||
function fakeModelProvider(): PaseoAgentModelProvider {
|
||||
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(() => {
|
||||
resetOAuthProviders();
|
||||
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,
|
||||
modelProviders: [fakeModelProvider()],
|
||||
model: { provider: FAKE_PROVIDER, id: FAKE_MODEL_ID },
|
||||
};
|
||||
}
|
||||
|
||||
it("creates a session from an in-memory model 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("exposes composed prompts through the resource loader without discovery", async () => {
|
||||
const { resourceLoader } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
composedPrompt: {
|
||||
customPrompt: "Custom Paseo base prompt.",
|
||||
appendSystemPrompt: ["Profile append.", "Daemon append."],
|
||||
},
|
||||
});
|
||||
|
||||
expect(resourceLoader.getSystemPrompt()).toBe("Custom Paseo base prompt.");
|
||||
expect(resourceLoader.getAppendSystemPrompt()).toEqual(["Profile append.", "Daemon append."]);
|
||||
expect(resourceLoader.getAgentsFiles().agentsFiles).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 model provider registered", async () => {
|
||||
await expect(
|
||||
createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
modelProviders: [],
|
||||
}),
|
||||
).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("honors an explicit agent tool allowlist", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["read", "paseo__demo"],
|
||||
customTools: [
|
||||
{
|
||||
name: "paseo__demo",
|
||||
label: "demo",
|
||||
description: "demo tool",
|
||||
parameters: { type: "object" } as never,
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["paseo__demo", "read"]);
|
||||
});
|
||||
|
||||
it("blocks a denied built-in tool through Pi's preflight hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["bash"],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "bash", action: "deny" }]),
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(["bash"]);
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toEqual({
|
||||
block: true,
|
||||
reason: 'Paseo Agent denied tool "bash" by agent permissions.',
|
||||
});
|
||||
});
|
||||
|
||||
it("allows unmatched built-in tools to fall through the existing Pi hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["bash"],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "read", action: "deny" }]),
|
||||
});
|
||||
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks a denied custom tool through the same Pi preflight hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["paseo__demo"],
|
||||
customTools: [
|
||||
{
|
||||
name: "paseo__demo",
|
||||
label: "demo",
|
||||
description: "demo tool",
|
||||
parameters: { type: "object" } as never,
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "paseo__*", action: "deny" }]),
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(["paseo__demo"]);
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("paseo__demo"))).resolves.toEqual({
|
||||
block: true,
|
||||
reason: 'Paseo Agent denied tool "paseo__demo" by agent permissions.',
|
||||
});
|
||||
});
|
||||
|
||||
it("registers an OAuth provider by flow and seeds the advanced refresh-token override", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const oauthProvider = oauthModelProvider();
|
||||
const { session, modelRegistry } = await createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [
|
||||
{ ...oauthProvider, oauth: { flow: TEST_OAUTH_FLOW, refreshToken: "rt-test-only" } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(session.model?.provider).toBe("subscription");
|
||||
expect(modelRegistry.find("subscription", "oauth-model")?.api).toBe("openai-completions");
|
||||
const available = modelRegistry.getAvailable();
|
||||
expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an OAuth flow that Pi has not registered", async () => {
|
||||
await expect(
|
||||
createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [{ ...oauthModelProvider(), oauth: { flow: "missing-flow" } }],
|
||||
}),
|
||||
).rejects.toThrow(/OAuth flow "missing-flow" is not registered/);
|
||||
});
|
||||
|
||||
it("loads an OAuth credential from a Paseo-owned AuthStorage", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json");
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
authStorage.set("subscription", {
|
||||
type: "oauth",
|
||||
access: "access-stored",
|
||||
refresh: "rt-stored",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const { modelRegistry } = await createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
authStorage,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [{ ...oauthModelProvider(), oauth: { flow: TEST_OAUTH_FLOW } }],
|
||||
});
|
||||
|
||||
const available = modelRegistry.getAvailable();
|
||||
expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe(
|
||||
true,
|
||||
);
|
||||
rmSync(authPath, { force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
AuthStorage,
|
||||
type AgentSession as PiAgentSession,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
createAgentSession,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { BeforeToolCallResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent, OAuthProviderInterface, TextContent } from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import { evaluateToolPermission, type ToolPermissionPolicy } from "./agent-permissions.js";
|
||||
import type { PaseoComposedPrompt } from "./prompt-profiles.js";
|
||||
|
||||
// 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<ModelRegistry["registerProvider"]>[1];
|
||||
type PiAuthData = Parameters<typeof AuthStorage.inMemory>[0];
|
||||
type PiSettings = Parameters<typeof SettingsManager.inMemory>[0];
|
||||
|
||||
export interface PaseoAgentOAuth {
|
||||
flow: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentModelProvider {
|
||||
/** 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;
|
||||
/** Model providers (model backends) registered entirely in memory. */
|
||||
modelProviders: PaseoAgentModelProvider[];
|
||||
/** 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[];
|
||||
/** Optional allowlist of active Pi tool names for this agent definition. */
|
||||
tools?: string[];
|
||||
/** Runtime allow/deny policy for every Pi tool call. */
|
||||
permissionPolicy?: ToolPermissionPolicy;
|
||||
/** Paseo-composed agent/session/daemon instructions. */
|
||||
composedPrompt?: PaseoComposedPrompt;
|
||||
}
|
||||
|
||||
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;
|
||||
composedPrompt?: PaseoComposedPrompt;
|
||||
}): ResourceLoader {
|
||||
const base = new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
settingsManager: options.settingsManager,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
return options.composedPrompt ? wrapPromptResourceLoader(base, options.composedPrompt) : base;
|
||||
}
|
||||
|
||||
function wrapPromptResourceLoader(
|
||||
delegate: ResourceLoader,
|
||||
composedPrompt: PaseoComposedPrompt,
|
||||
): ResourceLoader {
|
||||
return {
|
||||
getExtensions: () => delegate.getExtensions(),
|
||||
getSkills: () => delegate.getSkills(),
|
||||
getPrompts: () => delegate.getPrompts(),
|
||||
getThemes: () => delegate.getThemes(),
|
||||
getAgentsFiles: () => delegate.getAgentsFiles(),
|
||||
getSystemPrompt: () => composedPrompt.customPrompt ?? delegate.getSystemPrompt(),
|
||||
getAppendSystemPrompt: () => [
|
||||
...delegate.getAppendSystemPrompt(),
|
||||
...composedPrompt.appendSystemPrompt,
|
||||
],
|
||||
extendResources: (paths) => delegate.extendResources(paths),
|
||||
reload: () => delegate.reload(),
|
||||
};
|
||||
}
|
||||
|
||||
function installPermissionPolicy(
|
||||
session: PiAgentSession,
|
||||
permissionPolicy: ToolPermissionPolicy | undefined,
|
||||
): void {
|
||||
if (!permissionPolicy || permissionPolicy.rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBeforeToolCall = session.agent.beforeToolCall;
|
||||
session.agent.beforeToolCall = async (
|
||||
context,
|
||||
signal,
|
||||
): Promise<BeforeToolCallResult | undefined> => {
|
||||
const toolName = context.toolCall.name;
|
||||
if (evaluateToolPermission(permissionPolicy, toolName) === "deny") {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Paseo Agent denied tool "${toolName}" by agent permissions.`,
|
||||
};
|
||||
}
|
||||
return previousBeforeToolCall?.(context, signal);
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOAuthProvider(flow: string): OAuthProviderInterface {
|
||||
const provider = getOAuthProvider(flow);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PaseoAgentSessionHandle> {
|
||||
// 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.modelProviders) {
|
||||
if (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.modelProviders) {
|
||||
const config = provider.oauth
|
||||
? { ...provider.config, oauth: resolveOAuthProvider(provider.oauth.flow) }
|
||||
: 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,
|
||||
composedPrompt: options.composedPrompt,
|
||||
});
|
||||
|
||||
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 model 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 } : {}),
|
||||
...(options.tools ? { tools: options.tools } : {}),
|
||||
});
|
||||
|
||||
// Custom (MCP) tools are registered but not active by default — only the built-in
|
||||
// tool set is. Activate them unless an agent definition supplied an explicit tool allowlist.
|
||||
if (!options.tools && options.customTools && options.customTools.length > 0) {
|
||||
const customToolNames = options.customTools.map((tool) => tool.name);
|
||||
session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]);
|
||||
}
|
||||
|
||||
installPermissionPolicy(session, options.permissionPolicy);
|
||||
|
||||
return { session, modelRegistry, resourceLoader, sessionManager };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composePromptParts,
|
||||
listAgentDefinitionIds,
|
||||
loadAgentDefinition,
|
||||
} from "./prompt-profiles.js";
|
||||
|
||||
describe("Paseo Agent definitions", () => {
|
||||
let paseoHome: string;
|
||||
let agentsDir: string;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-profiles-"));
|
||||
agentsDir = join(paseoHome, "agents");
|
||||
mkdirSync(join(agentsDir, "fragments"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeAgent(name: string, content: string): void {
|
||||
writeFileSync(join(agentsDir, name), content);
|
||||
}
|
||||
|
||||
it("parses frontmatter and lists only top-level markdown agents", () => {
|
||||
writeAgent(
|
||||
"orchestrator.md",
|
||||
`---
|
||||
name: Orchestrator
|
||||
description: Routes work
|
||||
prompt: override
|
||||
mcp: [paseo]
|
||||
model: openrouter-main/test-model
|
||||
tools: [read, paseo__list_agents]
|
||||
permissions:
|
||||
- tool: paseo__archive_agent
|
||||
action: deny
|
||||
projectContext: true
|
||||
---
|
||||
Agent body.
|
||||
`,
|
||||
);
|
||||
writeAgent("notes.txt", "ignored");
|
||||
writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment");
|
||||
|
||||
const agent = loadAgentDefinition(paseoHome, "orchestrator");
|
||||
|
||||
expect(listAgentDefinitionIds(paseoHome)).toEqual(["orchestrator"]);
|
||||
expect(agent?.frontmatter).toMatchObject({
|
||||
name: "Orchestrator",
|
||||
description: "Routes work",
|
||||
prompt: "override",
|
||||
mcp: ["paseo"],
|
||||
model: "openrouter-main/test-model",
|
||||
tools: ["read", "paseo__list_agents"],
|
||||
permissions: [{ tool: "paseo__archive_agent", action: "deny" }],
|
||||
projectContext: true,
|
||||
});
|
||||
expect(agent?.composedPrompt.customPrompt).toBe("Agent body.");
|
||||
});
|
||||
|
||||
it("defaults to extend prompt mode", () => {
|
||||
writeAgent("worker.md", "Body.");
|
||||
|
||||
const agent = loadAgentDefinition(paseoHome, "worker.md");
|
||||
|
||||
expect(agent?.frontmatter.prompt).toBe("extend");
|
||||
expect(agent?.body).toBe("Body.");
|
||||
expect(agent?.composedPrompt.appendSystemPrompt).toEqual(["Body."]);
|
||||
});
|
||||
|
||||
it("resolves bang-brace partials in place relative to the current file", () => {
|
||||
mkdirSync(join(agentsDir, "team", "partials"), { recursive: true });
|
||||
writeFileSync(join(agentsDir, "team", "partials", "style.md"), "Use short answers.");
|
||||
writeFileSync(join(agentsDir, "team", "nested.md"), "nested !{{./partials/style.md}}");
|
||||
writeAgent("inline.md", "Before\n!{{./team/nested.md}}\nAfter");
|
||||
|
||||
expect(loadAgentDefinition(paseoHome, "inline")?.body).toBe(
|
||||
"Before\nnested Use short answers.\nAfter",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects frontmatter inside partials", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "bad.md"), "---\nname: Nope\n---\nfragment");
|
||||
writeAgent("agent.md", "!{{./fragments/bad.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "agent")).toThrow(/partials cannot declare/i);
|
||||
});
|
||||
|
||||
it("detects partial cycles", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "a.md"), "!{{./b.md}}");
|
||||
writeFileSync(join(agentsDir, "fragments", "b.md"), "!{{./a.md}}");
|
||||
writeAgent("cycle.md", "!{{./fragments/a.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "cycle")).toThrow(/cycle/i);
|
||||
});
|
||||
|
||||
it("rejects missing partials and path escapes", () => {
|
||||
writeAgent("missing.md", "!{{./fragments/nope.md}}");
|
||||
writeAgent("escape.md", "!{{../secret.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "missing")).toThrow(/not found/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "escape")).toThrow(/escape|invalid/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "../escape")).toThrow(/invalid/i);
|
||||
});
|
||||
|
||||
it("rejects symlink escapes for agents and partials", () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), "paseo-agent-profile-outside-"));
|
||||
tempDirs.push(outsideDir);
|
||||
writeFileSync(join(outsideDir, "secret.md"), "outside secret");
|
||||
symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "linked-profile.md"));
|
||||
symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "fragments", "linked.md"));
|
||||
writeAgent("include-link.md", "!{{./fragments/linked.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "linked-profile")).toThrow(/escapes/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "include-link")).toThrow(/escapes/i);
|
||||
});
|
||||
|
||||
it("enforces depth and total size caps", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "deep.md"), "!{{./deeper.md}}");
|
||||
writeFileSync(join(agentsDir, "fragments", "deeper.md"), "done");
|
||||
writeAgent("depth.md", "!{{./fragments/deep.md}}");
|
||||
writeAgent("large.md", "0123456789");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i);
|
||||
});
|
||||
|
||||
it("orders agent append, session prompt, and daemon append with daemon last", () => {
|
||||
writeAgent("extend.md", "Agent prompt.");
|
||||
const agent = loadAgentDefinition(paseoHome, "extend");
|
||||
|
||||
expect(
|
||||
composePromptParts({
|
||||
agent,
|
||||
systemPrompt: " Session prompt. ",
|
||||
daemonAppendSystemPrompt: "Daemon prompt.",
|
||||
}),
|
||||
).toEqual({
|
||||
appendSystemPrompt: ["Agent prompt.", "Session prompt.", "Daemon prompt."],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses override agent body as custom prompt while appending session and daemon prompts", () => {
|
||||
writeAgent(
|
||||
"override.md",
|
||||
`---
|
||||
prompt: override
|
||||
---
|
||||
Replacement base.
|
||||
`,
|
||||
);
|
||||
const agent = loadAgentDefinition(paseoHome, "override");
|
||||
|
||||
expect(
|
||||
composePromptParts({
|
||||
agent,
|
||||
systemPrompt: "Session prompt.",
|
||||
daemonAppendSystemPrompt: "Daemon prompt.",
|
||||
}),
|
||||
).toEqual({
|
||||
customPrompt: "Replacement base.",
|
||||
appendSystemPrompt: ["Session prompt.", "Daemon prompt."],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { ToolPermissionRuleSchema, type ToolPermissionRule } from "./agent-permissions.js";
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 8;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024;
|
||||
const PARTIAL_PATTERN = /!\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
const AgentDefinitionFrontmatterSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
prompt: z.enum(["extend", "override"]).default("extend"),
|
||||
mcp: z.array(z.string().min(1)).optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
tools: z.array(z.string().min(1)).optional(),
|
||||
permissions: z.array(ToolPermissionRuleSchema).optional(),
|
||||
// Parsed for the future explicit project-context model. It is intentionally
|
||||
// inactive here; Paseo Agent still keeps implicit AGENTS.md discovery off.
|
||||
projectContext: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type AgentDefinitionFrontmatter = z.infer<typeof AgentDefinitionFrontmatterSchema>;
|
||||
export type PromptProfileFrontmatter = AgentDefinitionFrontmatter;
|
||||
|
||||
export interface PaseoComposedPrompt {
|
||||
customPrompt?: string;
|
||||
appendSystemPrompt: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedAgentDefinition {
|
||||
id: string;
|
||||
path: string;
|
||||
frontmatter: AgentDefinitionFrontmatter;
|
||||
body: string;
|
||||
composedPrompt: PaseoComposedPrompt;
|
||||
expectedMcpServers: string[];
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
permissions: ToolPermissionRule[];
|
||||
}
|
||||
|
||||
export type ResolvedPromptProfile = ResolvedAgentDefinition;
|
||||
|
||||
interface LoadAgentDefinitionOptions {
|
||||
maxDepth?: number;
|
||||
maxTotalBytes?: number;
|
||||
}
|
||||
|
||||
interface LoadState {
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
frontmatter: AgentDefinitionFrontmatter;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function loadAgentDefinition(
|
||||
paseoHome: string,
|
||||
agentName: string | undefined,
|
||||
options: LoadAgentDefinitionOptions = {},
|
||||
): ResolvedAgentDefinition | null {
|
||||
if (!agentName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentsDir = resolve(paseoHome, "agents");
|
||||
const agentPath = resolveAgentPath(agentsDir, agentName);
|
||||
if (!existsSync(agentPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state: LoadState = { totalBytes: 0 };
|
||||
const parsed = loadMarkdownWithPartials({
|
||||
agentsDir,
|
||||
path: agentPath,
|
||||
depth: 0,
|
||||
stack: [],
|
||||
state,
|
||||
options,
|
||||
allowFrontmatter: true,
|
||||
});
|
||||
const body = trimPrompt(parsed.body);
|
||||
const promptMode = parsed.frontmatter.prompt;
|
||||
const composedPrompt =
|
||||
promptMode === "override"
|
||||
? { customPrompt: body, appendSystemPrompt: [] }
|
||||
: { appendSystemPrompt: body ? [body] : [] };
|
||||
|
||||
return {
|
||||
id: basename(agentPath, ".md"),
|
||||
path: agentPath,
|
||||
frontmatter: parsed.frontmatter,
|
||||
body,
|
||||
composedPrompt,
|
||||
expectedMcpServers: parsed.frontmatter.mcp ?? [],
|
||||
...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}),
|
||||
...(parsed.frontmatter.tools ? { tools: parsed.frontmatter.tools } : {}),
|
||||
permissions: parsed.frontmatter.permissions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPromptProfile(
|
||||
paseoHome: string,
|
||||
profileName: string | undefined,
|
||||
options: LoadAgentDefinitionOptions = {},
|
||||
): ResolvedPromptProfile | null {
|
||||
return loadAgentDefinition(paseoHome, profileName, options);
|
||||
}
|
||||
|
||||
export function listAgentDefinitionIds(paseoHome: string): string[] {
|
||||
const agentsDir = resolve(paseoHome, "agents");
|
||||
if (!existsSync(agentsDir)) {
|
||||
return [];
|
||||
}
|
||||
return readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
||||
.map((entry) => basename(entry.name, ".md"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function listPromptProfileIds(paseoHome: string): string[] {
|
||||
return listAgentDefinitionIds(paseoHome);
|
||||
}
|
||||
|
||||
function resolveAgentPath(agentsDir: string, agentName: string): string {
|
||||
if (!isSafeRelativePath(agentName) || agentName.includes("/") || agentName.includes("\\")) {
|
||||
throw new Error(`Invalid Paseo Agent definition path: ${agentName}`);
|
||||
}
|
||||
const filename = agentName.endsWith(".md") ? agentName : `${agentName}.md`;
|
||||
return resolveConfinedPath(agentsDir, filename);
|
||||
}
|
||||
|
||||
function resolvePartialPath(agentsDir: string, currentPath: string, partialPath: string): string {
|
||||
if (!isSafeRelativePath(partialPath)) {
|
||||
throw new Error(`Invalid Paseo Agent partial path: ${partialPath}`);
|
||||
}
|
||||
return resolveConfinedPath(agentsDir, resolve(dirname(currentPath), partialPath));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(input: string): boolean {
|
||||
return input.trim() === input && input.length > 0 && !isAbsolute(input) && !input.includes("\0");
|
||||
}
|
||||
|
||||
function resolveConfinedPath(agentsDir: string, input: string): string {
|
||||
const realAgentsDir = existsSync(agentsDir) ? realpathSync(agentsDir) : agentsDir;
|
||||
const resolved = isAbsolute(input) ? input : resolve(agentsDir, input);
|
||||
const comparablePath = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
||||
const rel = relative(realAgentsDir, comparablePath);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Paseo Agent path escapes agents directory: ${input}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function loadMarkdownWithPartials(input: {
|
||||
agentsDir: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
stack: string[];
|
||||
state: LoadState;
|
||||
options: LoadAgentDefinitionOptions;
|
||||
allowFrontmatter: boolean;
|
||||
}): ParsedMarkdown {
|
||||
const maxDepth = input.options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
if (input.depth > maxDepth) {
|
||||
throw new Error(`Paseo Agent prompt include depth exceeds ${maxDepth}`);
|
||||
}
|
||||
if (!existsSync(input.path) || !statSync(input.path).isFile()) {
|
||||
throw new Error(`Paseo Agent partial not found: ${relative(input.agentsDir, input.path)}`);
|
||||
}
|
||||
|
||||
const path = realpathConfined(input.agentsDir, input.path);
|
||||
if (input.stack.includes(path)) {
|
||||
const cycle = [...input.stack, path].map((entry) => relative(input.agentsDir, entry));
|
||||
throw new Error(`Paseo Agent partial cycle: ${cycle.join(" -> ")}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
input.state.totalBytes += Buffer.byteLength(raw, "utf8");
|
||||
const maxTotalBytes = input.options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
||||
if (input.state.totalBytes > maxTotalBytes) {
|
||||
throw new Error(`Paseo Agent definition exceeds ${maxTotalBytes} bytes`);
|
||||
}
|
||||
|
||||
const parsed = parseMarkdown(raw, input.allowFrontmatter);
|
||||
const stack = [...input.stack, path];
|
||||
const bodyWithPartials = parsed.body.replace(
|
||||
PARTIAL_PATTERN,
|
||||
(_match, partialPath: string) =>
|
||||
loadMarkdownWithPartials({
|
||||
...input,
|
||||
path: resolvePartialPath(input.agentsDir, path, partialPath.trim()),
|
||||
depth: input.depth + 1,
|
||||
stack,
|
||||
allowFrontmatter: false,
|
||||
}).body,
|
||||
);
|
||||
|
||||
return {
|
||||
frontmatter: parsed.frontmatter,
|
||||
body: bodyWithPartials,
|
||||
};
|
||||
}
|
||||
|
||||
function realpathConfined(agentsDir: string, path: string): string {
|
||||
const realAgentsDir = realpathSync(agentsDir);
|
||||
const realPath = realpathSync(path);
|
||||
const rel = relative(realAgentsDir, realPath);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Paseo Agent path escapes agents directory: ${relative(agentsDir, path)}`);
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
|
||||
function parseMarkdown(raw: string, allowFrontmatter: boolean): ParsedMarkdown {
|
||||
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
||||
return {
|
||||
frontmatter: AgentDefinitionFrontmatterSchema.parse({}),
|
||||
body: raw,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowFrontmatter) {
|
||||
throw new Error("Paseo Agent partials cannot declare frontmatter");
|
||||
}
|
||||
|
||||
const newline = raw.startsWith("---\r\n") ? "\r\n" : "\n";
|
||||
const closeMarker = `${newline}---${newline}`;
|
||||
const closeIndex = raw.indexOf(closeMarker, 4);
|
||||
if (closeIndex === -1) {
|
||||
throw new Error("Paseo Agent definition has unterminated frontmatter");
|
||||
}
|
||||
|
||||
const yaml = raw.slice(4, closeIndex);
|
||||
const body = raw.slice(closeIndex + closeMarker.length);
|
||||
const value = yaml.trim() ? parseYaml(yaml) : {};
|
||||
return {
|
||||
frontmatter: AgentDefinitionFrontmatterSchema.parse(value ?? {}),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function composePromptParts(input: {
|
||||
agent?: ResolvedAgentDefinition | null;
|
||||
systemPrompt?: string;
|
||||
daemonAppendSystemPrompt?: string;
|
||||
}): PaseoComposedPrompt | undefined {
|
||||
const agentPrompt = input.agent?.composedPrompt;
|
||||
const appendSystemPrompt = [
|
||||
...(agentPrompt?.appendSystemPrompt ?? []),
|
||||
input.systemPrompt,
|
||||
input.daemonAppendSystemPrompt,
|
||||
].flatMap((part) => {
|
||||
const trimmed = trimPrompt(part);
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
const hasCustomPrompt = Boolean(
|
||||
agentPrompt && Object.prototype.hasOwnProperty.call(agentPrompt, "customPrompt"),
|
||||
);
|
||||
const customPrompt = trimPrompt(agentPrompt?.customPrompt);
|
||||
|
||||
if (!hasCustomPrompt && appendSystemPrompt.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(hasCustomPrompt ? { customPrompt } : {}),
|
||||
appendSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function trimPrompt(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@@ -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<string, ProviderOverride>;
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
@@ -739,6 +741,8 @@ export async function createPaseoDaemon(
|
||||
logger: providerSnapshotLogger,
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
paseoAgentConfig: config.paseoAgentConfig,
|
||||
paseoHome: config.paseoHome,
|
||||
workspaceGitService,
|
||||
managedProcesses,
|
||||
isDev: config.isDev === true,
|
||||
@@ -996,7 +1000,6 @@ export async function createPaseoDaemon(
|
||||
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
|
||||
|
||||
const mcpEnabled = config.mcpEnabled ?? true;
|
||||
let agentMcpBaseUrl: string | null = null;
|
||||
if (mcpEnabled) {
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
|
||||
@@ -1155,11 +1158,9 @@ export async function createPaseoDaemon(
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
agentManager.setMcpBaseUrl(mcpBaseUrl);
|
||||
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
agentManager.setPaseoToolsEnabled(value !== false);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
|
||||
@@ -505,6 +505,7 @@ export function loadConfig(
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
paseoAgentConfig: persisted.agents?.paseo,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import {
|
||||
DaemonConfigStore,
|
||||
applyMutableProviderConfigToOverrides,
|
||||
type MutableDaemonConfigPatch,
|
||||
} from "./daemon-config-store.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
|
||||
describe("applyMutableProviderConfigToOverrides", () => {
|
||||
@@ -119,6 +123,100 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("generic mutable config strips agent provider config instead of echoing secrets", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const broadcasts: unknown[] = [];
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
store.onChange((config) => broadcasts.push(config));
|
||||
|
||||
const secretBearingPatch: MutableDaemonConfigPatch & { agents: unknown } = {
|
||||
agents: {
|
||||
providers: {
|
||||
custom: {
|
||||
env: {
|
||||
API_KEY: "shared-provider-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
paseo: {
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
headers: { Authorization: "Bearer header-secret" },
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const returned = store.patch(secretBearingPatch);
|
||||
|
||||
const returnedJson = JSON.stringify(returned);
|
||||
const broadcastJson = JSON.stringify(broadcasts);
|
||||
expect(returnedJson).not.toContain("sk-secret-openrouter");
|
||||
expect(returnedJson).not.toContain("header-secret");
|
||||
expect(returnedJson).not.toContain("shared-provider-secret");
|
||||
expect(broadcastJson).not.toContain("sk-secret-openrouter");
|
||||
expect(broadcastJson).not.toContain("header-secret");
|
||||
expect(broadcastJson).not.toContain("shared-provider-secret");
|
||||
expect(loadPersistedConfig(paseoHome).agents?.providers).toBeUndefined();
|
||||
expect(loadPersistedConfig(paseoHome).agents?.paseo).toBeUndefined();
|
||||
});
|
||||
|
||||
test("mutable provider patches preserve existing dedicated Paseo Agent config on disk", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const initial = loadPersistedConfig(paseoHome);
|
||||
initial.agents = {
|
||||
paseo: {
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(path.join(paseoHome, "config.json"), JSON.stringify(initial, null, 2) + "\n");
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
providers: {
|
||||
claude: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe(
|
||||
"sk-secret-openrouter",
|
||||
);
|
||||
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("patch persists append system prompt into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -62,6 +62,16 @@ function isEqualValue(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function stripAgentProviderSecretConfig<T extends Record<string, unknown>>(config: T): T {
|
||||
if (!Object.prototype.hasOwnProperty.call(config, "agents")) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...config };
|
||||
delete next.agents;
|
||||
return next as T;
|
||||
}
|
||||
|
||||
export function applyMutableProviderConfigToOverrides(
|
||||
baseOverrides: Record<string, ProviderOverride> | undefined,
|
||||
mutableProviders: MutableDaemonConfig["providers"] | undefined,
|
||||
@@ -91,7 +101,7 @@ export class DaemonConfigStore {
|
||||
constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
|
||||
this.paseoHome = paseoHome;
|
||||
this.logger = getLogger(logger);
|
||||
this.current = MutableDaemonConfigSchema.parse(initial);
|
||||
this.current = stripAgentProviderSecretConfig(MutableDaemonConfigSchema.parse(initial));
|
||||
}
|
||||
|
||||
public get(): MutableDaemonConfig {
|
||||
@@ -99,8 +109,12 @@ export class DaemonConfigStore {
|
||||
}
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
|
||||
const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
|
||||
const parsedPatch = stripAgentProviderSecretConfig(
|
||||
MutableDaemonConfigPatchSchema.parse(partial),
|
||||
);
|
||||
const next = stripAgentProviderSecretConfig(
|
||||
MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)),
|
||||
);
|
||||
|
||||
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
|
||||
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import pino from "pino";
|
||||
|
||||
import { PaseoAgentSession } from "../agent/providers/paseo-agent/agent.js";
|
||||
import type { PaseoAgentSessionHandle } from "../agent/providers/paseo-agent/pi-services.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
FetchCatalogOptions,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
|
||||
const PASEO_AGENT_TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
requiresPaseoTools: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
class FakeInProcessPiSession {
|
||||
readonly sessionId: string;
|
||||
readonly thinkingLevel = "medium";
|
||||
readonly model = { provider: "openrouter-main", id: "test-model" };
|
||||
readonly messages: Array<{ role: string; content: unknown; toolCallId?: string }> = [];
|
||||
readonly agent = { state: { errorMessage: "" } };
|
||||
abortCalls = 0;
|
||||
disposeCalls = 0;
|
||||
promptCalls: Array<{ text: string; options: unknown }> = [];
|
||||
private readonly subscribers = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
constructor(sessionId: string) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentSessionEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(text: string, options?: unknown): Promise<void> {
|
||||
this.promptCalls.push({ text, options });
|
||||
this.messages.push({ role: "user", content: text });
|
||||
setTimeout(() => {
|
||||
this.emit({ type: "agent_start" } as AgentSessionEvent);
|
||||
this.emit({ type: "turn_start" } as AgentSessionEvent);
|
||||
this.messages.push({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `ack: ${text}` }],
|
||||
});
|
||||
this.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: `ack: ${text}` },
|
||||
} as AgentSessionEvent);
|
||||
this.emit({
|
||||
type: "agent_end",
|
||||
messages: this.messages,
|
||||
willRetry: false,
|
||||
} as AgentSessionEvent);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortCalls += 1;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeCalls += 1;
|
||||
}
|
||||
|
||||
getSessionStats() {
|
||||
return {
|
||||
sessionFile: undefined,
|
||||
sessionId: this.sessionId,
|
||||
userMessages: 1,
|
||||
assistantMessages: 1,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
totalMessages: 2,
|
||||
tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, total: 2 },
|
||||
cost: 0,
|
||||
contextUsage: { contextWindow: 200000, tokens: 2, percentage: 0.01 },
|
||||
};
|
||||
}
|
||||
|
||||
setThinkingLevel(): void {}
|
||||
|
||||
async setModel(): Promise<void> {}
|
||||
|
||||
private emit(event: AgentSessionEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingPaseoAgentClient implements AgentClient {
|
||||
readonly provider = "paseo";
|
||||
readonly capabilities = PASEO_AGENT_TEST_CAPABILITIES;
|
||||
readonly createdConfigs: AgentSessionConfig[] = [];
|
||||
private nextSessionOrdinal = 0;
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createdConfigs.push(config);
|
||||
this.nextSessionOrdinal += 1;
|
||||
const fakePi = new FakeInProcessPiSession(`fake-paseo-session-${this.nextSessionOrdinal}`);
|
||||
const handle = {
|
||||
session: fakePi,
|
||||
modelRegistry: { find: () => fakePi.model },
|
||||
resourceLoader: {},
|
||||
sessionManager: {},
|
||||
} as unknown as PaseoAgentSessionHandle;
|
||||
const mcpBridge = {
|
||||
tools: [],
|
||||
async close() {},
|
||||
};
|
||||
return new PaseoAgentSession(handle, config, mcpBridge, null, []);
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("test Paseo Agent client does not support resume");
|
||||
}
|
||||
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<{
|
||||
models: AgentModelDefinition[];
|
||||
modes: AgentMode[];
|
||||
}> {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
provider: "paseo",
|
||||
id: "openrouter-main/test-model",
|
||||
label: "Test Model",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function textTimelineItems(
|
||||
entries: ReadonlyArray<{ item: { type: string; text?: string; messageId?: string } }>,
|
||||
) {
|
||||
return entries
|
||||
.filter(
|
||||
(entry) => entry.item.type === "user_message" || entry.item.type === "assistant_message",
|
||||
)
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
describe("daemon E2E - Paseo Agent persistence", () => {
|
||||
const tempDirs: string[] = [];
|
||||
let daemon: TestPaseoDaemon | undefined;
|
||||
let client: DaemonClient | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
client = undefined;
|
||||
daemon = undefined;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Paseo Agent user messages survive a client refresh and agent reload", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-persist-cwd-"));
|
||||
tempDirs.push(cwd);
|
||||
const paseo = new RecordingPaseoAgentClient();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
agentClients: { paseo },
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "paseo-agent-persistence-test" } });
|
||||
|
||||
const agent = await client.createAgent({ provider: "paseo", cwd });
|
||||
await client.sendMessage(agent.id, "hello from the user", { messageId: "client-message-1" });
|
||||
await client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
await client.close();
|
||||
client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "paseo-agent-persistence-refresh-test" },
|
||||
});
|
||||
|
||||
const afterClientRefresh = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(textTimelineItems(afterClientRefresh.entries)).toEqual([
|
||||
{ type: "user_message", text: "hello from the user", messageId: "client-message-1" },
|
||||
{ type: "assistant_message", text: "ack: hello from the user" },
|
||||
]);
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
const afterAgentReload = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(textTimelineItems(afterAgentReload.entries)).toEqual([
|
||||
{ type: "user_message", text: "hello from the user", messageId: "client-message-1" },
|
||||
{ type: "assistant_message", text: "ack: hello from the user" },
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
test("Paseo Agent gets the internal Paseo MCP server when global tool injection is off", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-tools-cwd-"));
|
||||
tempDirs.push(cwd);
|
||||
const paseo = new RecordingPaseoAgentClient();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
agentClients: { paseo },
|
||||
mcpInjectIntoAgents: false,
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "paseo-agent-tools-test" } });
|
||||
|
||||
const agent = await client.createAgent({ provider: "paseo", cwd });
|
||||
|
||||
expect(paseo.createdConfigs.at(-1)?.mcpServers?.paseo).toMatchObject({
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agent.id}`,
|
||||
});
|
||||
expect(daemon.daemon.agentManager.getAgent(agent.id)?.config.mcpServers?.paseo).toBeUndefined();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -48,6 +48,14 @@ export {
|
||||
type SherpaLoaderEnvResolution,
|
||||
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
// Paseo Agent OAuth login + Paseo-owned credential store
|
||||
export {
|
||||
loginOAuthBrowser,
|
||||
loginAndStoreOAuth,
|
||||
paseoAgentAuthStoragePath,
|
||||
type OAuthDeviceCodeInfo,
|
||||
} from "./agent/providers/paseo-agent/oauth-store.js";
|
||||
|
||||
// Provider binary resolution
|
||||
export {
|
||||
type ProviderOverride,
|
||||
|
||||
@@ -241,6 +241,34 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the dedicated agents.paseo model provider config", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
paseo: {
|
||||
defaultModel: "openrouter-main/anthropic/claude",
|
||||
defaultAgent: "builder",
|
||||
defaultProfile: "orchestrator",
|
||||
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?.defaultAgent).toBe("builder");
|
||||
expect(parsed.agents?.paseo?.defaultProfile).toBe("orchestrator");
|
||||
expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider overrides (new format)", () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
@@ -20,6 +20,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||
import type { OAuthLogin } from "./agent/providers/paseo-agent/oauth-store.js";
|
||||
import {
|
||||
asSessionInternals as asSessionInternalsHelper,
|
||||
asAgentManager,
|
||||
@@ -218,6 +219,7 @@ interface SessionForTestOptions {
|
||||
serverId?: SessionOptions["serverId"];
|
||||
daemonVersion?: SessionOptions["daemonVersion"];
|
||||
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
|
||||
paseoAgentOAuthLogin?: OAuthLogin;
|
||||
downloadTokenStore?: SessionOptions["downloadTokenStore"];
|
||||
messages?: unknown[];
|
||||
binaryMessages?: Uint8Array[];
|
||||
@@ -304,6 +306,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
serverId: options.serverId,
|
||||
daemonVersion: options.daemonVersion,
|
||||
daemonRuntimeConfig: options.daemonRuntimeConfig,
|
||||
paseoAgentOAuthLogin: options.paseoAgentOAuthLogin,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1154,6 +1157,356 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Paseo Agent config catalog RPCs", () => {
|
||||
test("get_catalog returns static manifests without credential material", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
requestId: "catalog-1",
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.get_catalog.response" }
|
||||
> => message.type === "config.paseo_agent.get_catalog.response",
|
||||
);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.catalog.map((entry) => entry.id)).toEqual([
|
||||
"openrouter",
|
||||
"chatgpt",
|
||||
"kimi",
|
||||
"opencode-go",
|
||||
]);
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("apiKey");
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("access-token");
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("oauth.start fails clearly for an API-key provider instance", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ messages, paseoHome: home });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "oauth-start-api-key",
|
||||
name: "openrouter-main",
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.start.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.start.response",
|
||||
);
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "oauth-start-api-key",
|
||||
success: false,
|
||||
name: "openrouter-main",
|
||||
authorization: null,
|
||||
});
|
||||
expect(response?.payload.error).toContain("does not use OAuth");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("oauth.start defaults to browser auth-url and complete stores the callback credential", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
subscription: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const callbackCredential = deferred<Awaited<ReturnType<OAuthLogin>>>();
|
||||
const login = vi.fn<OAuthLogin>(async (callbacks) => {
|
||||
callbacks.onAuth({
|
||||
url: "https://auth.example.test/oauth/authorize?client_id=test-client",
|
||||
instructions: "Open this URL.",
|
||||
});
|
||||
return callbackCredential.promise;
|
||||
});
|
||||
const { manager: providerSnapshotManager, refreshSettingsSnapshot } =
|
||||
createProviderSnapshotManagerStub();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
paseoHome: home,
|
||||
providerSnapshotManager,
|
||||
agentManager: { updateProviderRegistry: vi.fn() },
|
||||
paseoAgentOAuthLogin: login,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "oauth-start-browser",
|
||||
name: "subscription",
|
||||
});
|
||||
|
||||
const startResponse = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.start.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.start.response",
|
||||
);
|
||||
expect(startResponse?.payload).toEqual({
|
||||
requestId: "oauth-start-browser",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://auth.example.test/oauth/authorize?client_id=test-client",
|
||||
instructions: "Open this URL.",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
callbackCredential.resolve({
|
||||
access: "browser-access-token",
|
||||
refresh: "browser-refresh-token",
|
||||
expires: 456,
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
requestId: "oauth-complete-browser",
|
||||
name: "subscription",
|
||||
});
|
||||
|
||||
const completeResponse = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.complete.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.complete.response",
|
||||
);
|
||||
expect(completeResponse?.payload).toEqual({
|
||||
requestId: "oauth-complete-browser",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
});
|
||||
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({ providers: ["paseo"] });
|
||||
const stored = JSON.parse(readFileSync(join(home, "paseo-agent", "auth.json"), "utf8"));
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "browser-access-token",
|
||||
refresh: "browser-refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
});
|
||||
expect(login).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("oauth.start mode=device_code keeps the device-code authorization path", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
subscription: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const login = vi.fn<OAuthLogin>(async (callbacks) => {
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
return { access: "device-access-token", refresh: "device-refresh-token", expires: 789 };
|
||||
});
|
||||
const { manager: providerSnapshotManager } = createProviderSnapshotManagerStub();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
paseoHome: home,
|
||||
providerSnapshotManager,
|
||||
agentManager: { updateProviderRegistry: vi.fn() },
|
||||
paseoAgentOAuthLogin: login,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "oauth-start-device-code",
|
||||
name: "subscription",
|
||||
mode: "device_code",
|
||||
});
|
||||
|
||||
const startResponse = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.start.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.start.response",
|
||||
);
|
||||
expect(startResponse?.payload).toEqual({
|
||||
requestId: "oauth-start-device-code",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
requestId: "oauth-complete-device-code",
|
||||
name: "subscription",
|
||||
});
|
||||
|
||||
const completeResponse = messages.findLast(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.complete.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.complete.response",
|
||||
);
|
||||
expect(completeResponse?.payload).toMatchObject({
|
||||
requestId: "oauth-complete-device-code",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("oauth.store_credential persists a bound credential without echoing tokens", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
subscription: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { manager: providerSnapshotManager, refreshSettingsSnapshot } =
|
||||
createProviderSnapshotManagerStub();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
paseoHome: home,
|
||||
providerSnapshotManager,
|
||||
agentManager: { updateProviderRegistry: vi.fn() },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
requestId: "oauth-store-1",
|
||||
name: "subscription",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.store_credential.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.store_credential.response",
|
||||
);
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "oauth-store-1",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
});
|
||||
expect(JSON.stringify(response)).not.toContain("access-token");
|
||||
expect(JSON.stringify(response)).not.toContain("refresh-token");
|
||||
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({ providers: ["paseo"] });
|
||||
|
||||
const stored = JSON.parse(readFileSync(join(home, "paseo-agent", "auth.json"), "utf8"));
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session provider refresh cwd routing", () => {
|
||||
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stat } from "node:fs/promises";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
serializeAgentStreamEvent,
|
||||
type AgentSnapshotPayload,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
type PaseoAgentOAuthCredential,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
TerminalManager,
|
||||
@@ -68,6 +70,13 @@ import {
|
||||
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { PaseoAgentConfigService } from "./agent/providers/paseo-agent/config-service.js";
|
||||
import {
|
||||
loginOAuthBrowser,
|
||||
loginOAuthDevice,
|
||||
type OAuthCredentialBinding,
|
||||
type OAuthLogin,
|
||||
} from "./agent/providers/paseo-agent/oauth-store.js";
|
||||
import type {
|
||||
AgentManagerEvent,
|
||||
AgentTimelineCursor,
|
||||
@@ -376,6 +385,139 @@ interface WorkspaceUpdatesSubscriptionState {
|
||||
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
||||
}
|
||||
|
||||
const PASEO_AGENT_OAUTH_PENDING_TTL_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_PASEO_AGENT_OAUTH_MODE = "browser";
|
||||
|
||||
type PaseoAgentOAuthMode = "browser" | "device_code";
|
||||
|
||||
interface PaseoAgentOAuthStartAuthorization {
|
||||
[key: string]: unknown;
|
||||
kind: string;
|
||||
url?: string;
|
||||
instructions?: string;
|
||||
userCode?: string;
|
||||
verificationUri?: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
interface DeferredValue<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface PendingPaseoAgentOAuthFlow {
|
||||
name: string;
|
||||
binding: OAuthCredentialBinding;
|
||||
abortController: AbortController;
|
||||
expiresAt: number;
|
||||
authorizationPromise: Promise<PaseoAgentOAuthStartAuthorization | null>;
|
||||
credentialPromise: Promise<PaseoAgentOAuthCredential>;
|
||||
}
|
||||
|
||||
interface CreatePendingPaseoAgentOAuthFlowOptions {
|
||||
name: string;
|
||||
binding: OAuthCredentialBinding;
|
||||
mode: PaseoAgentOAuthMode;
|
||||
login?: OAuthLogin;
|
||||
}
|
||||
|
||||
function deferredValue<T>(): DeferredValue<T> {
|
||||
let resolveValue!: (value: T | PromiseLike<T>) => void;
|
||||
let rejectValue!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolveValue = innerResolve;
|
||||
rejectValue = innerReject;
|
||||
});
|
||||
return { promise, resolve: resolveValue, reject: rejectValue };
|
||||
}
|
||||
|
||||
function deviceCodeAuthorization(info: OAuthDeviceCodeInfo): PaseoAgentOAuthStartAuthorization {
|
||||
return {
|
||||
kind: "device_code",
|
||||
userCode: info.userCode,
|
||||
verificationUri: info.verificationUri,
|
||||
...(info.intervalSeconds !== undefined ? { intervalSeconds: info.intervalSeconds } : {}),
|
||||
...(info.expiresInSeconds !== undefined ? { expiresInSeconds: info.expiresInSeconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function authUrlAuthorization(
|
||||
url: string,
|
||||
instructions?: string,
|
||||
): PaseoAgentOAuthStartAuthorization {
|
||||
return {
|
||||
kind: "auth_url",
|
||||
url,
|
||||
...(instructions ? { instructions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePaseoAgentOAuthMode(mode: string | undefined): PaseoAgentOAuthMode {
|
||||
const resolved = mode ?? DEFAULT_PASEO_AGENT_OAUTH_MODE;
|
||||
if (resolved === "browser" || resolved === "device_code") {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(`Unsupported Paseo Agent OAuth mode "${resolved}".`);
|
||||
}
|
||||
|
||||
function createPendingPaseoAgentOAuthFlow(
|
||||
options: CreatePendingPaseoAgentOAuthFlowOptions,
|
||||
): PendingPaseoAgentOAuthFlow {
|
||||
const abortController = new AbortController();
|
||||
const authorization = deferredValue<PaseoAgentOAuthStartAuthorization | null>();
|
||||
let authorizationSettled = false;
|
||||
|
||||
function settleAuthorization(value: PaseoAgentOAuthStartAuthorization | null): void {
|
||||
if (authorizationSettled) {
|
||||
return;
|
||||
}
|
||||
authorizationSettled = true;
|
||||
authorization.resolve(value);
|
||||
}
|
||||
|
||||
function rejectAuthorization(error: unknown): void {
|
||||
if (authorizationSettled) {
|
||||
return;
|
||||
}
|
||||
authorizationSettled = true;
|
||||
authorization.reject(error);
|
||||
}
|
||||
|
||||
const credentialPromise =
|
||||
options.mode === "browser"
|
||||
? loginOAuthBrowser({
|
||||
flow: options.binding.flow,
|
||||
onAuthUrl: (url, instructions) =>
|
||||
settleAuthorization(authUrlAuthorization(url, instructions)),
|
||||
login: options.login,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
: loginOAuthDevice({
|
||||
flow: options.binding.flow,
|
||||
onDeviceCode: (info) => settleAuthorization(deviceCodeAuthorization(info)),
|
||||
login: options.login,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const storedCredentialPromise = credentialPromise.then(
|
||||
(credential): PaseoAgentOAuthCredential => {
|
||||
settleAuthorization(null);
|
||||
return credential;
|
||||
},
|
||||
);
|
||||
void storedCredentialPromise.catch((error) => rejectAuthorization(error));
|
||||
|
||||
return {
|
||||
name: options.name,
|
||||
binding: options.binding,
|
||||
abortController,
|
||||
expiresAt: Date.now() + PASEO_AGENT_OAUTH_PENDING_TTL_MS,
|
||||
authorizationPromise: authorization.promise,
|
||||
credentialPromise: storedCredentialPromise,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -470,6 +612,7 @@ export interface SessionOptions {
|
||||
daemonVersion?: string;
|
||||
daemonRuntimeConfig?: DaemonRuntimeConfig;
|
||||
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
paseoAgentOAuthLogin?: OAuthLogin;
|
||||
}
|
||||
|
||||
export type SessionLifecycleIntent =
|
||||
@@ -569,6 +712,9 @@ export class Session {
|
||||
} | null = null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private paseoAgentConfigService: PaseoAgentConfigService | null = null;
|
||||
private readonly paseoAgentOAuthLogin: OAuthLogin | undefined;
|
||||
private readonly pendingPaseoAgentOAuthFlows = new Map<string, PendingPaseoAgentOAuthFlow>();
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
@@ -641,6 +787,7 @@ export class Session {
|
||||
daemonVersion,
|
||||
daemonRuntimeConfig,
|
||||
getWebSocketRuntimeMetrics,
|
||||
paseoAgentOAuthLogin,
|
||||
} = options;
|
||||
this.clientId = clientId;
|
||||
this.appVersion = appVersion ?? null;
|
||||
@@ -847,6 +994,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
this.paseoAgentOAuthLogin = paseoAgentOAuthLogin;
|
||||
this.serviceProxy = serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
||||
@@ -1652,6 +1800,22 @@ export class Session {
|
||||
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
||||
case "list_provider_features_request":
|
||||
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
||||
case "config.paseo_agent.get_providers.request":
|
||||
return this.handlePaseoAgentGetProvidersRequest(msg);
|
||||
case "config.paseo_agent.get_catalog.request":
|
||||
return this.handlePaseoAgentGetCatalogRequest(msg);
|
||||
case "config.paseo_agent.set_provider.request":
|
||||
return this.handlePaseoAgentSetProviderRequest(msg);
|
||||
case "config.paseo_agent.remove_provider.request":
|
||||
return this.handlePaseoAgentRemoveProviderRequest(msg);
|
||||
case "config.paseo_agent.rename_provider.request":
|
||||
return this.handlePaseoAgentRenameProviderRequest(msg);
|
||||
case "config.paseo_agent.oauth.start.request":
|
||||
return this.handlePaseoAgentOAuthStartRequest(msg);
|
||||
case "config.paseo_agent.oauth.complete.request":
|
||||
return this.handlePaseoAgentOAuthCompleteRequest(msg);
|
||||
case "config.paseo_agent.oauth.store_credential.request":
|
||||
return this.handlePaseoAgentOAuthStoreCredentialRequest(msg);
|
||||
case "list_available_providers_request":
|
||||
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
||||
case "get_providers_snapshot_request":
|
||||
@@ -1667,6 +1831,329 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private createPaseoAgentConfigService(): PaseoAgentConfigService {
|
||||
this.paseoAgentConfigService ??= new PaseoAgentConfigService({
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
onConfigChanged: (config) => {
|
||||
const state = this.providerSnapshotManager.applyPaseoAgentConfig(config);
|
||||
this.agentManager.updateProviderRegistry(state);
|
||||
},
|
||||
});
|
||||
return this.paseoAgentConfigService;
|
||||
}
|
||||
|
||||
private async refreshPaseoAgentRuntimeSnapshot(): Promise<void> {
|
||||
await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: ["paseo"] });
|
||||
}
|
||||
|
||||
private deleteExpiredPaseoAgentOAuthFlows(now = Date.now()): void {
|
||||
for (const [name, pending] of this.pendingPaseoAgentOAuthFlows) {
|
||||
if (pending.expiresAt > now) {
|
||||
continue;
|
||||
}
|
||||
pending.abortController.abort();
|
||||
this.pendingPaseoAgentOAuthFlows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private replacePendingPaseoAgentOAuthFlow(pending: PendingPaseoAgentOAuthFlow): void {
|
||||
const previous = this.pendingPaseoAgentOAuthFlows.get(pending.name);
|
||||
previous?.abortController.abort();
|
||||
this.pendingPaseoAgentOAuthFlows.set(pending.name, pending);
|
||||
}
|
||||
|
||||
private takePendingPaseoAgentOAuthFlow(name: string): PendingPaseoAgentOAuthFlow {
|
||||
this.deleteExpiredPaseoAgentOAuthFlows();
|
||||
const pending = this.pendingPaseoAgentOAuthFlows.get(name);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending OAuth flow for Paseo Agent provider '${name}'.`);
|
||||
}
|
||||
this.pendingPaseoAgentOAuthFlows.delete(name);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async handlePaseoAgentGetCatalogRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.get_catalog.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const catalog = this.createPaseoAgentConfigService().getCatalog();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
catalog,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent catalog");
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
catalog: [],
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentGetProvidersRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.get_providers.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = this.createPaseoAgentConfigService().getProviders();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
defaultModel: result.defaultModel,
|
||||
providers: result.providers,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent providers");
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
defaultModel: null,
|
||||
providers: [],
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentSetProviderRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.set_provider.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const provider = this.createPaseoAgentConfigService().setProvider({
|
||||
name: msg.name,
|
||||
displayName: msg.displayName,
|
||||
providerType: msg.providerType,
|
||||
options: msg.options,
|
||||
});
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.set_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
provider,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name, providerType: msg.providerType },
|
||||
"Failed to set Paseo Agent provider",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.set_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
provider: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentRemoveProviderRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.remove_provider.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const removed = this.createPaseoAgentConfigService().removeProvider(msg.name);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.remove_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
removed,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to remove Paseo Agent provider",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.remove_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
removed: false,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentRenameProviderRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.rename_provider.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const provider = this.createPaseoAgentConfigService().renameProvider(
|
||||
msg.name,
|
||||
msg.displayName,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
provider,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to rename Paseo Agent provider",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
provider: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthStartRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.oauth.start.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.deleteExpiredPaseoAgentOAuthFlows();
|
||||
const binding = this.createPaseoAgentConfigService().getOAuthCredentialBinding(msg.name);
|
||||
const pending = createPendingPaseoAgentOAuthFlow({
|
||||
name: msg.name,
|
||||
binding,
|
||||
mode: resolvePaseoAgentOAuthMode(msg.mode),
|
||||
login: this.paseoAgentOAuthLogin,
|
||||
});
|
||||
this.replacePendingPaseoAgentOAuthFlow(pending);
|
||||
const authorization = await pending.authorizationPromise;
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
name: msg.name,
|
||||
authorization,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.pendingPaseoAgentOAuthFlows.delete(msg.name);
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to start Paseo Agent OAuth flow",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
name: msg.name,
|
||||
authorization: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthCompleteRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.oauth.complete.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const pending = this.takePendingPaseoAgentOAuthFlow(msg.name);
|
||||
const credential = await pending.credentialPromise;
|
||||
const auth = this.createPaseoAgentConfigService().storeOAuthCredential(
|
||||
msg.name,
|
||||
credential,
|
||||
pending.binding,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
name: msg.name,
|
||||
auth,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to complete Paseo Agent OAuth flow",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
name: msg.name,
|
||||
auth: { kind: "oauth", configured: false },
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthStoreCredentialRequest(
|
||||
msg: Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.store_credential.request" }
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const auth = this.createPaseoAgentConfigService().storeOAuthCredential(
|
||||
msg.name,
|
||||
msg.credential,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
name: msg.name,
|
||||
auth,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to store Paseo Agent OAuth credential",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
name: msg.name,
|
||||
auth: { kind: "oauth", configured: false },
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (msg.type === "start_workspace_script_request") {
|
||||
return this.handleStartWorkspaceScriptRequest(msg);
|
||||
@@ -5668,6 +6155,11 @@ export class Session {
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
|
||||
|
||||
for (const pending of this.pendingPaseoAgentOAuthFlows.values()) {
|
||||
pending.abortController.abort();
|
||||
}
|
||||
this.pendingPaseoAgentOAuthFlows.clear();
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
this.unsubscribeAgentEvents = null;
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TestPaseoDaemonOptions {
|
||||
listen?: string;
|
||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
mcpDebug?: boolean;
|
||||
isDev?: boolean;
|
||||
relayEnabled?: boolean;
|
||||
@@ -158,6 +159,7 @@ async function prepareTestDaemonConfig(
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
hostnames: true,
|
||||
mcpEnabled: options.mcpEnabled ?? true,
|
||||
mcpInjectIntoAgents: options.mcpInjectIntoAgents,
|
||||
staticDir,
|
||||
mcpDebug: options.mcpDebug ?? false,
|
||||
isDev: options.isDev,
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface ProviderSnapshotManagerSpies {
|
||||
typeof vi.fn<[AgentProvider], Promise<ProviderDiagnosticResult>>
|
||||
>;
|
||||
applyMutableProviderConfig: ReturnType<typeof vi.fn<[unknown], AgentManagerProviderState>>;
|
||||
applyPaseoAgentConfig: ReturnType<typeof vi.fn<[unknown], AgentManagerProviderState>>;
|
||||
destroy: ReturnType<typeof vi.fn<[], void>>;
|
||||
}
|
||||
|
||||
@@ -204,6 +205,10 @@ export function createProviderSnapshotManagerStub(): {
|
||||
providerDefinitions: {},
|
||||
clients: {},
|
||||
}));
|
||||
const applyPaseoAgentConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({
|
||||
providerDefinitions: {},
|
||||
clients: {},
|
||||
}));
|
||||
const on = vi.fn();
|
||||
const off = vi.fn();
|
||||
const destroy = vi.fn<[], void>();
|
||||
@@ -224,6 +229,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
resolveDefaultModel,
|
||||
getProviderDiagnostic,
|
||||
applyMutableProviderConfig,
|
||||
applyPaseoAgentConfig,
|
||||
on,
|
||||
off,
|
||||
destroy,
|
||||
@@ -249,6 +255,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
resolveDefaultModel,
|
||||
getProviderDiagnostic,
|
||||
applyMutableProviderConfig,
|
||||
applyPaseoAgentConfig,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1225,6 +1225,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
daemonSelfUpdate: true,
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: true,
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
|
||||
paseoAgentConfig: true,
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104
|
||||
paseoAgentCatalog: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1714,17 +1718,20 @@ export class VoiceAssistantWebSocketServer {
|
||||
const { ws, data, error, log } = params;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data);
|
||||
const redactedParsedPayload = redactPaseoAgentConfigSecrets(parsedPayload);
|
||||
const redactedRawPayload =
|
||||
redactedParsedPayload === parsedPayload ? rawPayload : JSON.stringify(redactedParsedPayload);
|
||||
|
||||
const trimmedRawPayload =
|
||||
typeof rawPayload === "string" && rawPayload.length > 2000
|
||||
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
||||
: rawPayload;
|
||||
typeof redactedRawPayload === "string" && redactedRawPayload.length > 2000
|
||||
? `${redactedRawPayload.slice(0, 2000)}... (truncated)`
|
||||
: redactedRawPayload;
|
||||
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
rawPayload: trimmedRawPayload,
|
||||
parsedPayload,
|
||||
parsedPayload: redactedParsedPayload,
|
||||
},
|
||||
"Failed to parse/handle message",
|
||||
);
|
||||
@@ -2297,3 +2304,48 @@ function extractRequestInfoFromUnknownWsInbound(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function redactPaseoAgentConfigSecrets(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.type !== "session" || !record.message || typeof record.message !== "object") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const message = record.message as Record<string, unknown>;
|
||||
if (message.type === "config.paseo_agent.set_provider.request") {
|
||||
const options = message.options;
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
message: {
|
||||
...message,
|
||||
options: {
|
||||
...(options as Record<string, unknown>),
|
||||
...(Object.prototype.hasOwnProperty.call(options, "apiKey")
|
||||
? { apiKey: "<redacted>" }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(options, "headers")
|
||||
? { headers: "<redacted>" }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "config.paseo_agent.oauth.store_credential.request") {
|
||||
return {
|
||||
...record,
|
||||
message: {
|
||||
...message,
|
||||
credential: "<redacted>",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+17
-9
@@ -25,18 +25,26 @@ node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE"
|
||||
# 2. Prefetch deps and compute hash
|
||||
echo "Prefetching npm dependencies..."
|
||||
|
||||
# Resolve prefetch-npm-deps from the same nixpkgs pinned in flake.lock
|
||||
NIXPKGS_URL="$(node -p "
|
||||
const l = JSON.parse(require('fs').readFileSync('$ROOT_DIR/flake.lock', 'utf8'));
|
||||
const n = l.nodes.nixpkgs.locked;
|
||||
'github:' + n.owner + '/' + n.repo + '/' + n.rev;
|
||||
")"
|
||||
|
||||
STDERR_LOG="$(mktemp)"
|
||||
trap "rm -f '$STDERR_LOG'" EXIT
|
||||
|
||||
if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then
|
||||
echo "ERROR: prefetch-npm-deps failed:" >&2
|
||||
HASH_EXPR="
|
||||
let
|
||||
flake = builtins.getFlake \"path:$ROOT_DIR\";
|
||||
system = builtins.currentSystem;
|
||||
fakeHash = \"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\";
|
||||
in
|
||||
(flake.packages.\${system}.default.override { npmDepsHash = fakeHash; }).npmDeps
|
||||
"
|
||||
|
||||
if nix build --no-link --impure --expr "$HASH_EXPR" >/dev/null 2>"$STDERR_LOG"; then
|
||||
echo "ERROR: fake npmDepsHash unexpectedly succeeded." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_HASH="$(sed -n 's/.*got:[[:space:]]*\(sha256-[^[:space:]]*\).*/\1/p' "$STDERR_LOG" | tail -1)"
|
||||
if [[ -z "$NEW_HASH" ]]; then
|
||||
echo "ERROR: failed to compute npmDepsHash:" >&2
|
||||
tail -20 "$STDERR_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user