Compare commits
11 Commits
v2.5.11-cl
...
v2.5.15-ac
| Author | SHA1 | Date | |
|---|---|---|---|
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 | |||
| 024ffc0b92 | |||
| 691eef1b30 | |||
| e92c51578d | |||
| 6d03690a65 | |||
| 21384cce5b | |||
| 920f8b75a6 | |||
| e83d9b7d5b | |||
| f302969c71 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,5 +16,5 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
!data/coder-providers.json
|
!data/coder-providers.example.json
|
||||||
codecontext/fork.tar.gz
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
78
BOOCODER.md
78
BOOCODER.md
@@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us
|
|||||||
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||||
|
|
||||||
|
## Provider lifecycle (v2.3)
|
||||||
|
|
||||||
|
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
|
||||||
|
|
||||||
|
### Config file: `data/coder-providers.json`
|
||||||
|
|
||||||
|
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"goose": { "enabled": false },
|
||||||
|
"amp-acp": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "Amp",
|
||||||
|
"description": "ACP wrapper for Amp",
|
||||||
|
"command": ["amp-acp"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-provider override fields (all optional):
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
|
||||||
|
| `label` | Display name (required for custom) |
|
||||||
|
| `description` | Sub-label shown in the picker / settings |
|
||||||
|
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
|
||||||
|
| `env` | Extra env vars merged into the spawn |
|
||||||
|
| `enabled` | Default `true`; `false` hides it from the composer |
|
||||||
|
| `order` | UI sort key |
|
||||||
|
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
|
||||||
|
|
||||||
|
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
|
||||||
|
|
||||||
|
### Refresh contract
|
||||||
|
|
||||||
|
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
|
||||||
|
|
||||||
|
### Enable / disable
|
||||||
|
|
||||||
|
Two ways:
|
||||||
|
- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
|
||||||
|
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
|
||||||
|
|
||||||
|
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
|
||||||
|
|
||||||
|
### Adding a custom ACP provider
|
||||||
|
|
||||||
|
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
|
||||||
|
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
|
||||||
|
|
||||||
|
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
|
||||||
|
|
||||||
|
### Known limitation — subset refresh
|
||||||
|
|
||||||
|
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
|
||||||
|
|
||||||
|
### Deploy + smoke
|
||||||
|
|
||||||
|
Two deploy targets:
|
||||||
|
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||||
|
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||||
|
|
||||||
|
Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
|
||||||
|
|
||||||
|
Smoke (via Tailscale):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
|
||||||
|
curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
|
||||||
|
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
|
||||||
|
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
|
||||||
|
```
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.5.15-acp-path-guard — 2026-05-29
|
||||||
|
|
||||||
|
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
|
||||||
|
|
||||||
|
## v2.5.14-claude-md — 2026-05-29
|
||||||
|
|
||||||
|
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7`–`v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
|
||||||
|
|
||||||
|
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
|
||||||
|
|
||||||
|
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||||
|
|
||||||
|
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||||
|
|
||||||
## v2.5.11-claude-skill-discovery — 2026-05-29
|
## v2.5.11-claude-skill-discovery — 2026-05-29
|
||||||
|
|
||||||
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -68,9 +68,9 @@ Key services:
|
|||||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
- **`apps/coder/src/routes/providers.ts`** (BooCoder) — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
|
||||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||||
|
|
||||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||||
@@ -80,12 +80,16 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
|||||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||||
|
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
|
||||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||||
|
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||||
|
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||||
|
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
|
||||||
|
|
||||||
### Frontend (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
@@ -145,6 +149,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||||
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
||||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||||
|
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
|
||||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||||
@@ -172,10 +177,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||||
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
||||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||||
|
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
|
||||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||||
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||||
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||||
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||||
|
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
|
||||||
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||||
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
||||||
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||||
@@ -191,6 +198,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||||
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||||
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||||
|
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
|
||||||
|
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
|
||||||
|
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
|
||||||
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||||
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||||
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||||
|
|||||||
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { registerProviderRoutes } from '../providers.js';
|
||||||
|
import { load } from '../../services/provider-config.js';
|
||||||
|
import { loadProviderConfig } from '../../services/provider-config-registry.js';
|
||||||
|
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
|
||||||
|
import type { Config } from '../../config.js';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
/** Minimal sql stub: available_agents reads return []. */
|
||||||
|
function mockSql(): Sql {
|
||||||
|
return vi.fn((strings: TemplateStringsArray) => {
|
||||||
|
const q = strings.join('');
|
||||||
|
if (q.includes('available_agents')) return Promise.resolve([]);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as unknown as Sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpCounter = 0;
|
||||||
|
function freshPath(): string {
|
||||||
|
tmpCounter += 1;
|
||||||
|
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(providersPath: string): FastifyInstance {
|
||||||
|
const app = Fastify();
|
||||||
|
// Mirror index.ts: tolerate empty JSON bodies.
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) return done(null, {});
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const config = {
|
||||||
|
CODER_PROVIDERS_PATH: providersPath,
|
||||||
|
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||||
|
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||||
|
} as unknown as Config;
|
||||||
|
registerProviderRoutes(app, mockSql(), config);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_HEADERS = { 'content-type': 'application/json' };
|
||||||
|
const createdPaths: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearProviderSnapshotCache();
|
||||||
|
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const p of createdPaths.splice(0)) {
|
||||||
|
try {
|
||||||
|
rmSync(p, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/providers/config', () => {
|
||||||
|
it('returns the current config file (built-ins-only when missing)', async () => {
|
||||||
|
const path = freshPath();
|
||||||
|
createdPaths.push(path);
|
||||||
|
const app = buildApp(path);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ providers: {} });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects an existing file', async () => {
|
||||||
|
const path = freshPath();
|
||||||
|
createdPaths.push(path);
|
||||||
|
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
|
||||||
|
const app = buildApp(path);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||||
|
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/providers/config', () => {
|
||||||
|
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
|
||||||
|
const path = freshPath();
|
||||||
|
createdPaths.push(path);
|
||||||
|
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
|
||||||
|
const app = buildApp(path);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/providers/config',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ ok: true });
|
||||||
|
// File written + merged (goose untouched, opencode added).
|
||||||
|
const onDisk = load(path);
|
||||||
|
expect(onDisk.providers).toEqual({
|
||||||
|
goose: { label: 'Goose' },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null value deletes the override', async () => {
|
||||||
|
const path = freshPath();
|
||||||
|
createdPaths.push(path);
|
||||||
|
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
|
||||||
|
const app = buildApp(path);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/providers/config',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
payload: JSON.stringify({ providers: { goose: null } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
|
||||||
|
const path = freshPath();
|
||||||
|
createdPaths.push(path);
|
||||||
|
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
|
||||||
|
writeFileSync(path, before);
|
||||||
|
const app = buildApp(path);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/providers/config',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
// File must be byte-for-byte unchanged — nothing written on a 422.
|
||||||
|
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
|
||||||
|
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
|
||||||
|
const app = buildApp(path);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/providers/config',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/providers/refresh', () => {
|
||||||
|
it('no body → refreshes all registered providers', async () => {
|
||||||
|
const app = buildApp(freshPath());
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().refreshed).toBeGreaterThan(0);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subset body → refreshed count reflects only the requested providers', async () => {
|
||||||
|
const app = buildApp(freshPath());
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/providers/refresh',
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
payload: JSON.stringify({ providers: ['boocode'] }),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ refreshed: 1 });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/providers/:id/diagnostic', () => {
|
||||||
|
it('known provider → 200 JSON { diagnostic }', async () => {
|
||||||
|
const app = buildApp(freshPath());
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/json');
|
||||||
|
expect(res.json().diagnostic).toContain('provider: boocode');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown provider → 404', async () => {
|
||||||
|
const app = buildApp(freshPath());
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,29 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
import {
|
||||||
|
getProviderSnapshot,
|
||||||
|
clearProviderSnapshotCache,
|
||||||
|
peekSnapshotEntry,
|
||||||
|
} from '../services/provider-snapshot.js';
|
||||||
|
import {
|
||||||
|
load,
|
||||||
|
save,
|
||||||
|
CoderProvidersFileSchema,
|
||||||
|
ProviderConfigPatchSchema,
|
||||||
|
mergeProviderConfigPatch,
|
||||||
|
} from '../services/provider-config.js';
|
||||||
|
import {
|
||||||
|
reloadProviderConfig,
|
||||||
|
getResolvedRegistry,
|
||||||
|
} from '../services/provider-config-registry.js';
|
||||||
|
import {
|
||||||
|
getProviderDiagnostic,
|
||||||
|
type DiagnosticAgentRow,
|
||||||
|
} from '../services/provider-diagnostic.js';
|
||||||
|
|
||||||
|
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
|
||||||
|
|
||||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||||
@@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C
|
|||||||
return getProviderSnapshot(sql, config, cwd);
|
return getProviderSnapshot(sql, config, cwd);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
|
||||||
|
app.get('/api/providers/config', async (_req, _reply) => {
|
||||||
|
return load(config.CODER_PROVIDERS_PATH);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
|
||||||
|
// correctness story: validate → save → reload → clear. A malformed body or an
|
||||||
|
// invalid merged result returns 422 and NEVER writes; a save failure returns
|
||||||
|
// 500 and leaves in-memory state untouched (no file/registry divergence).
|
||||||
|
app.patch('/api/providers/config', async (req, reply) => {
|
||||||
|
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(422).send({
|
||||||
|
error: 'invalid provider config patch',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
|
||||||
|
const current = load(config.CODER_PROVIDERS_PATH);
|
||||||
|
const merged = mergeProviderConfigPatch(current, parsed.data);
|
||||||
|
|
||||||
|
// 3. Validate the merged result — refuse to write a config that won't load.
|
||||||
|
const validated = CoderProvidersFileSchema.safeParse(merged);
|
||||||
|
if (!validated.success) {
|
||||||
|
return reply.code(422).send({
|
||||||
|
error: 'merged provider config is invalid',
|
||||||
|
issues: validated.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
|
||||||
|
// disk and the in-memory resolved registry can never diverge.
|
||||||
|
try {
|
||||||
|
save(config.CODER_PROVIDERS_PATH, validated.data);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
|
||||||
|
'provider-config: save failed — in-memory state untouched',
|
||||||
|
);
|
||||||
|
return reply.code(500).send({ error: 'failed to write provider config' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
|
||||||
|
// the snapshot cache so the next /snapshot reflects the change.
|
||||||
|
reloadProviderConfig();
|
||||||
|
clearProviderSnapshotCache();
|
||||||
|
|
||||||
|
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
|
||||||
|
// so the client can update without a follow-up GET).
|
||||||
|
return { ok: true, providers: validated.data.providers };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
|
||||||
|
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
|
||||||
|
// the only existing re-probe primitive (per-provider force would be a
|
||||||
|
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
|
||||||
|
// installed providers; the `refreshed` count reflects the requested subset.
|
||||||
|
app.post('/api/providers/refresh', async (req, reply) => {
|
||||||
|
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
const subset = parsed.data.providers;
|
||||||
clearProviderSnapshotCache();
|
clearProviderSnapshotCache();
|
||||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||||
return { refreshed: entries.length };
|
const refreshed =
|
||||||
|
subset && subset.length > 0
|
||||||
|
? entries.filter((e) => subset.includes(e.name)).length
|
||||||
|
: entries.length;
|
||||||
|
return { refreshed };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
|
||||||
|
// Read-only: reports cached state (resolved def + available_agents row + warm
|
||||||
|
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
|
||||||
|
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
|
||||||
|
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const resolved = getResolvedRegistry().get(id);
|
||||||
|
if (!resolved) {
|
||||||
|
return reply.code(404).send({ error: `unknown provider '${id}'` });
|
||||||
|
}
|
||||||
|
const rows = await sql<DiagnosticAgentRow[]>`
|
||||||
|
SELECT name, install_path, supports_acp, models, last_probed_at
|
||||||
|
FROM available_agents WHERE name = ${id}
|
||||||
|
`;
|
||||||
|
const report = await getProviderDiagnostic(resolved, rows[0], {
|
||||||
|
cachedEntry: peekSnapshotEntry(id),
|
||||||
|
});
|
||||||
|
return { diagnostic: report };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
function freshWorktree(): string {
|
||||||
|
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
|
||||||
|
created.push(wt);
|
||||||
|
return wt;
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of created.splice(0)) {
|
||||||
|
try {
|
||||||
|
rmSync(d, { recursive: true, force: true });
|
||||||
|
rmSync(`${d}-evil`, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acp-client-fs worktree scoping', () => {
|
||||||
|
it('writes then reads a file inside the worktree', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
|
||||||
|
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ../ traversal on read', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ../ traversal on write', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
// Absolute path that shares the worktree as a STRING prefix but is a sibling
|
||||||
|
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
|
||||||
|
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
|
||||||
|
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
|
||||||
|
/escapes worktree/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
mergeProviderConfigPatch,
|
||||||
|
ProviderConfigPatchSchema,
|
||||||
|
CoderProvidersFileSchema,
|
||||||
|
type CoderProvidersFile,
|
||||||
|
} from '../provider-config.js';
|
||||||
|
|
||||||
|
describe('ProviderConfigPatchSchema', () => {
|
||||||
|
it('accepts a per-provider override patch', () => {
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a null value (delete-the-override sentinel)', () => {
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults providers to {} on an empty body', () => {
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse({});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
if (parsed.success) expect(parsed.data.providers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed override (wrong field type)', () => {
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-object providers map', () => {
|
||||||
|
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeProviderConfigPatch', () => {
|
||||||
|
const current: CoderProvidersFile = {
|
||||||
|
providers: {
|
||||||
|
goose: { enabled: true, label: 'Goose' },
|
||||||
|
opencode: { enabled: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('replaces an existing override object wholesale (not deep-merge)', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||||
|
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
|
||||||
|
expect(merged.providers.goose).toEqual({ enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a brand-new override id', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(current, {
|
||||||
|
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
|
||||||
|
});
|
||||||
|
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an override when the value is null', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
|
||||||
|
expect(merged.providers.goose).toBeUndefined();
|
||||||
|
expect(Object.keys(merged.providers)).toEqual(['opencode']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ids absent from the patch untouched', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||||
|
expect(merged.providers.opencode).toEqual({ enabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the input config', () => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(current));
|
||||||
|
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
|
||||||
|
expect(current).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty patch returns an equivalent config', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(current, { providers: {} });
|
||||||
|
expect(merged).toEqual(current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
|
||||||
|
it('accepts a clean merged config', () => {
|
||||||
|
const merged = mergeProviderConfigPatch(
|
||||||
|
{ providers: {} },
|
||||||
|
{ providers: { goose: { enabled: false } } },
|
||||||
|
);
|
||||||
|
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a config carrying an invalid override (never written)', () => {
|
||||||
|
// A merged object that somehow holds a bad override must fail validation
|
||||||
|
// so the PATCH route returns 422 and never calls save().
|
||||||
|
const invalid = { providers: { goose: { enabled: 'nope' } } };
|
||||||
|
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
|
||||||
|
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||||
|
import { PROVIDERS } from '../provider-registry.js';
|
||||||
|
import type { ProviderSnapshotEntry } from '../provider-types.js';
|
||||||
|
|
||||||
|
const registry = buildResolvedRegistry(PROVIDERS, {
|
||||||
|
providers: {
|
||||||
|
goose: { enabled: false },
|
||||||
|
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alwaysAvailable = () => Promise.resolve(true);
|
||||||
|
const neverAvailable = () => Promise.resolve(false);
|
||||||
|
|
||||||
|
describe('getProviderDiagnostic', () => {
|
||||||
|
it('reports a disabled built-in (enabled:false, no install)', async () => {
|
||||||
|
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
|
||||||
|
checkAvailable: neverAvailable,
|
||||||
|
});
|
||||||
|
expect(report).toContain('provider: goose');
|
||||||
|
expect(report).toContain('enabled: false');
|
||||||
|
expect(report).toContain('installed: false');
|
||||||
|
expect(report).toMatch(/command_available:\s*false/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
|
||||||
|
const agentRow: DiagnosticAgentRow = {
|
||||||
|
name: 'opencode',
|
||||||
|
install_path: '/usr/bin/opencode',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [
|
||||||
|
{ id: 'm1', label: 'M1' },
|
||||||
|
{ id: 'm2', label: 'M2' },
|
||||||
|
],
|
||||||
|
last_probed_at: '2026-05-29T12:00:00.000Z',
|
||||||
|
};
|
||||||
|
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
|
||||||
|
checkAvailable: alwaysAvailable,
|
||||||
|
});
|
||||||
|
expect(report).toContain('install_path: /usr/bin/opencode');
|
||||||
|
expect(report).toContain('2026-05-29T12:00:00.000Z');
|
||||||
|
expect(report).toContain('installed: true');
|
||||||
|
expect(report).toMatch(/models_in_db:\s*2/);
|
||||||
|
expect(report).toMatch(/command_available:\s*true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a custom ACP launch command + its binary', async () => {
|
||||||
|
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
|
||||||
|
checkAvailable: alwaysAvailable,
|
||||||
|
});
|
||||||
|
expect(report).toContain('provider: amp-acp');
|
||||||
|
expect(report).toContain('amp-acp --acp');
|
||||||
|
expect(report).toContain('customAcp: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the last probe error from a cached snapshot entry', async () => {
|
||||||
|
const cachedEntry: ProviderSnapshotEntry = {
|
||||||
|
name: 'opencode',
|
||||||
|
label: 'OpenCode',
|
||||||
|
transport: 'acp',
|
||||||
|
status: 'error',
|
||||||
|
enabled: true,
|
||||||
|
installed: true,
|
||||||
|
models: [],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
error: 'ACP initialize timed out',
|
||||||
|
};
|
||||||
|
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||||
|
cachedEntry,
|
||||||
|
checkAvailable: alwaysAvailable,
|
||||||
|
});
|
||||||
|
expect(report).toContain('ACP initialize timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no error when none is cached', async () => {
|
||||||
|
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||||
|
checkAvailable: alwaysAvailable,
|
||||||
|
});
|
||||||
|
expect(report).toMatch(/last_probe_error:\s*\(none/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
prefixLlamaSwapModels,
|
prefixLlamaSwapModels,
|
||||||
clearProviderSnapshotCache,
|
clearProviderSnapshotCache,
|
||||||
getProviderSnapshot,
|
getProviderSnapshot,
|
||||||
|
peekSnapshotEntry,
|
||||||
} from '../provider-snapshot.js';
|
} from '../provider-snapshot.js';
|
||||||
import { loadProviderConfig } from '../provider-config-registry.js';
|
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||||
|
|
||||||
@@ -324,6 +325,18 @@ describe('getProviderSnapshot', () => {
|
|||||||
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
// Cold cache → undefined (no build triggered).
|
||||||
|
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
|
||||||
|
|
||||||
|
const sql = mockSql([]);
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/peek', true);
|
||||||
|
|
||||||
|
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
|
||||||
|
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||||
loadConfigFixture({});
|
loadConfigFixture({});
|
||||||
mockProbe.mockResolvedValue({
|
mockProbe.mockResolvedValue({
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
||||||
|
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
||||||
|
* `../` segments, then a **separator-bounded** prefix test — a bare
|
||||||
|
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
||||||
|
*
|
||||||
|
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
||||||
|
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
||||||
|
* boundary — the agent process already runs with host FS access; symlink-swap
|
||||||
|
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
||||||
|
*/
|
||||||
|
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
||||||
|
const root = resolve(worktreePath);
|
||||||
|
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||||
|
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
||||||
|
throw new Error(`path escapes worktree: ${filePath}`);
|
||||||
|
}
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||||
export async function readWorktreeTextFile(
|
export async function readWorktreeTextFile(
|
||||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
|||||||
line?: number | null,
|
line?: number | null,
|
||||||
limit?: number | null,
|
limit?: number | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
const raw = await fs.readFile(absolute, 'utf8');
|
const raw = await fs.readFile(absolute, 'utf8');
|
||||||
if (!line && !limit) return raw;
|
if (!line && !limit) return raw;
|
||||||
const lines = raw.split(/\r?\n/);
|
const lines = raw.split(/\r?\n/);
|
||||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||||
await fs.writeFile(absolute, content, 'utf8');
|
await fs.writeFile(absolute, content, 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
|
|||||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH body schema (design.md §6.2). A partial providers map where each value
|
||||||
|
* is either a full override object (REPLACES that id's override) or `null`
|
||||||
|
* (DELETES the override → revert to the built-in default). Ids absent from the
|
||||||
|
* patch are left untouched. The route validates the body against this first
|
||||||
|
* (malformed → 422) so a bad shape can never reach the merge/save step.
|
||||||
|
*/
|
||||||
|
export const ProviderConfigPatchSchema = z.object({
|
||||||
|
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
|
||||||
|
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
|
||||||
|
* field merge); a `null` value DELETES the override. Returns a new object —
|
||||||
|
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
|
||||||
|
* which the route re-validates against CoderProvidersFileSchema before save.
|
||||||
|
*/
|
||||||
|
export function mergeProviderConfigPatch(
|
||||||
|
current: CoderProvidersFile,
|
||||||
|
patch: ProviderConfigPatch,
|
||||||
|
): CoderProvidersFile {
|
||||||
|
const providers: Record<string, ProviderOverride> = { ...current.providers };
|
||||||
|
for (const [id, override] of Object.entries(patch.providers)) {
|
||||||
|
if (override === null) {
|
||||||
|
delete providers[id];
|
||||||
|
} else {
|
||||||
|
providers[id] = override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { providers };
|
||||||
|
}
|
||||||
|
|
||||||
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||||
export function load(path: string): CoderProvidersFile {
|
export function load(path: string): CoderProvidersFile {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
|
|||||||
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
|
||||||
|
*
|
||||||
|
* Read-only by default: reports CACHED state (resolved registry def + the
|
||||||
|
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
|
||||||
|
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
|
||||||
|
* the live initialize probe as optional, and the route defaults to cached state.
|
||||||
|
*
|
||||||
|
* A template string is the whole formatter (no Paseo diagnostic-utils port).
|
||||||
|
*/
|
||||||
|
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
|
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
|
||||||
|
import { isCommandAvailable } from './command-availability.js';
|
||||||
|
|
||||||
|
/** The subset of an `available_agents` row the diagnostic reads. */
|
||||||
|
export interface DiagnosticAgentRow {
|
||||||
|
name: string;
|
||||||
|
install_path: string | null;
|
||||||
|
supports_acp?: boolean;
|
||||||
|
models?: ProviderModel[] | null;
|
||||||
|
last_probed_at?: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticOpts {
|
||||||
|
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
|
||||||
|
cachedEntry?: ProviderSnapshotEntry;
|
||||||
|
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
|
||||||
|
checkAvailable?: (binary: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
|
||||||
|
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
|
||||||
|
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderDiagnostic(
|
||||||
|
resolved: ResolvedProviderDef,
|
||||||
|
agentRow: DiagnosticAgentRow | undefined,
|
||||||
|
opts: DiagnosticOpts = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
|
||||||
|
const installed = agentRow?.install_path != null;
|
||||||
|
const binary = resolveBinary(resolved, agentRow);
|
||||||
|
// boocode is native (no binary to launch) — short-circuit the PATH check.
|
||||||
|
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
|
||||||
|
const lastProbedAt =
|
||||||
|
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
|
||||||
|
const modelCount = agentRow?.models?.length ?? 0;
|
||||||
|
const launchCommand = resolved.launchCommand
|
||||||
|
? resolved.launchCommand.join(' ')
|
||||||
|
: '(built-in default, resolved at dispatch)';
|
||||||
|
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`provider: ${resolved.id}`,
|
||||||
|
`label: ${resolved.configLabel ?? resolved.label}`,
|
||||||
|
`transport: ${resolved.transport}`,
|
||||||
|
`enabled: ${resolved.enabled}`,
|
||||||
|
`builtin: ${resolved.isBuiltin}`,
|
||||||
|
`customAcp: ${resolved.isCustomAcp}`,
|
||||||
|
`installed: ${installed}`,
|
||||||
|
`install_path: ${agentRow?.install_path ?? '(none)'}`,
|
||||||
|
`binary: ${binary}`,
|
||||||
|
`command_available: ${commandAvailable}`,
|
||||||
|
`launch_command: ${launchCommand}`,
|
||||||
|
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
|
||||||
|
`last_probed_at: ${lastProbedAt}`,
|
||||||
|
`models_in_db: ${modelCount}`,
|
||||||
|
`last_probe_error: ${lastError}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -283,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
|
|||||||
snapshotInflight.clear();
|
snapshotInflight.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only peek into the warm snapshot cache for one provider (no build, no
|
||||||
|
* probe). Used by the diagnostic route to report the last computed probe error
|
||||||
|
* without spawning anything. Returns undefined on a cold cache / unknown name.
|
||||||
|
*/
|
||||||
|
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
|
||||||
|
const resolvedCwd = cwd?.trim() || homedir();
|
||||||
|
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||||
export async function persistProbedModels(
|
export async function persistProbedModels(
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
AskUserAnswer,
|
AskUserAnswer,
|
||||||
ToolCostStat,
|
ToolCostStat,
|
||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
|
CoderProvidersFile,
|
||||||
|
ProviderConfigPatch,
|
||||||
CoderSendMessageBody,
|
CoderSendMessageBody,
|
||||||
CoderSendMessageResponse,
|
CoderSendMessageResponse,
|
||||||
CoderMessageWire,
|
CoderMessageWire,
|
||||||
@@ -310,8 +312,23 @@ export const api = {
|
|||||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||||
},
|
},
|
||||||
refreshProviders: () =>
|
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
|
||||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
refreshProviders: (providers?: string[]) =>
|
||||||
|
request<{ refreshed: number }>('/api/coder/providers/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}),
|
||||||
|
}),
|
||||||
|
// v2.3 Phase 4: read/patch the provider config file. PATCH returns the new
|
||||||
|
// config; a `null` value in the patch deletes that id's override.
|
||||||
|
getProvidersConfig: () => request<CoderProvidersFile>('/api/coder/providers/config'),
|
||||||
|
patchProvidersConfig: (patch: ProviderConfigPatch) =>
|
||||||
|
request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}),
|
||||||
|
// v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4).
|
||||||
|
getProviderDiagnostic: (id: string) =>
|
||||||
|
request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`),
|
||||||
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry {
|
|||||||
fetchedAt?: string;
|
fetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
|
||||||
|
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
|
||||||
|
// (web can't cross-import the coder package — TS6307 on the composite project).
|
||||||
|
export interface ProviderOverride {
|
||||||
|
extends?: 'acp';
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
command?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
order?: number;
|
||||||
|
models?: Array<{ id: string; label: string }>;
|
||||||
|
additionalModels?: Array<{ id: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoderProvidersFile {
|
||||||
|
providers: Record<string, ProviderOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH body: a partial providers map. A `null` value deletes that id's
|
||||||
|
// override (revert to built-in default); an object replaces it wholesale.
|
||||||
|
export interface ProviderConfigPatch {
|
||||||
|
providers: Record<string, ProviderOverride | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
@@ -176,8 +176,12 @@ interface Props {
|
|||||||
|
|
||||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||||
const allEntries = useProviderSnapshot(projectPath);
|
const allEntries = useProviderSnapshot(projectPath);
|
||||||
|
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||||
|
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||||
|
// hidden here and managed in Settings → Providers. Native boocode is always
|
||||||
|
// enabled+ready, so it always appears.
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
|
||||||
[allEntries],
|
[allEntries],
|
||||||
);
|
);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
onChange(resolveConfig(entry, prefs));
|
onChange(resolveConfig(entry, prefs));
|
||||||
}, [entries, onChange, value.provider]);
|
}, [entries, onChange, value.provider]);
|
||||||
|
|
||||||
|
// If the active provider is disabled in the settings drawer it drops out of
|
||||||
|
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
|
||||||
|
// strands on an unselectable provider with empty model/mode pickers.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entries?.length) return;
|
||||||
|
if (entries.some((e) => e.name === value.provider)) return;
|
||||||
|
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
|
||||||
|
if (!fallback) return;
|
||||||
|
onChange(resolveConfig(fallback, loadPrefs()));
|
||||||
|
}, [entries, value.provider, onChange]);
|
||||||
|
|
||||||
|
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
|
||||||
|
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
|
||||||
|
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
|
||||||
|
const pollsRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||||
|
if (!anyLoading) {
|
||||||
|
pollsRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pollsRef.current >= 10) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
pollsRef.current += 1;
|
||||||
|
void refreshProviderSnapshot(projectPath);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [allEntries, projectPath]);
|
||||||
|
|
||||||
const currentEntry = useMemo(
|
const currentEntry = useMemo(
|
||||||
() => entries?.find((e) => e.name === value.provider),
|
() => entries?.find((e) => e.name === value.provider),
|
||||||
[entries, value.provider],
|
[entries, value.provider],
|
||||||
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
value={value.provider}
|
value={value.provider}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
onPick={pickProvider}
|
onPick={pickProvider}
|
||||||
icon={providerIcon(value.provider)}
|
icon={
|
||||||
|
currentEntry?.status === 'loading'
|
||||||
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||||
|
: providerIcon(value.provider)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Mode"
|
||||||
|
|||||||
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ExternalLink, Search } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
ACP_PROVIDER_CATALOG,
|
||||||
|
buildAcpProviderConfigPatch,
|
||||||
|
type AcpCatalogEntry,
|
||||||
|
} from '@/data/acp-provider-catalog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Fired after a successful add so the parent can refetch the snapshot. */
|
||||||
|
onAdded: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a
|
||||||
|
* provider: PATCH /api/providers/config with its custom-ACP override, then
|
||||||
|
* refresh that one provider. Adding only edits config — it does NOT install the
|
||||||
|
* binary, so the provider shows "Not installed" until the CLI is on PATH.
|
||||||
|
*/
|
||||||
|
export function AddProviderModal({ open, onOpenChange, onAdded }: Props) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return ACP_PROVIDER_CATALOG;
|
||||||
|
return ACP_PROVIDER_CATALOG.filter(
|
||||||
|
(e) =>
|
||||||
|
e.id.toLowerCase().includes(q) ||
|
||||||
|
e.label.toLowerCase().includes(q) ||
|
||||||
|
e.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
async function add(entry: AcpCatalogEntry): Promise<void> {
|
||||||
|
setBusyId(entry.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry));
|
||||||
|
await api.coder.refreshProviders([entry.id]);
|
||||||
|
onAdded(entry.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
// 422 from PATCH (invalid override) surfaces here as ApiError.message.
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to add provider');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add ACP provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Registers the provider in your coder config. It is not installed — install the CLI
|
||||||
|
yourself; until it's on PATH it shows as “Not installed”.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col min-h-0 gap-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search providers…"
|
||||||
|
className="pl-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 rounded-md border overflow-y-auto overscroll-contain divide-y">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">No matching providers.</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<div key={e.id} className="px-3 py-2.5 space-y-1.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{e.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{e.description}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() => void add(e)}
|
||||||
|
>
|
||||||
|
{busyId === e.id ? 'Adding…' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[11px] text-muted-foreground truncate">
|
||||||
|
$ {e.command.join(' ')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={e.installUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Install {e.label} <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
{e.installCmd && (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||||
|
{e.installCmd}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive shrink-0">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busyId !== null}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types';
|
||||||
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AddProviderModal } from './AddProviderModal';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Map a snapshot entry to a status badge (design.md §7.1 labels). */
|
||||||
|
function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } {
|
||||||
|
if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' };
|
||||||
|
if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' };
|
||||||
|
if (e.status === 'ready')
|
||||||
|
return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' };
|
||||||
|
if (e.status === 'error')
|
||||||
|
return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
|
||||||
|
if (!e.installed)
|
||||||
|
return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' };
|
||||||
|
return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.3 — provider management as a Settings tab section (design.md §7.1). Lists
|
||||||
|
* ALL registered providers (including the disabled/unavailable ones the composer
|
||||||
|
* picker hides). Per row: label + model count, status badge, per-id refresh,
|
||||||
|
* diagnostic, and an enable/disable toggle. Native boocode is always-on.
|
||||||
|
*
|
||||||
|
* Uses the home-cwd snapshot (no project arg) — provider management is global,
|
||||||
|
* not per-project (design.md §4.5).
|
||||||
|
*/
|
||||||
|
export function ProvidersSettings() {
|
||||||
|
const allEntries = useProviderSnapshot();
|
||||||
|
const [config, setConfig] = useState<CoderProvidersFile | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [diagId, setDiagId] = useState<string | null>(null);
|
||||||
|
const [diagText, setDiagText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The raw config is needed to preserve a provider's FULL override when
|
||||||
|
// toggling: the PATCH replaces an id's override wholesale, so a bare
|
||||||
|
// { enabled } would wipe a custom ACP provider's command/label.
|
||||||
|
useEffect(() => {
|
||||||
|
api.coder
|
||||||
|
.getProvidersConfig()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch(() => setConfig({ providers: {} }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// While any entry is loading, refetch until terminal (capped, no WS frame).
|
||||||
|
const pollsRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||||
|
if (!anyLoading) {
|
||||||
|
pollsRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pollsRef.current >= 10) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
pollsRef.current += 1;
|
||||||
|
void refreshProviderSnapshot();
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [allEntries]);
|
||||||
|
|
||||||
|
async function toggle(e: ProviderSnapshotEntry): Promise<void> {
|
||||||
|
setBusyId(e.name);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const existing: ProviderOverride = config?.providers[e.name] ?? {};
|
||||||
|
const resp = await api.coder.patchProvidersConfig({
|
||||||
|
providers: { [e.name]: { ...existing, enabled: !e.enabled } },
|
||||||
|
});
|
||||||
|
setConfig({ providers: resp.providers });
|
||||||
|
await refreshProviderSnapshot();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to update provider');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOne(id: string): Promise<void> {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.coder.refreshProviders([id]);
|
||||||
|
await refreshProviderSnapshot();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to refresh');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDiagnostic(id: string): Promise<void> {
|
||||||
|
if (diagId === id) {
|
||||||
|
setDiagId(null);
|
||||||
|
setDiagText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDiagId(id);
|
||||||
|
setDiagText('Loading…');
|
||||||
|
try {
|
||||||
|
const { diagnostic } = await api.coder.getProviderDiagnostic(id);
|
||||||
|
setDiagText(diagnostic);
|
||||||
|
} catch (err) {
|
||||||
|
setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = allEntries ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
|
||||||
|
hidden from the composer picker but managed here.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)} className="shrink-0">
|
||||||
|
<Plus className="size-3.5" /> Add provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border divide-y">
|
||||||
|
{allEntries === null && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">Loading…</div>
|
||||||
|
)}
|
||||||
|
{entries.map((e) => {
|
||||||
|
const badge = statusBadge(e);
|
||||||
|
const isNative = e.transport === 'native';
|
||||||
|
const busy = busyId === e.name;
|
||||||
|
return (
|
||||||
|
<div key={e.name} className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{e.models.length} model{e.models.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||||||
|
badge.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.status === 'loading' && <Loader2 className="size-3 mr-1 animate-spin" />}
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshOne(e.name)}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
aria-label={`Refresh ${e.label}`}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openDiagnostic(e.name)}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Diagnostic for ${e.label}`}
|
||||||
|
title="Diagnostic"
|
||||||
|
>
|
||||||
|
<Stethoscope className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{isNative ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground w-14 text-center">
|
||||||
|
Always on
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={e.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void toggle(e)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40',
|
||||||
|
e.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
aria-label={`${e.enabled ? 'Disable' : 'Enable'} ${e.label}`}
|
||||||
|
title={e.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block size-4 rounded-full bg-background transition-transform',
|
||||||
|
e.enabled ? 'translate-x-4' : 'translate-x-0.5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{diagId === e.name && (
|
||||||
|
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted/50 p-2 text-[11px] font-mono whitespace-pre-wrap">
|
||||||
|
{diagText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
<AddProviderModal
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
onAdded={() => void refreshProviderSnapshot()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
import { ThemePicker } from '@/components/ThemePicker';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
|
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Section = 'session' | 'project' | 'theme';
|
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
{(['session', 'project', 'theme'] as const).map((s) => (
|
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||||
{activeSection === 'theme' && <ThemePicker />}
|
{activeSection === 'theme' && <ThemePicker />}
|
||||||
|
{activeSection === 'providers' && <ProvidersSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ProviderConfigPatch } from '@/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.3 Phase 5 (design.md §7.3) — a SMALL curated catalog of ACP coding agents
|
||||||
|
* the user might register. We deliberately do NOT port Paseo's 30+ entry list.
|
||||||
|
*
|
||||||
|
* Non-goal: we never install anything. Each entry is a manual-install hint
|
||||||
|
* (`installUrl` / `installCmd`) plus the config `command` that gets written into
|
||||||
|
* `/data/coder-providers.json`. The user installs the CLI themselves; until the
|
||||||
|
* binary is on PATH the provider shows as "Not installed". Commands are
|
||||||
|
* editable after adding — versions are aliased/untrimmed on purpose; pin on your
|
||||||
|
* own host once verified.
|
||||||
|
*/
|
||||||
|
export interface AcpCatalogEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Config command written verbatim into providers[id].command: [binary, ...args]. */
|
||||||
|
command: [string, ...string[]];
|
||||||
|
/** Where to install the CLI manually — we LINK, never install. */
|
||||||
|
installUrl: string;
|
||||||
|
/** Optional suggested install command, shown as a copyable hint. */
|
||||||
|
installCmd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACP_PROVIDER_CATALOG: AcpCatalogEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'amp-acp',
|
||||||
|
label: 'Amp',
|
||||||
|
description: 'Sourcegraph Amp — agentic coding CLI with an ACP bridge.',
|
||||||
|
command: ['amp-acp'],
|
||||||
|
installUrl: 'https://ampcode.com/',
|
||||||
|
installCmd: 'npm i -g @sourcegraph/amp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
description: 'Google Gemini CLI in ACP mode (--experimental-acp).',
|
||||||
|
command: ['gemini', '--experimental-acp'],
|
||||||
|
installUrl: 'https://github.com/google-gemini/gemini-cli',
|
||||||
|
installCmd: 'npm i -g @google/gemini-cli',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cline',
|
||||||
|
label: 'Cline',
|
||||||
|
description: 'Cline coding agent over ACP (run via npx).',
|
||||||
|
command: ['npx', '-y', 'cline', '--acp'],
|
||||||
|
installUrl: 'https://cline.bot/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-code-acp',
|
||||||
|
label: 'Claude Code (ACP)',
|
||||||
|
description: "Zed's ACP adapter for Claude Code — distinct from the built-in PTY claude provider.",
|
||||||
|
command: ['npx', '-y', '@zed-industries/claude-code-acp'],
|
||||||
|
installUrl: 'https://github.com/zed-industries/claude-code-acp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pi-acp',
|
||||||
|
label: 'Pi',
|
||||||
|
description: 'Example custom ACP entry — build the binary from source, then edit the command.',
|
||||||
|
command: ['pi-acp'],
|
||||||
|
installUrl: 'https://agentclientprotocol.com/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the PATCH body that registers a catalog entry: a single-id partial
|
||||||
|
* providers map with the custom-ACP override (extends:'acp' + label + command),
|
||||||
|
* enabled. Sent to PATCH /api/providers/config (then refreshProviders([id])).
|
||||||
|
*/
|
||||||
|
export function buildAcpProviderConfigPatch(entry: AcpCatalogEntry): ProviderConfigPatch {
|
||||||
|
return {
|
||||||
|
providers: {
|
||||||
|
[entry.id]: {
|
||||||
|
extends: 'acp',
|
||||||
|
label: entry.label,
|
||||||
|
description: entry.description,
|
||||||
|
command: entry.command,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
|||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
// SettingsPane component renders Session/Project sections from the
|
// SettingsPane component renders Session/Project sections from the
|
||||||
// surrounding session/project.
|
// surrounding session/project.
|
||||||
function settingsPane(): WorkspacePane {
|
function settingsPane(id: string = generateId()): WorkspacePane {
|
||||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||||
@@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult {
|
|||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
toggleSettingsPane: () => void;
|
toggleSettingsPane: () => string | null;
|
||||||
removePane: (idx: number) => void;
|
removePane: (idx: number) => void;
|
||||||
removeChatFromPanes: (chatId: string) => void;
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
@@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return success ? newPaneId : null;
|
return success ? newPaneId : null;
|
||||||
}, [seedPaneChat]);
|
}, [seedPaneChat]);
|
||||||
|
|
||||||
const toggleSettingsPane = useCallback(() => {
|
// Returns the new settings pane id when one is OPENED (so mobile callers can
|
||||||
|
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
|
||||||
|
// Id generated outside the updater so a strict-mode double-invoke agrees.
|
||||||
|
const toggleSettingsPane = useCallback((): string | null => {
|
||||||
|
const newPaneId = generateId();
|
||||||
|
let openedId: string | null = null;
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||||
if (existingIdx < 0) {
|
if (existingIdx < 0) {
|
||||||
const next = [...prev, settingsPane()];
|
const next = [...prev, settingsPane(newPaneId)];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
|
openedId = newPaneId;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
openedId = null;
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
return [emptyPane()];
|
return [emptyPane()];
|
||||||
@@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return openedId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removePane = useCallback((idx: number) => {
|
const removePane = useCallback((idx: number) => {
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// v2.3: opening the settings pane on mobile must push ?pane= atomically, or
|
||||||
|
// the URL-sync effect below snaps activePaneIdx back to the chat pane and the
|
||||||
|
// settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane
|
||||||
|
// returns the new pane id when it opens (null when it closes → drop ?pane= so
|
||||||
|
// the effect falls back to pane 0). Desktop has no URL pane state — no-op.
|
||||||
|
const toggleSettingsAndSync = useCallback(() => {
|
||||||
|
const openedId = panesHook.toggleSettingsPane();
|
||||||
|
if (!isMobile) return;
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (openedId) params.set('pane', openedId);
|
||||||
|
else params.delete('pane');
|
||||||
|
navigate(`${location.pathname}?${params.toString()}`);
|
||||||
|
}, [panesHook, isMobile, navigate, location.pathname, location.search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
||||||
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
// Sidebar Settings button broadcasts this when a session is mounted;
|
// Sidebar Settings button broadcasts this when a session is mounted;
|
||||||
// toggleSettingsPane opens on first click, closes on second.
|
// toggleSettingsPane opens on first click, closes on second.
|
||||||
if (event.type === 'open_settings_pane') {
|
if (event.type === 'open_settings_pane') {
|
||||||
panesHook.toggleSettingsPane();
|
toggleSettingsAndSync();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [sessionId, editingName, navigate, project, panesHook]);
|
}, [sessionId, editingName, navigate, project, toggleSettingsAndSync]);
|
||||||
|
|
||||||
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||||
|
|||||||
12
data/coder-providers.example.json
Normal file
12
data/coder-providers.example.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"goose": { "enabled": false },
|
||||||
|
"amp-acp": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "Amp",
|
||||||
|
"description": "ACP wrapper for Amp",
|
||||||
|
"command": ["amp-acp"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"providers": {}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
|
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
|
||||||
|
|
||||||
Last updated: 2026-05-26
|
Last updated: 2026-05-29
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Last updated: 2026-05-26
|
|||||||
| Item | Category | User impact | Effort | Risk if left alone |
|
| Item | Category | User impact | Effort | Risk if left alone |
|
||||||
|------|----------|-------------|--------|-------------------|
|
|------|----------|-------------|--------|-------------------|
|
||||||
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
|
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
|
||||||
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | Medium (v2.3 batch) | Slow provider picker; repeated ACP spawns on every snapshot rebuild |
|
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | ✅ Shipped (v2.3, Phase 2) | Resolved — `PROVIDER_PROBE_TTL_MS` TTL gate live |
|
||||||
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
||||||
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
||||||
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
|
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
|
||||||
@@ -111,7 +111,7 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web
|
|||||||
|
|
||||||
## 2. Skip ACP cold probe when DB models are fresh
|
## 2. Skip ACP cold probe when DB models are fresh
|
||||||
|
|
||||||
**Status:** Planned — [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked).
|
**Status:** ✅ **ADDRESSED** in v2.3 (phases 1–5: `v2.5.4-provider-lifecycle-phase1` … `v2.5.12-provider-lifecycle-phase4`, plus the phase-5 settings UI + picker filter). The `PROVIDER_PROBE_TTL_MS` (default 24h) gate on `available_agents.last_probed_at` is live — the tier-2 cold ACP probe runs only on `force` (`POST /api/providers/refresh`), TTL staleness, or empty DB models; otherwise the snapshot serves cached models. See [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). The original (v2.2) behavior below is kept for history.
|
||||||
|
|
||||||
### Current behavior (v2.2)
|
### Current behavior (v2.2)
|
||||||
|
|
||||||
@@ -140,12 +140,21 @@ See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md):
|
|||||||
|
|
||||||
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
|
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
|
||||||
|
|
||||||
### Acceptance criteria (when v2.3 ships)
|
### Acceptance criteria — met
|
||||||
|
|
||||||
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests)
|
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`)
|
||||||
- Disabled provider visible in settings, absent from composer
|
- Disabled provider visible in settings (Providers tab), absent from composer
|
||||||
- Explicit refresh repopulates models; warm open is sub-second
|
- Explicit refresh repopulates models; warm open is sub-second
|
||||||
|
|
||||||
|
### Still deferred (Tier-2 follow-ups, not shipped in v2.3)
|
||||||
|
|
||||||
|
These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open:
|
||||||
|
|
||||||
|
- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1).
|
||||||
|
- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2).
|
||||||
|
- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below).
|
||||||
|
- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Unified `packages/types` for provider snapshot JSON
|
## 3. Unified `packages/types` for provider snapshot JSON
|
||||||
|
|||||||
Reference in New Issue
Block a user