From 800341f582bd0fddfe3585d703c768f2d1b361f7 Mon Sep 17 00:00:00 2001 From: Vito Date: Mon, 17 Aug 2026 23:56:22 +0800 Subject: [PATCH] feat(server): pluggable public API component under /api/v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a versioned, engine-agnostic HTTP surface so third parties can drive iPolloWork without the desktop UI, organized as independently toggleable modules rather than another routes file. The design finishes two things the project had already started: - `server-route` is a declared contribution type (packages/types/src/plugins.ts) with no runtime consumer. The module registry is that concept made real: an `ApiModule` declares its operations once, and `registerApiModules` turns that single declaration into both the live route table and the OpenAPI document, so the two cannot drift. - `ConversationEngineAdapter` already unifies OpenCode and DeepSeek Harness in the browser (apps/app/.../engine/conversation-engine.ts) but had no server-side counterpart, which is why the server owned reads for both engines and writes for neither. `api/engine/` mirrors that contract, reusing its event vocabulary. Modules (all enabled by default; IPOLLOWORK_API_MODULES/_DISABLED select): sessions 11 ops create/prompt/interrupt, resumable SSE, permissions, questions tasks 5 ops submit a goal, follow it to a terminal state (in-memory) webhooks 5 ops HMAC-signed delivery, bounded retries, SSRF-checked targets policy 3 ops per-token workspace binding, approval policy, expiry openapi 3 ops OpenAPI 3.1 document, dependency-free docs page, catalogue compat 135 ops /api/v1 aliases of the legacy routes, which stay unchanged Where the engines genuinely differ, the API reports it instead of hiding it: capabilities are returned with every session, and an option an engine cannot apply is rejected with 501 rather than accepted and silently dropped. Changes to existing files are small and additive, except serve-node.ts, which had to be fixed for streaming to work at all: `request.signal` was never wired to the socket and the response body was released rather than cancelled, so an SSE producer kept running — timers, engine subscription and all — for the life of the process after a client hung up. server.ts mount registerApiV1 at the end of createRoutes tokens.ts findByHash, so policy can resolve a token without its secret serve-node.ts propagate client disconnect; cancel the response stream Verified: tsc --noEmit clean; 618 component tests; the existing suite is unaffected. Co-Authored-By: Claude Opus 5 --- apps/server/src/api/README.md | 190 +++ apps/server/src/api/engine/harness.test.ts | 784 +++++++++++++ apps/server/src/api/engine/harness.ts | 969 ++++++++++++++++ apps/server/src/api/engine/opencode.test.ts | 628 ++++++++++ apps/server/src/api/engine/opencode.ts | 644 +++++++++++ apps/server/src/api/engine/types.ts | 316 +++++ apps/server/src/api/index.test.ts | 484 ++++++++ apps/server/src/api/index.ts | 297 +++++ apps/server/src/api/module.test.ts | 360 ++++++ apps/server/src/api/module.ts | 292 +++++ .../src/api/modules/compat/module.test.ts | 460 ++++++++ apps/server/src/api/modules/compat/module.ts | 421 +++++++ apps/server/src/api/modules/openapi/module.ts | 534 +++++++++ .../src/api/modules/policy/module.test.ts | 474 ++++++++ apps/server/src/api/modules/policy/module.ts | 438 +++++++ .../src/api/modules/policy/policy.test.ts | 440 +++++++ apps/server/src/api/modules/policy/policy.ts | 340 ++++++ .../src/api/modules/sessions/module.test.ts | 624 ++++++++++ .../server/src/api/modules/sessions/module.ts | 925 +++++++++++++++ .../src/api/modules/tasks/module.test.ts | 1024 +++++++++++++++++ apps/server/src/api/modules/tasks/module.ts | 776 +++++++++++++ apps/server/src/api/modules/tasks/runner.ts | 497 ++++++++ .../src/api/modules/tasks/store.test.ts | 419 +++++++ apps/server/src/api/modules/tasks/store.ts | 455 ++++++++ .../src/api/modules/webhooks/delivery.test.ts | 693 +++++++++++ .../src/api/modules/webhooks/delivery.ts | 660 +++++++++++ .../src/api/modules/webhooks/module.test.ts | 680 +++++++++++ .../server/src/api/modules/webhooks/module.ts | 486 ++++++++ apps/server/src/api/modules/webhooks/store.ts | 346 ++++++ apps/server/src/api/openapi.test.ts | 497 ++++++++ apps/server/src/api/openapi.ts | 485 ++++++++ apps/server/src/api/plugin-manifest.test.ts | 59 + apps/server/src/api/sse.test.ts | 178 +++ apps/server/src/api/sse.ts | 172 +++ apps/server/src/serve-node.test.ts | 113 ++ apps/server/src/serve-node.ts | 61 +- apps/server/src/server.ts | 18 + apps/server/src/tokens.ts | 15 + .../public-api/ipollowork.plugin.json | 106 ++ examples/public-api/01-stream-session.ts | 63 + examples/public-api/02-handle-permissions.ts | 94 ++ examples/public-api/03-run-task.py | 66 ++ examples/public-api/04-ci-review.sh | 90 ++ examples/public-api/05-webhook-receiver.ts | 95 ++ examples/public-api/README.md | 64 ++ packages/ipollowork-api-sdk-python/README.md | 137 +++ .../ipollowork_api/__init__.py | 19 + .../ipollowork_api/client.py | 371 ++++++ .../ipollowork_api/errors.py | 70 ++ .../ipollowork_api/sse.py | 100 ++ .../ipollowork-api-sdk-python/pyproject.toml | 31 + .../tests/test_sdk.py | 300 +++++ packages/ipollowork-api-sdk/README.md | 147 +++ packages/ipollowork-api-sdk/package.json | 51 + packages/ipollowork-api-sdk/src/client.ts | 409 +++++++ packages/ipollowork-api-sdk/src/errors.ts | 50 + packages/ipollowork-api-sdk/src/index.ts | 23 + packages/ipollowork-api-sdk/src/sdk.test.ts | 361 ++++++ packages/ipollowork-api-sdk/src/sse.ts | 111 ++ packages/ipollowork-api-sdk/src/types.ts | 237 ++++ packages/ipollowork-api-sdk/tsconfig.json | 18 + 61 files changed, 20261 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/api/README.md create mode 100644 apps/server/src/api/engine/harness.test.ts create mode 100644 apps/server/src/api/engine/harness.ts create mode 100644 apps/server/src/api/engine/opencode.test.ts create mode 100644 apps/server/src/api/engine/opencode.ts create mode 100644 apps/server/src/api/engine/types.ts create mode 100644 apps/server/src/api/index.test.ts create mode 100644 apps/server/src/api/index.ts create mode 100644 apps/server/src/api/module.test.ts create mode 100644 apps/server/src/api/module.ts create mode 100644 apps/server/src/api/modules/compat/module.test.ts create mode 100644 apps/server/src/api/modules/compat/module.ts create mode 100644 apps/server/src/api/modules/openapi/module.ts create mode 100644 apps/server/src/api/modules/policy/module.test.ts create mode 100644 apps/server/src/api/modules/policy/module.ts create mode 100644 apps/server/src/api/modules/policy/policy.test.ts create mode 100644 apps/server/src/api/modules/policy/policy.ts create mode 100644 apps/server/src/api/modules/sessions/module.test.ts create mode 100644 apps/server/src/api/modules/sessions/module.ts create mode 100644 apps/server/src/api/modules/tasks/module.test.ts create mode 100644 apps/server/src/api/modules/tasks/module.ts create mode 100644 apps/server/src/api/modules/tasks/runner.ts create mode 100644 apps/server/src/api/modules/tasks/store.test.ts create mode 100644 apps/server/src/api/modules/tasks/store.ts create mode 100644 apps/server/src/api/modules/webhooks/delivery.test.ts create mode 100644 apps/server/src/api/modules/webhooks/delivery.ts create mode 100644 apps/server/src/api/modules/webhooks/module.test.ts create mode 100644 apps/server/src/api/modules/webhooks/module.ts create mode 100644 apps/server/src/api/modules/webhooks/store.ts create mode 100644 apps/server/src/api/openapi.test.ts create mode 100644 apps/server/src/api/openapi.ts create mode 100644 apps/server/src/api/plugin-manifest.test.ts create mode 100644 apps/server/src/api/sse.test.ts create mode 100644 apps/server/src/api/sse.ts create mode 100644 examples/plugin-packages/public-api/ipollowork.plugin.json create mode 100644 examples/public-api/01-stream-session.ts create mode 100644 examples/public-api/02-handle-permissions.ts create mode 100644 examples/public-api/03-run-task.py create mode 100755 examples/public-api/04-ci-review.sh create mode 100644 examples/public-api/05-webhook-receiver.ts create mode 100644 examples/public-api/README.md create mode 100644 packages/ipollowork-api-sdk-python/README.md create mode 100644 packages/ipollowork-api-sdk-python/ipollowork_api/__init__.py create mode 100644 packages/ipollowork-api-sdk-python/ipollowork_api/client.py create mode 100644 packages/ipollowork-api-sdk-python/ipollowork_api/errors.py create mode 100644 packages/ipollowork-api-sdk-python/ipollowork_api/sse.py create mode 100644 packages/ipollowork-api-sdk-python/pyproject.toml create mode 100644 packages/ipollowork-api-sdk-python/tests/test_sdk.py create mode 100644 packages/ipollowork-api-sdk/README.md create mode 100644 packages/ipollowork-api-sdk/package.json create mode 100644 packages/ipollowork-api-sdk/src/client.ts create mode 100644 packages/ipollowork-api-sdk/src/errors.ts create mode 100644 packages/ipollowork-api-sdk/src/index.ts create mode 100644 packages/ipollowork-api-sdk/src/sdk.test.ts create mode 100644 packages/ipollowork-api-sdk/src/sse.ts create mode 100644 packages/ipollowork-api-sdk/src/types.ts create mode 100644 packages/ipollowork-api-sdk/tsconfig.json diff --git a/apps/server/src/api/README.md b/apps/server/src/api/README.md new file mode 100644 index 000000000..621a2bfb7 --- /dev/null +++ b/apps/server/src/api/README.md @@ -0,0 +1,190 @@ +# Public API component (`/api/v1`) + +A self-contained, pluggable API surface for `ipollowork-server`. Every operation is +declared by a **module**; the declaration is the single source of truth, so the live route +table and the published OpenAPI document are generated from the same objects and cannot +drift. + +Two things make it a component rather than another routes file: + +- **Engine independence.** Handlers talk to `EngineConnection` (`engine/types.ts`), not to + OpenCode or DeepSeek Harness. `engine/opencode.ts` and `engine/harness.ts` are the only + files that know an engine's wire format. +- **Independent enable/disable.** Each module can be turned off without touching code, and + the OpenAPI document follows. + +## Mounting + +`registerApiV1({ ... })` (`index.ts`) is the composition root: it builds the +`EngineAdapterRegistry`, owns the task and webhook stores, assembles the +`ApiModuleContext`, resolves which modules are enabled, and calls `registerApiModules`. + +It is invoked once, at the **end** of `createRoutes` in `../server.ts`, after every legacy +`register*Routes` call. Order matters in exactly one direction: `matchRoute` +(`../routes/registry.ts`) scans the array and returns the **first** match, so appending +routes can never shadow an existing one — but the `compat` module re-dispatches into the +legacy table and must be able to see all of it. It receives `legacyRoutes: () => routes`, +a getter rather than a snapshot, so it reads the finished table at request time. + +## Modules + +| id | stability | ops | base paths | +| --- | --- | --- | --- | +| `sessions` | stable | 11 | `/api/v1/workspaces/{workspaceId}/sessions…` | +| `tasks` | preview | 5 | `/api/v1/workspaces/{workspaceId}/tasks…` | +| `webhooks` | preview | 5 | `/api/v1/workspaces/{workspaceId}/webhooks…` | +| `policy` | preview | 3 | `/api/v1/whoami`, `/api/v1/tokens/{tokenId}/policy` | +| `openapi` | stable | 3 | `/api/v1/openapi.json`, `/api/v1/docs`, `/api/v1/modules` | +| `compat` | stable | 135 | `/api/v1/…` aliases of legacy routes | + +- **`sessions`** — engine-agnostic conversations: create, inspect, prompt, interrupt, + stream events (SSE, resumable via `?after=` where the engine has a durable cursor), and + answer permission and question requests. +- **`tasks`** — one-shot automation over a session. Records live in **server memory and are + lost on restart**; the `sessionId` a task reports is the durable handle. Depends on + `sessions`. +- **`webhooks`** — outbound subscriptions per workspace, HMAC-signed, bounded retries, + private-network targets rejected unless explicitly opted in. `index.ts` bridges the task + store's event log into delivery, which is what makes the `task.*` event names real. +- **`policy`** — the caller's resolved identity, plus per-token workspace binding, approval + policy and expiry. Policy mutation is `host`-authenticated. +- **`openapi`** — the generated OpenAPI 3.1 document, a dependency-free HTML reference, and + the catalogue of enabled modules. +- **`compat`** — republishes legacy routes under `/api/v1` by re-dispatching into the legacy + handler. Legacy paths stay available and unchanged. The toy UI, the raw `/opencode/*` + proxy, `/w/:id/*` mounts, browser OAuth callbacks, `/mcp-proxy/*` and `/dev/log` are + deliberately **not** aliased — they are not a stable public contract. + +### Enabling and disabling + +Every module is enabled by default. + +| env var | effect | +| --- | --- | +| `IPOLLOWORK_API_MODULES` | Comma-separated **allowlist**. Only these modules load. | +| `IPOLLOWORK_API_MODULES_DISABLED` | Comma-separated **denylist**, applied after the allowlist. | + +An unknown id in either list is a startup error (`api_module_unknown`), not a silent no-op, +so a typo cannot quietly drop an API surface. Disabling a module another enabled module +depends on is also a startup error (`api_module_dependency_missing`) — e.g. `tasks` without +`sessions`. + +`GET /api/v1/modules` reports what is actually live, including each operation's method, +path, effect and scope. + +## Engines are uniform, but not identical + +`EngineConnection` gives both engines one shape, and most of the time a caller never needs +to know which one is behind a workspace. Where the engines genuinely differ, the difference +is reported rather than hidden — `GET /api/v1/workspaces/{id}/sessions/{sid}` returns a +`capabilities` object, and an operation the engine cannot perform answers `501 +engine_capability_unsupported` instead of failing in some engine-specific way. + +Two differences matter in practice today: + +| | OpenCode | DeepSeek Harness | +| --- | --- | --- | +| `?after=` stream resumption | yes — events carry a durable cursor | no — `resumableStreaming: false` | +| `system` / `reasoningEffort` on prompt | not applied | applied | + +The second one is the reason `promptOptions` exists. OpenCode's v2 prompt endpoint has no +field for a per-turn system prompt, so passing one would have quietly done nothing; the +module rejects it with `501 engine_prompt_option_unsupported` instead, naming the fields in +`details.unsupported`. A silent no-op is the worse failure: the caller gets a 202 and never +learns the instruction was dropped. + +## Auth model + +Three gates, applied in this order by `registerApiModules` before a handler runs: + +1. **Route auth mode** (`auth`, default `client`) — handed to `addRoute` and enforced by the + server's dispatcher, exactly as for legacy routes. `client` authenticates a client token, + `host` a host token. +2. **Writability** — every operation whose `effect` is `write` or `destructive` calls + `ensureWritable(config)`, which throws `403 read_only` on a read-only server. +3. **Client token scope** — for `auth: "client"` operations only. The required scope is the + operation's `scope`, defaulting by effect: `read → viewer`, `write` and + `destructive → collaborator`. + +**Handlers must not repeat these checks.** Re-checking scope or writability inside a handler +is redundant at best and, when it disagrees with the declaration, makes the OpenAPI document +lie about what a token needs. + +`compat` aliases are the one deliberate exception to the effect-based defaults: each alias +copies the legacy route's auth mode and declares a scope no stricter than the legacy +handler's own check, so an alias can never reject a request the legacy path would accept. +The 13 cases where an alias is stricter — legacy write handlers that never call +`ensureWritable`, and only on a read-only server — are listed in `COMPAT_READ_ONLY_STRICTER`. +That divergence is fail-closed. + +Errors are the server's standard shape: `throw new ApiError(status, code, message, details?)` +serialises to `{ code, message, details }`. + +## Adding a module + +1. Create `modules//module.ts` exporting an `ApiModule`: + + ```ts + export const thingsModule: ApiModule = { + id: "things", + title: "Things", + description: "…", + version: "1.0.0", + stability: "preview", + dependsOn: ["sessions"], // optional; checked against the enabled set + register(context: ApiModuleContext): ApiOperation[] { + return [ + { + operationId: "listThings", // unique across all modules; becomes the SDK method + method: "GET", + path: "/api/v1/workspaces/:workspaceId/things", + summary: "List things", + effect: "read", // read | write | destructive + // auth defaults to "client"; scope defaults from effect + responses: { 200: { description: "…", schema: { type: "array" } } }, + handler: async (ctx) => context.jsonResponse({ items: [] }), + }, + ]; + }, + }; + ``` + +2. Take everything the handler needs from `ApiModuleContext`: `config`, `jsonResponse`, + `readJsonBody`, `resolveWorkspace`, and late-bound singletons from `context.services`. + `index.ts` supplies `engines`, `legacyRoutes`, `getApiRegistry`, `serverVersion`, and — + when the owning module is enabled — `taskStore`, `taskRunner` and `webhookStore`. A + module may also accept its own optional injection point (`policy` reads + `tokenPolicies`, `webhooks` reads `webhookFetch`) and construct a default when absent. + Read a required service defensively and fail with a `500` naming it — that is a wiring + bug, not a request error. +3. Add it to `API_MODULES` in `index.ts`. `compat` stays last: its aliases are the broadest + patterns in the component. +4. Register anything the module owns as a singleton in `index.ts` rather than inside + `register()` when something else needs the same instance. +5. Write tests next to the source (`module.test.ts`, `bun:test`). Registry behaviour, pure + parsing/mapping and schema validation are testable without a live engine — use a stub + connection, never a real OpenCode process. + +Registration is fail-fast: a duplicate module id, a duplicate `operationId` and a duplicate +`method + path` each throw at startup. + +Set `internal: true` to route an operation without publishing it; set `streaming: "sse"` so +the document and generated SDKs describe it as a stream. + +## OpenAPI and docs + +| endpoint | returns | +| --- | --- | +| `GET /api/v1/openapi.json` | OpenAPI 3.1 document of every enabled, non-`internal` operation. Deterministic — identical input produces byte-identical JSON, so it can be committed and diffed in CI. | +| `GET /api/v1/docs` | Self-contained HTML reference. No CDN, no external assets. | +| `GET /api/v1/modules` | The enabled module catalogue (`describeModules`), including each operation's effect and required scope. | + +The document is built by `openapi.ts` from the same `ApiOperation` objects that produced the +routes, and is cached per registry identity. + +## Tests + +```sh +bun test src/api # the whole component +npx tsc -p tsconfig.json --noEmit # whole server, including this component +``` diff --git a/apps/server/src/api/engine/harness.test.ts b/apps/server/src/api/engine/harness.test.ts new file mode 100644 index 000000000..291b9a656 --- /dev/null +++ b/apps/server/src/api/engine/harness.test.ts @@ -0,0 +1,784 @@ +import { describe, expect, test } from "bun:test"; + +import { + DeepSeekHarnessRpcError, + DeepSeekHarnessUnavailableError, +} from "../../deepseek-harness-runtime.js"; +import { ApiError } from "../../errors.js"; +import { + createHarnessEngineAdapter, + createHarnessStreamState, + HARNESS_CAPABILITIES, + harnessPromptContent, + mapHarnessError, + mapHarnessEvent, + mapHarnessEvents, + normalizeHarnessErrorText, + type HarnessRuntimeLike, +} from "./harness.js"; +import type { EngineEvent } from "./types.js"; + +const SESSION_ID = "dsh-session"; + +function envelope(payload: Record, rpcId = "rpc-1") { + return { type: "server-request", rpcId, payload }; +} + +function sessionEvent(event: Record, sessionId = SESSION_ID) { + return envelope({ type: "session/event", sessionId, event }); +} + +function sseResponse(frames: unknown[]): Response { + const body = frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join(""); + return new Response(new TextEncoder().encode(body)); +} + +type RuntimeCall = { method: string; payload: unknown }; + +function stubRuntime(input: { + results?: Record; + mux?: unknown[]; + host?: unknown[]; +} = {}) { + const calls: RuntimeCall[] = []; + const responded: Array<{ rpcId: string; result: unknown }> = []; + const runtime: HarnessRuntimeLike = { + async call(method: string, payload: unknown): Promise { + calls.push({ method, payload }); + return (input.results?.[method] ?? {}) as T; + }, + async respond(request) { + responded.push(request); + }, + async events(stream) { + return sseResponse((stream === "mux" ? input.mux : input.host) ?? []); + }, + }; + return { runtime, calls, responded }; +} + +describe("mapHarnessEvent", () => { + test("maps an assistant text delta to message.delta without a durable cursor", () => { + const event = mapHarnessEvent( + sessionEvent({ + type: "assistant/chunk", + seq: 7, + time: 10, + data: { turn: 1, step: 2, chunk: { type: "text-delta", index: 0, text: "Hel" } }, + }), + SESSION_ID, + ); + + expect(event).toEqual({ + type: "message.delta", + sessionId: SESSION_ID, + messageId: "dsh:dsh-session:assistant:1:2", + partId: "dsh:dsh-session:assistant:1:2:block:0", + kind: "text", + delta: "Hel", + }); + expect(event && "seq" in event).toBe(false); + }); + + test("maps a reasoning delta to the reasoning kind", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "assistant/chunk", + seq: 8, + time: 11, + data: { turn: 0, step: 0, chunk: { type: "reasoning-delta", index: 3, text: "why" } }, + }), + SESSION_ID, + )).toMatchObject({ type: "message.delta", kind: "reasoning", delta: "why" }); + }); + + test("maps a user message and strips the internal system block", () => { + const event = mapHarnessEvent( + sessionEvent({ + type: "user/message", + seq: 1, + time: 5, + data: { + id: "user-1", + role: "user", + content: [ + { type: "text", text: "\n\nhidden\nBuild it" }, + ], + }, + }), + SESSION_ID, + ); + + expect(event).toEqual({ + type: "message.upsert", + sessionId: SESSION_ID, + message: { + id: "user-1", + role: "user", + parts: [{ id: "user-1:block:0", type: "text", text: "Build it" }], + createdAt: 5, + }, + }); + }); + + test("drops plugin-authored user turns", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "user/message", + seq: 2, + time: 6, + data: { + id: "runtime-context", + role: "user", + source: { kind: "plugin", plugin: "@deepseek-ai/dsh-system-prompt" }, + content: [{ type: "text", text: "Current runtime context" }], + }, + }), + SESSION_ID, + )).toBeNull(); + }); + + test("maps an assistant message onto the turn/step message id", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "assistant/message", + seq: 4, + time: 20, + data: { + turn: 1, + step: 1, + message: { id: "assistant-native", role: "assistant", content: [{ type: "text", text: "Done" }] }, + }, + }), + SESSION_ID, + )).toEqual({ + type: "message.upsert", + sessionId: SESSION_ID, + message: { + id: "dsh:dsh-session:assistant:1:1", + role: "assistant", + parts: [{ id: "dsh:dsh-session:assistant:1:1:block:0", type: "text", text: "Done" }], + createdAt: 20, + }, + }); + }); + + test("maps tool/call and normalizes snake_case tool input", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "tool/call", + seq: 9, + time: 30, + data: { + turn: 1, + step: 0, + callId: "call-1", + name: "read", + arguments: JSON.stringify({ file_path: "/tmp/a.txt" }), + }, + }), + SESSION_ID, + )).toEqual({ + type: "tool.called", + sessionId: SESSION_ID, + messageId: "dsh:dsh-session:assistant:1:0", + callId: "call-1", + tool: "read", + input: { filePath: "/tmp/a.txt" }, + }); + }); + + test("pairs tool/result with the recorded tool/call through the stream state", () => { + const state = createHarnessStreamState(); + mapHarnessEvents( + sessionEvent({ + type: "tool/call", + seq: 9, + time: 30, + data: { turn: 2, step: 1, callId: "call-9", name: "bash", arguments: { command: "ls" } }, + }), + SESSION_ID, + state, + ); + + expect(mapHarnessEvents( + sessionEvent({ + type: "tool/result", + seq: 10, + time: 31, + data: { + message: { + content: [{ type: "tool-result", toolCallId: "call-9", content: [{ type: "text", text: "a.txt" }] }], + }, + }, + }), + SESSION_ID, + state, + )).toEqual([{ + type: "tool.completed", + sessionId: SESSION_ID, + messageId: "dsh:dsh-session:assistant:2:1", + callId: "call-9", + tool: "bash", + status: "success", + output: "a.txt", + }]); + expect(state.tools.size).toBe(0); + }); + + test("reports a failed tool result", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "tool/result", + seq: 11, + time: 32, + data: { + name: "bash", + message: { + content: [{ type: "tool-result", toolCallId: "call-x", content: [], isError: true }], + }, + error: { name: "CommandFailed" }, + }, + }), + SESSION_ID, + )).toMatchObject({ type: "tool.completed", status: "failed", tool: "bash", error: "CommandFailed" }); + }); + + test("maps an approval request to a permission carrying its reply address", () => { + const event = mapHarnessEvent( + envelope({ + type: "approval/requested", + sessionId: SESSION_ID, + approvalId: "approval-1", + toolName: "bash", + reason: "Run tests", + }, "rpc-approval"), + SESSION_ID, + ); + + expect(event).toMatchObject({ + type: "permission.asked", + permission: { + id: "approval-1", + sessionId: SESSION_ID, + kind: "bash", + resources: ["Run tests"], + // Harness cannot persist an answer, so no scope is offered. + remember: [], + metadata: { rpcId: "rpc-approval", toolName: "bash", reason: "Run tests" }, + }, + }); + }); + + test("maps approval/resolved and question/resolved to replies", () => { + expect(mapHarnessEvent( + envelope({ type: "approval/resolved", sessionId: SESSION_ID, approvalId: "approval-1" }), + SESSION_ID, + )).toEqual({ type: "permission.replied", sessionId: SESSION_ID, requestId: "approval-1" }); + + expect(mapHarnessEvent( + envelope({ type: "question/resolved", sessionId: SESSION_ID, questionRpcId: "rpc-question" }), + SESSION_ID, + )).toEqual({ type: "question.replied", sessionId: SESSION_ID, requestId: "rpc-question" }); + }); + + test("maps a question request, using the rpc id as the question id", () => { + expect(mapHarnessEvent( + envelope({ + type: "question/requested", + sessionId: SESSION_ID, + questions: [{ + id: "q1", + header: "Pick", + question: "Which build?", + options: [{ label: "debug", description: "slow" }, { label: "release" }], + multiSelect: false, + }], + }, "rpc-question"), + SESSION_ID, + )).toEqual({ + type: "question.asked", + question: { + id: "rpc-question", + sessionId: SESSION_ID, + questions: [{ + header: "Pick", + question: "Which build?", + options: [{ label: "debug", description: "slow" }, { label: "release" }], + multiple: false, + custom: true, + }], + receivedAt: expect.any(Number), + }, + }); + }); + + test("turn/start and turn/end carry the session lifecycle", () => { + expect(mapHarnessEvent( + sessionEvent({ type: "turn/start", seq: 3, time: 9, data: { turn: 1 } }), + SESSION_ID, + )).toEqual({ type: "session.status", sessionId: SESSION_ID, status: { type: "busy" } }); + + expect(mapHarnessEvents( + sessionEvent({ type: "turn/end", seq: 6, time: 40, data: { turn: 1, reason: { kind: "completed" } } }), + SESSION_ID, + )).toEqual([ + { type: "session.status", sessionId: SESSION_ID, status: { type: "idle" } }, + { type: "session.idle", sessionId: SESSION_ID }, + ]); + }); + + test("a failed turn reports the error before idling", () => { + const events = mapHarnessEvents( + sessionEvent({ + type: "turn/end", + seq: 6, + time: 40, + data: { + turn: 1, + reason: { + kind: "error", + error: { message: 'llm-deepseek: no API key for provider route "deepseek-official"' }, + }, + }, + }), + SESSION_ID, + ); + + expect(events.map((event) => event.type)).toEqual(["session.error", "session.status", "session.idle"]); + expect(events[0]).toMatchObject({ error: { message: expect.stringContaining("API key") } }); + }); + + test("maps todos, titles, compaction and host frames", () => { + expect(mapHarnessEvent( + sessionEvent({ + type: "todo/write", + seq: 5, + time: 30, + data: { todos: [{ content: "Verify", status: "in_progress" }] }, + }), + SESSION_ID, + )).toEqual({ + type: "todo.updated", + sessionId: SESSION_ID, + todos: [{ id: "dsh-session:0:Verify", content: "Verify", status: "in_progress", priority: "medium" }], + }); + + expect(mapHarnessEvent( + envelope({ type: "session/projection", sessionId: SESSION_ID, key: "title", value: "Ship it" }), + SESSION_ID, + )).toEqual({ + type: "session.updated", + sessionId: SESSION_ID, + session: { id: SESSION_ID, title: "Ship it" }, + }); + + expect(mapHarnessEvent( + sessionEvent({ type: "compaction/start", seq: 12, time: 50, data: {} }), + SESSION_ID, + )).toEqual({ type: "session.compaction", sessionId: SESSION_ID, running: true }); + + expect(mapHarnessEvent( + envelope({ type: "host/session-removed", sessionId: SESSION_ID }), + SESSION_ID, + )).toEqual({ type: "session.deleted", sessionId: SESSION_ID }); + + expect(mapHarnessEvent( + envelope({ type: "host/agent-error", sessionId: SESSION_ID, message: "boom" }), + SESSION_ID, + )).toEqual({ type: "session.error", sessionId: SESSION_ID, error: { message: "boom" } }); + }); + + test("filters frames belonging to another session", () => { + const other = sessionEvent( + { type: "turn/start", seq: 3, time: 9, data: { turn: 1 } }, + "another-session", + ); + expect(mapHarnessEvent(other, SESSION_ID)).toBeNull(); + expect(mapHarnessEvents(other, SESSION_ID)).toEqual([]); + expect(mapHarnessEvent( + envelope({ type: "approval/requested", sessionId: "another-session", approvalId: "a" }), + SESSION_ID, + )).toBeNull(); + }); + + test("returns null for unknown, malformed and deliberately ignored frames", () => { + expect(mapHarnessEvent(null, SESSION_ID)).toBeNull(); + expect(mapHarnessEvent("nope", SESSION_ID)).toBeNull(); + expect(mapHarnessEvent({ type: "server-request", rpcId: "r" }, SESSION_ID)).toBeNull(); + expect(mapHarnessEvent(envelope({ type: "totally/unknown", sessionId: SESSION_ID }), SESSION_ID)).toBeNull(); + expect(mapHarnessEvent(sessionEvent({ type: "unknown/inner", seq: 1, time: 1, data: {} }), SESSION_ID)).toBeNull(); + // host/session-status races the ordered turn lifecycle and is dropped on purpose. + expect(mapHarnessEvent( + envelope({ type: "host/session-status", sessionId: SESSION_ID, running: true }), + SESSION_ID, + )).toBeNull(); + }); +}); + +describe("mapHarnessError", () => { + test("maps an unavailable runtime to 503 with its own code", () => { + const mapped = mapHarnessError(new DeepSeekHarnessUnavailableError("DeepSeek Harness could not be reached")); + expect(mapped).toBeInstanceOf(ApiError); + expect(mapped).toMatchObject({ + status: 503, + code: "deepseek_harness_unavailable", + message: "DeepSeek Harness could not be reached", + }); + }); + + test("maps not-found RPC failures to 404 and everything else to 502", () => { + for (const code of ["not-found", "session-not-found"]) { + expect(mapHarnessError(new DeepSeekHarnessRpcError({ code, message: "gone" }))).toMatchObject({ + status: 404, + code: `deepseek_harness_${code}`, + }); + } + + expect(mapHarnessError(new DeepSeekHarnessRpcError({ + code: "bad-request", + message: "nope", + details: { field: "sessionId" }, + }))).toMatchObject({ + status: 502, + code: "deepseek_harness_bad-request", + message: "nope", + details: { field: "sessionId" }, + }); + }); + + test("passes unrelated errors through untouched", () => { + const error = new Error("unrelated"); + expect(mapHarnessError(error)).toBe(error); + const apiError = new ApiError(404, "session_not_found", "Session not found"); + expect(mapHarnessError(apiError)).toBe(apiError); + }); + + test("replaces credential internals with an actionable message", () => { + expect(normalizeHarnessErrorText( + 'llm-deepseek: no API key for provider route "deepseek-official"; store DEEPSEEK_API_KEY through the credentials service', + )).toContain("API key"); + expect(normalizeHarnessErrorText("")).toBe("DeepSeek Harness failed to run this turn"); + }); +}); + +describe("harness engine connection", () => { + const adapter = createHarnessEngineAdapter({ runtime: stubRuntime().runtime }); + + test("reports capabilities honestly", () => { + expect(adapter.id).toBe("deepseek-harness"); + expect(HARNESS_CAPABILITIES).toEqual({ + streaming: true, + resumableStreaming: false, + permissions: true, + questions: true, + interrupt: true, + wait: false, + promptOptions: { system: true, reasoningEffort: true, variant: true }, + }); + }); + + test("routes each operation to its allowlisted RPC method", async () => { + const { runtime, calls } = stubRuntime({ + results: { + "session.create": { sessionId: "s1", agentPreset: "standard" }, + // A workspace-scoped connection confirms ownership before every write, so the + // session has to be visible in this cwd for the writes below to be allowed. + "session.list": { + items: [{ sessionId: "s1", updatedAt: 1, running: false, blank: false, cwd: "/work/repo" }], + }, + "workspace.list": { archivedSessionIds: [] }, + }, + }); + const connection = createHarnessEngineAdapter({ runtime }).connect({ path: "/work/repo" }); + + const session = await connection.createSession({ title: "Ship it", agent: "code" }); + expect(session).toMatchObject({ id: "s1", title: "Ship it", directory: "/work/repo" }); + + await connection.renameSession("s1", "Renamed"); + expect(await connection.interrupt("s1")).toBe(true); + await connection.prompt({ + sessionId: "s1", + parts: [{ type: "text", text: "hello" }], + model: { providerID: "deepseek", modelID: "deepseek-chat" }, + reasoningEffort: "high", + }); + + // `session.list` + `workspace.list` pairs are the ownership check that precedes each + // write; the writes themselves keep their original order. + expect(calls.map((entry) => entry.method).filter((method) => method !== "session.list" && method !== "workspace.list")) + .toEqual([ + "session.create", + "agentPreset.select", + "session.rename", + "session.rename", + "session.cancel", + "session.selectModel", + "session.prompt", + ]); + expect(calls.at(-2)?.payload).toMatchObject({ reasoningEffort: "high", model: "deepseek-chat" }); + expect(calls.at(-1)?.payload).toMatchObject({ + sessionId: "s1", + mode: "queue", + content: [{ type: "text", text: "hello" }], + }); + }); + + test("throws 501 for operations DeepSeek Harness has no RPC for", async () => { + const connection = createHarnessEngineAdapter({ runtime: stubRuntime().runtime }).connect({}); + + const rejects = async (promise: Promise) => { + try { + await promise; + } catch (error) { + return error as ApiError; + } + throw new Error("expected a rejection"); + }; + + for (const promise of [ + connection.deleteSession("s1"), + connection.prompt({ sessionId: "s1", parts: [], delivery: "steer" }), + connection.replyPermission({ sessionId: "s1", permissionId: "p1", reply: "always" }), + connection.subscribe({ + sessionId: "s1", + after: "42", + signal: new AbortController().signal, + onEvent: () => undefined, + }), + ]) { + const error = await rejects(promise); + expect(error.status).toBe(501); + expect(error.code).toBe("engine_capability_unsupported"); + expect(error.message).toContain("DeepSeek Harness"); + } + }); + + test("subscribe consumes both multiplexed streams and answers a permission", async () => { + const { runtime, responded } = stubRuntime({ + mux: [ + envelope({ + type: "approval/requested", + sessionId: SESSION_ID, + approvalId: "approval-1", + toolName: "bash", + reason: "Run tests", + }, "rpc-approval"), + sessionEvent({ type: "turn/end", seq: 6, time: 40, data: { turn: 1, reason: { kind: "completed" } } }), + sessionEvent({ type: "turn/start", seq: 1, time: 1, data: { turn: 0 } }, "other-session"), + ], + host: [envelope({ type: "host/agent-error", sessionId: SESSION_ID, message: "boom" })], + }); + const connection = createHarnessEngineAdapter({ runtime }).connect({}); + const events: EngineEvent[] = []; + + await connection.subscribe({ + sessionId: SESSION_ID, + signal: new AbortController().signal, + onEvent: (event) => events.push(event), + }); + + expect(events.map((event) => event.type).sort()).toEqual([ + "permission.asked", + "session.error", + "session.idle", + "session.status", + ]); + expect(await connection.listPermissions(SESSION_ID)).toHaveLength(1); + expect(await connection.listPermissions("other-session")).toHaveLength(0); + + await connection.replyPermission({ sessionId: SESSION_ID, permissionId: "approval-1", reply: "once" }); + expect(responded).toEqual([{ + rpcId: "rpc-approval", + result: { + ok: true, + value: { sessionId: SESSION_ID, approvalId: "approval-1", outcome: "allowed-once" }, + }, + }]); + expect(await connection.listPermissions(SESSION_ID)).toHaveLength(0); + }); + + test("answers a question against the pending frame and rejects a stale id", async () => { + const { runtime, responded } = stubRuntime({ + mux: [envelope({ + type: "question/requested", + sessionId: SESSION_ID, + questions: [{ + id: "q1", + question: "Which build?", + options: [{ label: "debug" }, { label: "release" }], + multiSelect: true, + }], + }, "rpc-question")], + }); + const connection = createHarnessEngineAdapter({ runtime }).connect({}); + await connection.subscribe({ + sessionId: SESSION_ID, + signal: new AbortController().signal, + onEvent: () => undefined, + }); + + expect(await connection.listQuestions(SESSION_ID)).toHaveLength(1); + await connection.replyQuestion({ + sessionId: SESSION_ID, + questionId: "rpc-question", + answers: [["release", "wasm"]], + }); + expect(responded).toEqual([{ + rpcId: "rpc-question", + result: { + ok: true, + value: { sessionId: SESSION_ID, answer: { answers: [{ id: "q1", selected: ["release"], custom: "wasm" }] } }, + }, + }]); + + await expect(connection.replyQuestion({ + sessionId: SESSION_ID, + questionId: "rpc-question", + answers: [[]], + })).rejects.toMatchObject({ status: 404, code: "engine_question_not_found" }); + }); + + test("getSession maps the summary and refuses a session from another workspace", async () => { + const { runtime } = stubRuntime({ + results: { + "session.list": { + items: [ + { + sessionId: SESSION_ID, + updatedAt: 1_700_000, + running: false, + blank: false, + cwd: "/work/repo", + projections: { asOfSeq: 3, values: { title: "Ship it" } }, + }, + { sessionId: "elsewhere", updatedAt: 1, running: false, blank: true, cwd: "/other/repo" }, + ], + }, + "workspace.list": { archivedSessionIds: [SESSION_ID] }, + }, + }); + const connection = createHarnessEngineAdapter({ runtime }).connect({ path: "/work/repo" }); + + expect(await connection.getSession(SESSION_ID)).toEqual({ + id: SESSION_ID, + title: "Ship it", + parentId: null, + directory: "/work/repo", + createdAt: 1_700_000, + updatedAt: 1_700_000, + archivedAt: 1_700_000, + }); + await expect(connection.getSession("elsewhere")) + .rejects.toMatchObject({ status: 404, code: "session_not_found" }); + }); + + test("write operations refuse a session that belongs to another workspace", async () => { + // The Harness runtime is one process shared by every workspace and `session.list` + // returns all of them, so a session id proves nothing about access on its own. + // Without the check, a prompt aimed at another workspace's session would run there — + // against its files and its agent. + const foreign = () => stubRuntime({ + results: { + "session.list": { + items: [ + { sessionId: SESSION_ID, updatedAt: 1, running: false, blank: false, cwd: "/work/repo" }, + { sessionId: "elsewhere", updatedAt: 1, running: false, blank: true, cwd: "/other/repo" }, + ], + }, + "workspace.list": { archivedSessionIds: [] }, + }, + }); + + for (const [label, act] of [ + ["prompt", (c: ReturnType["connect"]>) => + c.prompt({ sessionId: "elsewhere", parts: [{ type: "text", text: "hi" }] })], + ["renameSession", (c: ReturnType["connect"]>) => + c.renameSession("elsewhere", "renamed")], + ["interrupt", (c: ReturnType["connect"]>) => + c.interrupt("elsewhere")], + ] as const) { + const { runtime, calls } = foreign(); + const connection = createHarnessEngineAdapter({ runtime }).connect({ path: "/work/repo" }); + + await expect(act(connection)).rejects.toMatchObject({ status: 404, code: "session_not_found" }); + // The mutation must not reach the runtime at all. + expect(calls.map((call) => call.method)).not.toContain("session.prompt"); + expect(calls.map((call) => call.method)).not.toContain("session.rename"); + expect(calls.map((call) => call.method)).not.toContain("session.cancel"); + expect(label).toBeTruthy(); + } + }); + + test("write operations still work on a session in this workspace", async () => { + const { runtime, calls } = stubRuntime({ + results: { + "session.list": { + items: [{ sessionId: SESSION_ID, updatedAt: 1, running: false, blank: false, cwd: "/work/repo" }], + }, + "workspace.list": { archivedSessionIds: [] }, + }, + }); + const connection = createHarnessEngineAdapter({ runtime }).connect({ path: "/work/repo" }); + + await connection.interrupt(SESSION_ID); + expect(calls.map((call) => call.method)).toContain("session.cancel"); + }); + + test("an unscoped connection skips the ownership lookup", async () => { + // A connection with no directory is not workspace-scoped, so there is nothing to + // check and the extra `session.list` round-trip would be wasted. + const { runtime, calls } = stubRuntime(); + const connection = createHarnessEngineAdapter({ runtime }).connect({}); + + await connection.interrupt(SESSION_ID); + expect(calls.map((call) => call.method)).toEqual(["session.cancel"]); + }); + + test("wait polls session.list until the session stops running", async () => { + let running = true; + const runtime: HarnessRuntimeLike = { + async call(method: string): Promise { + if (method === "workspace.list") return { archivedSessionIds: [] } as T; + const items = [{ sessionId: SESSION_ID, updatedAt: 1, running, blank: false }]; + running = false; + return { items } as T; + }, + async respond() {}, + async events() { + return sseResponse([]); + }, + }; + const connection = createHarnessEngineAdapter({ runtime, waitPollIntervalMs: 1 }).connect({}); + + await connection.wait(SESSION_ID); + expect(connection.capabilities.wait).toBe(false); + }); + + test("wait aborts with the caller's signal", async () => { + const controller = new AbortController(); + controller.abort(); + const connection = createHarnessEngineAdapter({ runtime: stubRuntime().runtime, waitPollIntervalMs: 1 }) + .connect({}); + await expect(connection.wait(SESSION_ID, controller.signal)).rejects.toBeInstanceOf(ApiError); + }); + + test("prompt content carries files, agents and the internal system block", () => { + expect(harnessPromptContent({ + sessionId: "s1", + system: "context", + parts: [ + { type: "text", text: "hi" }, + { type: "agent", name: "reviewer" }, + { type: "file", mime: "image/png", url: "data:image/png;base64,AAA", filename: "shot.png" }, + { type: "file", mime: "text/plain", url: "data:text/plain,hello", filename: "a.txt" }, + ], + })).toEqual([ + { type: "text", text: "hi" }, + { type: "text", text: "@reviewer" }, + { type: "image", mediaType: "image/png", data: "AAA", name: "shot.png" }, + { type: "text", text: "[Attached file: a.txt]\nhello" }, + { type: "text", text: "\n\ncontext\n" }, + ]); + }); +}); diff --git a/apps/server/src/api/engine/harness.ts b/apps/server/src/api/engine/harness.ts new file mode 100644 index 000000000..fba8b08db --- /dev/null +++ b/apps/server/src/api/engine/harness.ts @@ -0,0 +1,969 @@ +/** + * DeepSeek Harness engine adapter. + * + * Harness speaks a JSON-RPC dialect over `DeepSeekHarnessRuntime` plus two + * multiplexed SSE streams (`mux` / `host`). It has no per-session, resumable + * event stream and no durable cursor, so `EngineEvent.seq` stays undefined and + * `capabilities.resumableStreaming` is false. + * + * The payload shapes and the frame vocabulary mirror the browser engine in + * `apps/app/src/react-app/domains/session/engine/deepseek-harness-conversation-mapper.ts`, + * which is the reference implementation for this mapping. + */ + +import { resolve } from "node:path"; + +import { DEEPSEEK_HARNESS_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { + DeepSeekHarnessRpcError, + DeepSeekHarnessUnavailableError, +} from "../../deepseek-harness-runtime.js"; +import { ApiError } from "../../errors.js"; +import type { + EngineAdapter, + EngineCapabilities, + EngineConnection, + EngineEvent, + EngineMessage, + EngineMessagePart, + EnginePermission, + EnginePromptInput, + EngineQuestion, + EngineSession, + EngineSubscribeInput, +} from "./types.js"; + +/** + * Structural view of `DeepSeekHarnessRuntime`, so a test can hand in a stub + * without spawning the `dsh` process. + */ +export interface HarnessRuntimeLike { + call(method: string, payload: unknown): Promise; + respond(input: { rpcId: string; result: unknown }): Promise; + events(stream: "mux" | "host", signal: AbortSignal): Promise; +} + +export interface HarnessEngineAdapterDeps { + runtime: HarnessRuntimeLike; + /** Upper bound for the polled `wait()` fallback. Defaults to 10 minutes. */ + waitTimeoutMs?: number; + /** First poll interval for `wait()`; it backs off to 2s. Defaults to 250ms. */ + waitPollIntervalMs?: number; +} + +export const HARNESS_INTERNAL_SYSTEM_PREFIX = "\n\n"; + +const LEGACY_SYSTEM_BLOCK = /^\n[\s\S]*\n<\/system>$/u; +const INTERNAL_SESSION_TITLE = /^|\s)/iu; +const MISSING_CREDENTIAL = /no api key for provider route|missing[_ -]?credential/iu; +const DEFAULT_SESSION_TITLE = "New conversation"; + +/** + * Harness reports a tool's name only on `tool/call`; `tool/result` carries just + * the call id. The stream keeps the pairing so `tool.completed` can name its tool. + */ +export interface HarnessStreamState { + tools: Map; +} + +export function createHarnessStreamState(): HarnessStreamState { + return { tools: new Map() }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/* -------------------------------------------------------------------------- */ +/* Errors */ +/* -------------------------------------------------------------------------- */ + +/** + * Shared error translation, identical to `routes/deepseek-harness.ts` so both + * the RPC passthrough and the engine adapter report the same status and code. + * Returns the original error untouched when it is not a Harness error. + */ +export function mapHarnessError(error: unknown): unknown { + if (error instanceof DeepSeekHarnessUnavailableError) { + return new ApiError(503, error.code, error.message); + } + if (error instanceof DeepSeekHarnessRpcError) { + const status = error.code === "not-found" || error.code === "session-not-found" ? 404 : 502; + return new ApiError(status, `deepseek_harness_${error.code}`, error.message, error.details); + } + return error; +} + +export function throwHarnessError(error: unknown): never { + throw mapHarnessError(error); +} + +function unsupported(operation: string, detail: string): ApiError { + return new ApiError( + 501, + "engine_capability_unsupported", + `DeepSeek Harness does not support ${operation}: ${detail}`, + { engineId: DEEPSEEK_HARNESS_ENGINE_ID, operation }, + ); +} + +export function normalizeHarnessErrorText(value: unknown): string { + const message = typeof value === "string" ? value.trim() : ""; + if (MISSING_CREDENTIAL.test(message)) { + return "DeepSeek Harness has no API key for this model provider. Connect the provider credential first."; + } + return message || "DeepSeek Harness failed to run this turn"; +} + +/* -------------------------------------------------------------------------- */ +/* Pure event mapping */ +/* -------------------------------------------------------------------------- */ + +function parseJson(value: unknown): unknown { + if (typeof value !== "string") return value ?? {}; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeToolInput(toolName: string, value: unknown): unknown { + const parsed = parseJson(value); + if (!isRecord(parsed)) return parsed; + const fieldMappings: Partial>> = { + apply_patch: { patch_text: "patchText" }, + edit: { file_path: "filePath", new_string: "newString", old_string: "oldString", replace_all: "replaceAll" }, + read: { file_path: "filePath" }, + write: { file_path: "filePath" }, + }; + const mappings = fieldMappings[toolName]; + if (!mappings) return parsed; + const normalized = { ...parsed }; + for (const [source, target] of Object.entries(mappings)) { + if (!(target in normalized) && source in normalized) normalized[target] = normalized[source]; + delete normalized[source]; + } + return normalized; +} + +function assistantMessageId(sessionId: string, turn: number, step: number): string { + return `dsh:${sessionId}:assistant:${turn}:${step}`; +} + +function partId(messageId: string, index: number): string { + return `${messageId}:block:${index}`; +} + +function numberField(data: Record | null, key: string): number { + const value = data?.[key]; + return typeof value === "number" ? value : 0; +} + +function visibleUserContent(message: Record): unknown { + const content = message.content; + if (!Array.isArray(content)) return content; + return content.flatMap((block, index) => { + if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") return [block]; + if (index === 0 && content.length > 1 && LEGACY_SYSTEM_BLOCK.test(block.text.trim())) return []; + let text = block.text; + let start = text.indexOf(HARNESS_INTERNAL_SYSTEM_PREFIX); + while (start !== -1) { + const end = text.indexOf("", start + HARNESS_INTERNAL_SYSTEM_PREFIX.length); + text = end === -1 + ? text.slice(0, start) + : `${text.slice(0, start)}${text.slice(end + "".length)}`; + start = text.indexOf(HARNESS_INTERNAL_SYSTEM_PREFIX); + } + return text.trim() ? [{ ...block, text }] : []; + }); +} + +function messageParts(messageId: string, content: unknown): EngineMessagePart[] { + if (!Array.isArray(content)) return []; + return content.flatMap((block, index): EngineMessagePart[] => { + if (!isRecord(block)) return []; + const id = partId(messageId, index); + if ((block.type === "text" || block.type === "reasoning") && typeof block.text === "string") { + return [{ id, type: block.type, text: block.text }]; + } + if (block.type === "tool-call" && typeof block.id === "string" && typeof block.name === "string") { + return [{ + id: block.id, + type: "tool", + callId: block.id, + tool: block.name, + input: normalizeToolInput(block.name, block.arguments), + }]; + } + if (block.type === "image") { + // The raw base64 payload is deliberately dropped: the public API streams + // conversation structure, not attachment bytes. + return [{ + id, + type: "file", + mime: typeof block.mediaType === "string" ? block.mediaType : "application/octet-stream", + ...(typeof block.name === "string" ? { filename: block.name } : {}), + }]; + } + return []; + }); +} + +function engineMessage(sessionId: string, event: Record): EngineMessage | null { + const data = isRecord(event.data) ? event.data : null; + const message = data && isRecord(data.message) + ? data.message + : event.type === "user/message" && data + ? data + : null; + if (!message || typeof message.id !== "string") return null; + if (event.type === "user/message") { + const source = isRecord(message.source) ? message.source : null; + // Plugin-injected turns are runtime context, not conversation content. + if (source && source.kind !== "user") return null; + } + const declaredRole = message.role === "assistant" + ? "assistant" + : message.role === "system" + ? "system" + : "user"; + const createdAt = typeof event.time === "number" ? event.time : null; + if (event.type === "assistant/message" || declaredRole === "assistant") { + const id = event.type === "assistant/message" + ? assistantMessageId(sessionId, numberField(data, "turn"), numberField(data, "step")) + : message.id; + return { + id, + role: "assistant", + parts: messageParts(id, event.type === "user/message" ? visibleUserContent(message) : message.content), + createdAt, + }; + } + return { + id: message.id, + role: declaredRole, + parts: messageParts(message.id, visibleUserContent(message)), + createdAt, + }; +} + +function textOutput(content: unknown): unknown { + if (!Array.isArray(content)) return content; + const text = content.flatMap((block) => { + if (!isRecord(block)) return []; + if ((block.type === "text" || block.type === "reasoning") && typeof block.text === "string") return [block.text]; + return []; + }).join("\n"); + return text || content; +} + +function toolResult(data: Record) { + if (!isRecord(data.message) || !Array.isArray(data.message.content)) return null; + const block = data.message.content.find((item) => isRecord(item) && item.type === "tool-result"); + if (!isRecord(block) || typeof block.toolCallId !== "string") return null; + return { + callId: block.toolCallId, + output: textOutput(block.content), + isError: block.isError === true || isRecord(data.error), + errorText: isRecord(data.error) && typeof data.error.name === "string" ? data.error.name : undefined, + }; +} + +function harnessTodos(sessionId: string, data: Record): unknown[] { + if (!Array.isArray(data.todos)) return []; + return data.todos.flatMap((todo, index) => { + if (!isRecord(todo) || typeof todo.content !== "string" || typeof todo.status !== "string") return []; + return [{ + id: `${sessionId}:${index}:${todo.content}`, + content: todo.content, + status: todo.status, + priority: "medium", + }]; + }); +} + +function harnessPermission( + sessionId: string, + rpcId: string, + frame: Record, +): EnginePermission { + return { + id: String(frame.approvalId), + sessionId, + kind: typeof frame.toolName === "string" ? frame.toolName : "tool", + resources: typeof frame.reason === "string" ? [frame.reason] : [], + // Harness answers one approval at a time; it has no persistent grant scope. + remember: [], + metadata: { + rpcId, + ...(typeof frame.toolName === "string" ? { toolName: frame.toolName } : {}), + ...(typeof frame.callId === "string" ? { callId: frame.callId } : {}), + ...(typeof frame.reason === "string" ? { reason: frame.reason } : {}), + }, + receivedAt: Date.now(), + }; +} + +function harnessQuestion( + sessionId: string, + rpcId: string, + frame: Record, +): EngineQuestion | null { + if (!Array.isArray(frame.questions)) return null; + return { + // The RPC id is the reply address, so it is also the question id. + id: rpcId, + sessionId, + questions: frame.questions.flatMap((item) => { + if (!isRecord(item) || typeof item.question !== "string") return []; + return [{ + ...(typeof item.header === "string" ? { header: item.header } : {}), + question: item.question, + options: Array.isArray(item.options) + ? item.options.flatMap((option) => isRecord(option) && typeof option.label === "string" + ? [{ + label: option.label, + ...(typeof option.description === "string" ? { description: option.description } : {}), + }] + : []) + : [], + multiple: item.multiSelect === true, + custom: true, + }]; + }), + receivedAt: Date.now(), + }; +} + +/** + * Maps one Harness SSE envelope to the normalized engine events. + * + * A single Harness frame can carry more than one engine-level fact (`turn/end` + * both fails and idles a session), so the full-fidelity mapping is the plural + * form; `mapHarnessEvent` is the single-event view required by the adapter + * contract. Both are pure: `state` is supplied by the caller and only records + * the tool-call pairing that Harness itself omits from `tool/result`. + */ +export function mapHarnessEvents( + event: unknown, + sessionId: string, + state?: HarnessStreamState, +): EngineEvent[] { + const envelope = isRecord(event) ? event : null; + const frame = envelope && isRecord(envelope.payload) ? envelope.payload : null; + if (!frame) return []; + const rpcId = envelope && typeof envelope.rpcId === "string" ? envelope.rpcId : ""; + const type = typeof frame.type === "string" ? frame.type : ""; + const frameSessionId = typeof frame.sessionId === "string" ? frame.sessionId : ""; + if (!frameSessionId || frameSessionId !== sessionId) return []; + + // `host/session-status` races ahead of assistant chunks on the other stream; + // turn/start and turn/end are the ordered, authoritative lifecycle boundary. + if (type === "host/session-status") return []; + if (type === "host/session-removed") return [{ type: "session.deleted", sessionId }]; + if (type === "host/agent-error") { + return [{ + type: "session.error", + sessionId, + error: { message: normalizeHarnessErrorText(frame.message) }, + }]; + } + if (type === "approval/requested" && typeof frame.approvalId === "string") { + return [{ type: "permission.asked", permission: harnessPermission(sessionId, rpcId, frame) }]; + } + if (type === "approval/resolved" && typeof frame.approvalId === "string") { + return [{ type: "permission.replied", sessionId, requestId: frame.approvalId }]; + } + if (type === "question/requested") { + const question = harnessQuestion(sessionId, rpcId, frame); + return question ? [{ type: "question.asked", question }] : []; + } + if (type === "question/resolved" && typeof frame.questionRpcId === "string") { + return [{ type: "question.replied", sessionId, requestId: frame.questionRpcId }]; + } + if (type === "session/projection" && frame.key === "title" && typeof frame.value === "string") { + return [{ type: "session.updated", sessionId, session: { id: sessionId, title: frame.value } }]; + } + if (type !== "session/event" || !isRecord(frame.event)) return []; + + const inner = frame.event; + const innerType = typeof inner.type === "string" ? inner.type : ""; + if (innerType === "user/message" || innerType === "assistant/message") { + const message = engineMessage(sessionId, inner); + return message ? [{ type: "message.upsert", sessionId, message }] : []; + } + const data = isRecord(inner.data) ? inner.data : null; + if (!data) return []; + const messageId = assistantMessageId(sessionId, numberField(data, "turn"), numberField(data, "step")); + + if (innerType === "assistant/chunk" && isRecord(data.chunk)) { + const chunk = data.chunk; + if ((chunk.type !== "text-delta" && chunk.type !== "reasoning-delta") || typeof chunk.text !== "string") { + return []; + } + return [{ + type: "message.delta", + sessionId, + messageId, + partId: partId(messageId, typeof chunk.index === "number" ? chunk.index : 0), + kind: chunk.type === "reasoning-delta" ? "reasoning" : "text", + delta: chunk.text, + }]; + } + if (innerType === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") { + const input = normalizeToolInput(data.name, data.arguments); + state?.tools.set(data.callId, { messageId, tool: data.name, input }); + return [{ type: "tool.called", sessionId, messageId, callId: data.callId, tool: data.name, input }]; + } + if (innerType === "tool/result") { + const result = toolResult(data); + if (!result) return []; + const pending = state?.tools.get(result.callId); + state?.tools.delete(result.callId); + return [{ + type: "tool.completed", + sessionId, + messageId: pending?.messageId ?? messageId, + callId: result.callId, + tool: pending?.tool ?? (typeof data.name === "string" ? data.name : "unknown"), + status: result.isError ? "failed" : "success", + output: result.output, + ...(result.isError ? { error: result.errorText ?? "Tool failed" } : {}), + }]; + } + if (innerType === "todo/write") { + return [{ type: "todo.updated", sessionId, todos: harnessTodos(sessionId, data) }]; + } + if (innerType === "session/title" && typeof data.title === "string") { + return [{ type: "session.updated", sessionId, session: { id: sessionId, title: data.title } }]; + } + if (innerType === "turn/start") { + return [{ type: "session.status", sessionId, status: { type: "busy" } }]; + } + if (innerType === "turn/end") { + const reason = isRecord(data.reason) ? data.reason : null; + const error = reason?.kind === "error" && isRecord(reason.error) ? reason.error : null; + return [ + ...(reason?.kind === "error" + ? [{ + type: "session.error" as const, + sessionId, + error: { message: normalizeHarnessErrorText(error?.message) }, + }] + : []), + { type: "session.status", sessionId, status: { type: "idle" } }, + { type: "session.idle", sessionId }, + ]; + } + if (innerType === "compaction/start") return [{ type: "session.compaction", sessionId, running: true }]; + if (innerType === "compaction/end") return [{ type: "session.compaction", sessionId, running: false }]; + return []; +} + +/** + * Single-event view of {@link mapHarnessEvents}: the first engine event a + * Harness envelope produces, or `null` when the envelope is unknown, internal, + * or belongs to another session. `seq` is never set — Harness exposes no + * durable cursor on its live streams. + */ +export function mapHarnessEvent(event: unknown, sessionId: string): EngineEvent | null { + return mapHarnessEvents(event, sessionId)[0] ?? null; +} + +/* -------------------------------------------------------------------------- */ +/* Session read model */ +/* -------------------------------------------------------------------------- */ + +type HarnessSummary = { + sessionId: string; + updatedAt: number; + running: boolean; + blank: boolean; + parentSessionId?: string; + cwd?: string; + agentPreset?: string; + projections?: { asOfSeq: number; values: Record }; +}; + +function summaryTitle(summary: HarnessSummary): string { + const projected = summary.projections?.values.title; + if (typeof projected !== "string" || !projected.trim()) return DEFAULT_SESSION_TITLE; + const title = projected.trim(); + return INTERNAL_SESSION_TITLE.test(title) ? DEFAULT_SESSION_TITLE : title; +} + +function engineSession(summary: HarnessSummary, archived: boolean): EngineSession { + return { + id: summary.sessionId, + title: summaryTitle(summary), + parentId: summary.parentSessionId ?? null, + directory: summary.cwd ?? null, + createdAt: summary.updatedAt, + updatedAt: summary.updatedAt, + archivedAt: archived ? summary.updatedAt : null, + }; +} + +function pathMatches(left: string | undefined, right: string): boolean { + if (!left?.trim()) return false; + const normalizedLeft = resolve(left); + const normalizedRight = resolve(right); + return process.platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function workspacePath(workspace: unknown): string | undefined { + if (!isRecord(workspace)) return undefined; + const path = typeof workspace.path === "string" ? workspace.path : undefined; + return path?.trim() ? path : undefined; +} + +/* -------------------------------------------------------------------------- */ +/* Prompt payload */ +/* -------------------------------------------------------------------------- */ + +function internalPromptText(text: string): string { + if (text.startsWith(HARNESS_INTERNAL_SYSTEM_PREFIX)) return text; + return `${HARNESS_INTERNAL_SYSTEM_PREFIX}${text}\n`; +} + +function textDataUrl(url: string): string | null { + const match = /^data:(text\/[^;,]+)(;base64)?,([\s\S]*)$/u.exec(url); + if (!match?.[1] || match[3] === undefined) return null; + if (!match[2]) return decodeURIComponent(match[3]); + return Buffer.from(match[3], "base64").toString("utf8"); +} + +export function harnessPromptContent(input: EnginePromptInput): unknown[] { + const content: Array> = []; + for (const part of input.parts) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }); + continue; + } + if (part.type === "agent") { + content.push({ type: "text", text: `@${part.name}` }); + continue; + } + const image = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.+)$/u.exec(part.url); + if (image?.[1] && image[2]) { + content.push({ + type: "image", + mediaType: image[1], + data: image[2], + ...(part.filename ? { name: part.filename } : {}), + }); + continue; + } + const text = part.url.startsWith("data:") ? textDataUrl(part.url) : part.url; + if (text === null) { + throw unsupported( + "this attachment type", + "only raster images and text attachments can be sent in a conversation", + ); + } + content.push({ type: "text", text: `[Attached file: ${part.filename || "file"}]\n${text}` }); + } + if (input.system?.trim()) { + content.push({ type: "text", text: internalPromptText(input.system.trim()) }); + } + return content; +} + +/* -------------------------------------------------------------------------- */ +/* SSE */ +/* -------------------------------------------------------------------------- */ + +type HarnessEnvelope = { type: string; rpcId: string; method?: string; payload: Record }; + +/** + * Parses the Harness SSE body. There is no SDK helper for this stream, and the + * runtime also bridges a WebSocket transport into the same `data: ` frame + * shape, so one parser covers both. + */ +export async function* readHarnessEnvelopes(response: Response): AsyncGenerator { + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary !== -1) { + const chunk = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = chunk + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .join(""); + if (data) { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + parsed = null; + } + if (isRecord(parsed) && parsed.type === "server-request" && typeof parsed.rpcId === "string") { + yield { + type: "server-request", + rpcId: parsed.rpcId, + ...(typeof parsed.method === "string" ? { method: parsed.method } : {}), + payload: isRecord(parsed.payload) ? parsed.payload : {}, + }; + } + } + boundary = buffer.indexOf("\n\n"); + } + } + } finally { + reader.releaseLock(); + } +} + +function isAbortError(error: unknown): boolean { + return isRecord(error) && error.name === "AbortError"; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolveSleep, rejectSleep) => { + if (signal?.aborted) { + rejectSleep(new ApiError(499, "client_closed_request", "Wait was aborted")); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolveSleep(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + rejectSleep(new ApiError(499, "client_closed_request", "Wait was aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/* -------------------------------------------------------------------------- */ +/* Adapter */ +/* -------------------------------------------------------------------------- */ + +/** + * Honest capability report. + * + * - `resumableStreaming`: `runtime.events()` accepts only a stream name and an + * abort signal — there is no cursor parameter and no durable sequence on live + * frames, so `?after=` cannot be honoured. + * - `wait`: no RPC in the Harness allowlist blocks until idle, so `wait()` is a + * polled fallback over `session.list`'s `running` flag. + * - `shell` / `commands`: Harness exposes neither direct shell execution nor a + * command catalogue through its conversation RPC surface. + */ +export const HARNESS_CAPABILITIES: EngineCapabilities = { + streaming: true, + resumableStreaming: false, + permissions: true, + questions: true, + interrupt: true, + wait: false, + // A system prompt is carried as an internal `` content block on the prompt + // itself, and the reasoning-effort hint rides on `session.selectModel` alongside the + // variant, so all three are genuinely applied here. + promptOptions: { system: true, reasoningEffort: true, variant: true }, +}; + +export function createHarnessEngineAdapter(deps: HarnessEngineAdapterDeps): EngineAdapter { + const { runtime } = deps; + const waitTimeoutMs = deps.waitTimeoutMs ?? 600_000; + const pollIntervalMs = deps.waitPollIntervalMs ?? 250; + + return { + id: DEEPSEEK_HARNESS_ENGINE_ID, + connect(workspace: unknown): EngineConnection { + return createHarnessConnection({ runtime, waitTimeoutMs, pollIntervalMs, workspace }); + }, + }; +} + +function createHarnessConnection(input: { + runtime: HarnessRuntimeLike; + waitTimeoutMs: number; + pollIntervalMs: number; + workspace: unknown; +}): EngineConnection { + const { runtime } = input; + const directory = workspacePath(input.workspace); + const permissions = new Map(); + const questions = new Map }>(); + const state = createHarnessStreamState(); + + const call = async (method: string, payload: unknown): Promise => { + try { + return await runtime.call(method, payload); + } catch (error) { + throw mapHarnessError(error); + } + }; + + const respond = async (rpcId: string, result: unknown): Promise => { + try { + await runtime.respond({ rpcId, result }); + } catch (error) { + throw mapHarnessError(error); + } + }; + + const readSummary = async (sessionId: string): Promise<{ summary: HarnessSummary; archived: boolean }> => { + const [sessions, workspaces] = await Promise.all([ + call<{ items: HarnessSummary[] }>("session.list", {}), + call<{ archivedSessionIds: string[] }>("workspace.list", {}), + ]); + // A runtime that answers without an `items` array means "no sessions", not a crash: + // a TypeError here would surface as a 500 on what is really a plain lookup miss. + const items = Array.isArray(sessions?.items) ? sessions.items : []; + const summary = items.find((item) => item.sessionId === sessionId); + // A workspace-scoped connection must not leak sessions from another cwd. + if (!summary || (directory && !pathMatches(summary.cwd, directory))) { + throw new ApiError(404, "session_not_found", "Session not found"); + } + const archivedIds = Array.isArray(workspaces?.archivedSessionIds) ? workspaces.archivedSessionIds : []; + return { summary, archived: archivedIds.includes(sessionId) }; + }; + + /** + * Confirms a session belongs to this connection's workspace before acting on it. + * + * The Harness runtime is one process shared by every workspace, and `session.list` + * returns all of them, so a session id is not self-scoping the way it is with OpenCode's + * per-directory client. Without this check, `POST /workspaces/wA/sessions/{id_from_wB}/prompt` + * would run in workspace B — against B's files, with B's agent — while the caller only ever + * proved access to A. Reads already went through `readSummary`; writes have to as well. + */ + const assertSessionInWorkspace = async (sessionId: string): Promise => { + if (!directory) return; + await readSummary(sessionId); + }; + + return { + engineId: DEEPSEEK_HARNESS_ENGINE_ID, + capabilities: HARNESS_CAPABILITIES, + + async createSession(request) { + const created = await call<{ sessionId: string; agentPreset?: string }>("session.create", { + ...(directory ? { cwd: directory } : {}), + }); + if (request.agent) { + await call("agentPreset.select", { sessionId: created.sessionId, agentPreset: request.agent }); + } + if (request.model) { + await call("session.selectModel", { + sessionId: created.sessionId, + provider: request.model.providerID, + model: request.model.modelID, + }); + } + if (request.title?.trim()) { + await call("session.rename", { sessionId: created.sessionId, title: request.title.trim() }); + } + const now = Date.now(); + return { + id: created.sessionId, + title: request.title?.trim() || DEFAULT_SESSION_TITLE, + parentId: null, + directory: directory ?? null, + createdAt: now, + updatedAt: now, + archivedAt: null, + }; + }, + + async getSession(sessionId) { + const { summary, archived } = await readSummary(sessionId); + return engineSession(summary, archived); + }, + + async deleteSession(sessionId) { + void sessionId; + throw unsupported( + "deleteSession", + "its RPC surface offers only workspace.archiveSession, which hides a session without deleting it", + ); + }, + + async renameSession(sessionId, title) { + await assertSessionInWorkspace(sessionId); + await call("session.rename", { sessionId, title }); + }, + + async prompt(request) { + await assertSessionInWorkspace(request.sessionId); + if (request.delivery === "steer") { + throw unsupported("steering an in-flight turn", "session.prompt only accepts queued delivery"); + } + if (request.agent) { + await call("agentPreset.select", { sessionId: request.sessionId, agentPreset: request.agent }); + } + if (request.model) { + const reasoningEffort = request.reasoningEffort || request.variant; + await call("session.selectModel", { + sessionId: request.sessionId, + provider: request.model.providerID, + model: request.model.modelID, + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + } + await call("session.prompt", { + sessionId: request.sessionId, + mode: "queue", + content: harnessPromptContent(request), + }); + // Harness assigns the assistant message id itself and reports it only on + // the event stream, so there is no id to return here. + return {}; + }, + + async interrupt(sessionId) { + await assertSessionInWorkspace(sessionId); + await call("session.cancel", { sessionId }); + return true; + }, + + /** + * Polled fallback: Harness has no blocking "run until idle" RPC, so this + * watches the `running` flag `session.list` reports, backing off to 2s. + */ + async wait(sessionId, signal) { + const deadline = Date.now() + input.waitTimeoutMs; + let delay = input.pollIntervalMs; + for (;;) { + await sleep(delay, signal); + const { summary } = await readSummary(sessionId); + if (!summary.running) return; + if (Date.now() >= deadline) { + throw new ApiError(504, "engine_wait_timeout", "DeepSeek Harness session is still running", { + engineId: DEEPSEEK_HARNESS_ENGINE_ID, + sessionId, + }); + } + delay = Math.min(delay * 2, 2_000); + } + }, + + /** + * Only permissions seen on this connection's live stream are listed: + * Harness has no "list pending approvals" RPC. + */ + async listPermissions(sessionId) { + return [...permissions.values()].filter((permission) => permission.sessionId === sessionId); + }, + + async replyPermission(request) { + if (request.reply === "always") { + throw unsupported( + "remembering a permission answer", + "an approval is answered once and never persisted, so reply must be \"once\" or \"reject\"", + ); + } + const permission = permissions.get(request.permissionId); + const rpcId = permission && typeof permission.metadata.rpcId === "string" ? permission.metadata.rpcId : null; + if (!permission || !rpcId) { + throw new ApiError(404, "engine_permission_not_found", "DeepSeek Harness approval is no longer pending", { + engineId: DEEPSEEK_HARNESS_ENGINE_ID, + permissionId: request.permissionId, + }); + } + await respond(rpcId, { + ok: true, + value: { + sessionId: permission.sessionId, + approvalId: permission.id, + outcome: request.reply === "reject" ? "rejected" : "allowed-once", + }, + }); + permissions.delete(request.permissionId); + }, + + async listQuestions(sessionId) { + return [...questions.values()] + .map((entry) => entry.question) + .filter((question) => question.sessionId === sessionId); + }, + + async replyQuestion(request) { + const pending = questions.get(request.questionId); + if (!pending || !Array.isArray(pending.frame.questions)) { + throw new ApiError(404, "engine_question_not_found", "DeepSeek Harness question is no longer pending", { + engineId: DEEPSEEK_HARNESS_ENGINE_ID, + questionId: request.questionId, + }); + } + const answers = pending.frame.questions.flatMap((question, index) => { + if (!isRecord(question) || typeof question.id !== "string") return []; + const selected = request.answers[index] ?? []; + const optionLabels = new Set( + Array.isArray(question.options) + ? question.options.flatMap((option) => isRecord(option) && typeof option.label === "string" + ? [option.label] + : []) + : [], + ); + const custom = selected.find((value) => !optionLabels.has(value)); + return [{ + id: question.id, + selected: selected.filter((value) => optionLabels.has(value)), + ...(custom ? { custom } : {}), + }]; + }); + await respond(request.questionId, { + ok: true, + value: { sessionId: pending.question.sessionId, answer: { answers } }, + }); + questions.delete(request.questionId); + }, + + /** + * Harness multiplexes every session onto `mux` (conversation frames, + * approvals, questions) and `host` (session lifecycle and agent errors), so + * both are consumed and filtered down to one session id. + */ + async subscribe(subscription: EngineSubscribeInput) { + if (subscription.after) { + throw unsupported( + "resuming an event stream", + "its live streams carry no durable cursor, so `after` cannot be replayed", + ); + } + const consume = async (stream: "mux" | "host") => { + let response: Response; + try { + response = await runtime.events(stream, subscription.signal); + } catch (error) { + if (subscription.signal.aborted || isAbortError(error)) return; + throw mapHarnessError(error); + } + try { + for await (const envelope of readHarnessEnvelopes(response)) { + for (const event of mapHarnessEvents(envelope, subscription.sessionId, state)) { + if (event.type === "permission.asked") permissions.set(event.permission.id, event.permission); + if (event.type === "permission.replied") permissions.delete(event.requestId); + if (event.type === "question.asked") { + questions.set(event.question.id, { question: event.question, frame: envelope.payload }); + } + if (event.type === "question.replied") questions.delete(event.requestId); + subscription.onEvent(event); + } + } + } catch (error) { + if (subscription.signal.aborted || isAbortError(error)) return; + throw mapHarnessError(error); + } + }; + await Promise.all([consume("mux"), consume("host")]); + }, + }; +} diff --git a/apps/server/src/api/engine/opencode.test.ts b/apps/server/src/api/engine/opencode.test.ts new file mode 100644 index 000000000..cd2839aeb --- /dev/null +++ b/apps/server/src/api/engine/opencode.test.ts @@ -0,0 +1,628 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildOpencodePromptInput, + createOpencodeEngineAdapter, + describeOpencodeError, + mapOpencodeEvent, + mapOpencodePermission, + mapOpencodeSession, + opencodePermissionKind, + OPENCODE_ENGINE_CAPABILITIES, +} from "./opencode.js"; + +function envelope(type: string, data: unknown, seq?: number) { + return { + id: `evt_${type}`, + type, + ...(seq === undefined ? {} : { durable: { aggregateID: "ses_1", seq, version: 1 } }), + data, + }; +} + +describe("mapOpencodeEvent", () => { + test("maps session.next.text.delta to a text message delta", () => { + const event = mapOpencodeEvent(envelope("session.next.text.delta", { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + textID: "prt_1", + delta: "Hello", + })); + + expect(event).toEqual({ + type: "message.delta", + sessionId: "ses_1", + messageId: "msg_1", + partId: "prt_1", + kind: "text", + delta: "Hello", + }); + }); + + test("maps session.next.reasoning.delta to a reasoning message delta", () => { + const event = mapOpencodeEvent(envelope("session.next.reasoning.delta", { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + reasoningID: "prt_r1", + delta: "thinking", + })); + + expect(event).toMatchObject({ type: "message.delta", kind: "reasoning", partId: "prt_r1", delta: "thinking" }); + }); + + test("maps session.next.tool.called", () => { + const event = mapOpencodeEvent(envelope("session.next.tool.called", { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_1", + tool: "bash", + input: { command: "ls" }, + provider: { executed: true }, + })); + + expect(event).toEqual({ + type: "tool.called", + sessionId: "ses_1", + messageId: "msg_1", + callId: "call_1", + tool: "bash", + input: { command: "ls" }, + }); + }); + + test("maps session.next.tool.success, leaving the tool name blank for the caller to fill", () => { + const event = mapOpencodeEvent(envelope("session.next.tool.success", { + timestamp: 2, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_1", + structured: { exit: 0 }, + content: [], + result: "README.md", + provider: { executed: true }, + })); + + expect(event).toEqual({ + type: "tool.completed", + sessionId: "ses_1", + messageId: "msg_1", + callId: "call_1", + tool: "", + status: "success", + output: "README.md", + }); + }); + + test("falls back to the structured payload when a tool success carries no result", () => { + const event = mapOpencodeEvent(envelope("session.next.tool.success", { + timestamp: 2, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_2", + structured: { exit: 0 }, + content: [], + provider: { executed: true }, + })); + + expect(event).toMatchObject({ type: "tool.completed", status: "success", output: { exit: 0 } }); + }); + + test("maps session.next.tool.failed with a flattened error message", () => { + const event = mapOpencodeEvent(envelope("session.next.tool.failed", { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_1", + error: { type: "unknown", message: "command not found" }, + provider: { executed: false }, + })); + + expect(event).toEqual({ + type: "tool.completed", + sessionId: "ses_1", + messageId: "msg_1", + callId: "call_1", + tool: "", + status: "failed", + error: "command not found", + }); + }); + + test("maps permission.v2.asked", () => { + const event = mapOpencodeEvent(envelope("permission.v2.asked", { + id: "per_1", + sessionID: "ses_1", + action: "file.edit", + resources: ["/repo/src/index.ts"], + save: ["session"], + metadata: { reason: "write" }, + source: { type: "tool", messageID: "msg_1", callID: "call_1" }, + })); + + expect(event?.type).toBe("permission.asked"); + if (event?.type !== "permission.asked") throw new Error("unexpected event"); + expect(event.permission.id).toBe("per_1"); + expect(event.permission.sessionId).toBe("ses_1"); + expect(event.permission.kind).toBe("edit"); + expect(event.permission.resources).toEqual(["/repo/src/index.ts"]); + expect(event.permission.remember).toEqual(["session"]); + expect(event.permission.metadata).toMatchObject({ + reason: "write", + action: "file.edit", + save: "session", + tool: { messageID: "msg_1", callID: "call_1" }, + }); + expect(typeof event.permission.receivedAt).toBe("number"); + }); + + test("maps permission.v2.replied", () => { + expect(mapOpencodeEvent(envelope("permission.v2.replied", { + sessionID: "ses_1", + requestID: "per_1", + reply: "once", + }))).toEqual({ type: "permission.replied", sessionId: "ses_1", requestId: "per_1" }); + }); + + test("maps question.v2.asked", () => { + const event = mapOpencodeEvent(envelope("question.v2.asked", { + id: "qst_1", + sessionID: "ses_1", + questions: [ + { + question: "Which branch should I target?", + header: "Branch", + options: [ + { label: "main", description: "the default branch" }, + { label: "develop", description: "the integration branch" }, + ], + multiple: false, + custom: true, + }, + ], + tool: { messageID: "msg_1", callID: "call_1" }, + })); + + expect(event).toEqual({ + type: "question.asked", + question: { + id: "qst_1", + sessionId: "ses_1", + questions: [ + { + header: "Branch", + question: "Which branch should I target?", + options: [ + { label: "main", description: "the default branch" }, + { label: "develop", description: "the integration branch" }, + ], + multiple: false, + custom: true, + }, + ], + // receivedAt is wall-clock; asserted separately below. + receivedAt: (event as { question: { receivedAt: number } }).question.receivedAt, + }, + }); + if (event?.type !== "question.asked") throw new Error("unexpected event"); + expect(typeof event.question.receivedAt).toBe("number"); + }); + + test("maps session.idle", () => { + expect(mapOpencodeEvent(envelope("session.idle", { sessionID: "ses_1" }))) + .toEqual({ type: "session.idle", sessionId: "ses_1" }); + }); + + test("maps session.error, flattening the named v2 error envelope", () => { + expect(mapOpencodeEvent(envelope("session.error", { + sessionID: "ses_1", + error: { name: "ContextOverflowError", data: { message: "context window exceeded" } }, + }))).toEqual({ + type: "session.error", + sessionId: "ses_1", + error: { code: "ContextOverflowError", message: "context window exceeded" }, + }); + }); + + test("maps message.part.updated, carrying the raw part through", () => { + const part = { + id: "prt_1", + sessionID: "ses_1", + messageID: "msg_1", + type: "text", + text: "hello world", + }; + expect(mapOpencodeEvent(envelope("message.part.updated", { sessionID: "ses_1", part, time: 5 }))) + .toEqual({ + type: "message.part", + sessionId: "ses_1", + messageId: "msg_1", + partId: "prt_1", + part, + }); + }); + + test("maps message.updated to a message upsert", () => { + expect(mapOpencodeEvent(envelope("message.updated", { + sessionID: "ses_1", + info: { id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 10, completed: 20 } }, + }))).toEqual({ + type: "message.upsert", + sessionId: "ses_1", + message: { id: "msg_1", role: "assistant", parts: [], createdAt: 10, completedAt: 20 }, + }); + }); + + test("maps session.status, including the retry variant", () => { + expect(mapOpencodeEvent(envelope("session.status", { + sessionID: "ses_1", + status: { type: "retry", attempt: 2, message: "rate limited", next: 1500 }, + }))).toEqual({ + type: "session.status", + sessionId: "ses_1", + status: { type: "retry", attempt: 2, message: "rate limited", next: 1500 }, + }); + }); + + test("maps compaction start and end onto a single running flag", () => { + expect(mapOpencodeEvent(envelope("session.next.compaction.started", { timestamp: 1, sessionID: "ses_1" }))) + .toEqual({ type: "session.compaction", sessionId: "ses_1", running: true }); + expect(mapOpencodeEvent(envelope("session.compacted", { sessionID: "ses_1" }))) + .toEqual({ type: "session.compaction", sessionId: "ses_1", running: false }); + }); + + test("ignores unknown event types", () => { + expect(mapOpencodeEvent(envelope("pty.created", { id: "pty_1" }))).toBeNull(); + expect(mapOpencodeEvent(envelope("session.next.tool.input.delta", { sessionID: "ses_1" }))).toBeNull(); + expect(mapOpencodeEvent({ type: "not.a.real.event" })).toBeNull(); + expect(mapOpencodeEvent(null)).toBeNull(); + expect(mapOpencodeEvent("session.idle")).toBeNull(); + expect(mapOpencodeEvent({ data: { sessionID: "ses_1" } })).toBeNull(); + }); + + test("drops events whose required identifiers are missing", () => { + expect(mapOpencodeEvent(envelope("session.idle", {}))).toBeNull(); + expect(mapOpencodeEvent(envelope("session.next.text.delta", { + sessionID: "ses_1", + assistantMessageID: "msg_1", + textID: "prt_1", + delta: "", + }))).toBeNull(); + expect(mapOpencodeEvent(envelope("message.part.updated", { sessionID: "ses_1", part: { type: "text" } }))).toBeNull(); + }); + + test("propagates durable.seq as a string cursor", () => { + expect(mapOpencodeEvent(envelope("session.idle", { sessionID: "ses_1" }, 42))) + .toEqual({ type: "session.idle", sessionId: "ses_1", seq: "42" }); + + expect(mapOpencodeEvent(envelope("session.next.text.delta", { + sessionID: "ses_1", + assistantMessageID: "msg_1", + textID: "prt_1", + delta: "x", + }, 0))).toMatchObject({ seq: "0" }); + + // No durable block on transient events: no cursor is invented. + expect(mapOpencodeEvent(envelope("session.idle", { sessionID: "ses_1" }))).not.toHaveProperty("seq"); + expect(mapOpencodeEvent({ type: "session.idle", durable: { aggregateID: "a" }, data: { sessionID: "ses_1" } })) + .not.toHaveProperty("seq"); + }); + + test("accepts the classic `properties` envelope as well as the v2 `data` envelope", () => { + expect(mapOpencodeEvent({ id: "evt", type: "session.idle", properties: { sessionID: "ses_1" } })) + .toEqual({ type: "session.idle", sessionId: "ses_1" }); + + const legacy = mapOpencodeEvent({ + id: "evt", + type: "permission.asked", + properties: { + id: "per_1", + sessionID: "ses_1", + permission: "bash", + patterns: ["rm *"], + metadata: {}, + always: ["session"], + }, + }); + expect(legacy?.type).toBe("permission.asked"); + if (legacy?.type !== "permission.asked") throw new Error("unexpected event"); + expect(legacy.permission.kind).toBe("bash"); + expect(legacy.permission.resources).toEqual(["rm *"]); + expect(legacy.permission.remember).toEqual(["session"]); + }); +}); + +describe("describeOpencodeError", () => { + test("reads every error envelope shape OpenCode emits", () => { + expect(describeOpencodeError({ type: "unknown", message: "boom" })).toEqual({ code: "unknown", message: "boom" }); + expect(describeOpencodeError({ _tag: "ConflictError", message: "conflict" })) + .toEqual({ code: "ConflictError", message: "conflict" }); + expect(describeOpencodeError({ name: "APIError", data: { message: "429" } })) + .toEqual({ code: "APIError", message: "429" }); + expect(describeOpencodeError("plain")).toEqual({ message: "plain" }); + expect(describeOpencodeError(undefined).message.length).toBeGreaterThan(0); + }); +}); + +describe("opencodePermissionKind", () => { + test("normalizes the v2 action vocabulary", () => { + expect(opencodePermissionKind("file.read")).toBe("read"); + expect(opencodePermissionKind("file.edit")).toBe("edit"); + expect(opencodePermissionKind("file.write")).toBe("edit"); + expect(opencodePermissionKind("workspace.external_directory")).toBe("external_directory"); + expect(opencodePermissionKind("bash")).toBe("bash"); + }); +}); + +describe("mapOpencodeSession", () => { + test("maps a v2 SessionV2Info", () => { + expect(mapOpencodeSession({ + id: "ses_1", + projectID: "prj_1", + parentID: "ses_0", + cost: 0, + tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2, archived: 3 }, + title: "Fix the parser", + location: { directory: "/repo" }, + })).toEqual({ + id: "ses_1", + title: "Fix the parser", + parentId: "ses_0", + directory: "/repo", + createdAt: 1, + updatedAt: 2, + archivedAt: 3, + }); + }); + + test("rejects a payload without an id", () => { + expect(mapOpencodeSession({ title: "orphan" })).toBeNull(); + expect(mapOpencodeSession(null)).toBeNull(); + }); +}); + +describe("mapOpencodePermission", () => { + test("keeps the raw metadata alongside the action", () => { + const permission = mapOpencodePermission({ + id: "per_1", + sessionID: "ses_1", + action: "file.read", + resources: ["/repo/a.ts"], + }, 1234); + expect(permission).toEqual({ + id: "per_1", + sessionId: "ses_1", + kind: "read", + resources: ["/repo/a.ts"], + remember: [], + metadata: { action: "file.read" }, + receivedAt: 1234, + }); + }); +}); + +describe("buildOpencodePromptInput", () => { + test("splits engine prompt parts into text, files and agents", () => { + expect(buildOpencodePromptInput([ + { type: "text", text: "first" }, + { type: "file", mime: "text/plain", url: "file:///repo/a.txt", filename: "a.txt" }, + { type: "agent", name: "reviewer" }, + { type: "text", text: "second" }, + { type: "file", mime: "image/png", url: "file:///repo/b.png" }, + ])).toEqual({ + text: "first\n\nsecond", + files: [{ uri: "file:///repo/a.txt", name: "a.txt" }, { uri: "file:///repo/b.png" }], + agents: [{ name: "reviewer" }], + }); + }); + + test("omits empty collections and empty text parts", () => { + expect(buildOpencodePromptInput([{ type: "text", text: "" }, { type: "text", text: "only" }])) + .toEqual({ text: "only" }); + expect(buildOpencodePromptInput([])).toEqual({ text: "" }); + }); +}); + +describe("createOpencodeEngineAdapter", () => { + function stubClient() { + const calls: Array<{ method: string; params: unknown }> = []; + const ok = (method: string, data: unknown) => (params: unknown) => { + calls.push({ method, params }); + return Promise.resolve({ data, error: undefined, response: new Response(null) }); + }; + const client = { + session: { + delete: ok("session.delete", true), + update: ok("session.update", { id: "ses_1", title: "renamed" }), + }, + v2: { + session: { + create: ok("v2.session.create", { data: { id: "ses_1", title: "", time: { created: 1, updated: 1 } } }), + get: ok("v2.session.get", { data: { id: "ses_1", title: "T", time: { created: 1, updated: 2 } } }), + prompt: ok("v2.session.prompt", { data: { id: "inp_1", sessionID: "ses_1" } }), + switchAgent: ok("v2.session.switchAgent", undefined), + switchModel: ok("v2.session.switchModel", undefined), + interrupt: ok("v2.session.interrupt", undefined), + wait: ok("v2.session.wait", undefined), + permission: { + list: ok("v2.session.permission.list", { + data: [{ id: "per_1", sessionID: "ses_1", action: "file.read", resources: ["/a"] }], + }), + reply: ok("v2.session.permission.reply", undefined), + }, + question: { + list: ok("v2.session.question.list", { + data: [{ id: "qst_1", sessionID: "ses_1", questions: [{ question: "q?", header: "h", options: [] }] }], + }), + reply: ok("v2.session.question.reply", undefined), + }, + events: (params: unknown) => { + calls.push({ method: "v2.session.events", params }); + return Promise.resolve({ + stream: (async function* () { + yield envelope("session.next.tool.called", { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_1", + tool: "bash", + input: {}, + }, 1); + yield envelope("pty.created", { id: "pty_1" }, 2); + yield envelope("session.next.tool.success", { + timestamp: 2, + sessionID: "ses_1", + assistantMessageID: "msg_1", + callID: "call_1", + structured: {}, + content: [], + }, 3); + yield envelope("session.idle", { sessionID: "ses_1" }, 4); + })(), + }); + }, + }, + }, + }; + return { client, calls }; + } + + function connect(stub: ReturnType) { + const adapter = createOpencodeEngineAdapter({ + createClient: () => stub.client as never, + unwrap: ((result: { data?: unknown; error?: unknown }) => { + if (result.error !== undefined) throw new Error("opencode_request_failed"); + return result.data; + }) as never, + }); + expect(adapter.id).toBe("opencode"); + return adapter.connect({ id: "ws_1" }); + } + + test("advertises the full OpenCode capability set", () => { + const connection = connect(stubClient()); + expect(connection.engineId).toBe("opencode"); + expect(connection.capabilities).toEqual(OPENCODE_ENGINE_CAPABILITIES); + expect(OPENCODE_ENGINE_CAPABILITIES).toEqual({ + streaming: true, + resumableStreaming: true, + permissions: true, + questions: true, + interrupt: true, + wait: true, + promptOptions: { system: false, reasoningEffort: false, variant: true }, + }); + }); + + test("names a new session through the classic update endpoint", async () => { + const stub = stubClient(); + const session = await connect(stub).createSession({ title: "renamed", agent: "build" }); + expect(session).toMatchObject({ id: "ses_1", title: "renamed" }); + expect(stub.calls.map((call) => call.method)).toEqual(["v2.session.create", "session.update"]); + expect(stub.calls[1]?.params).toEqual({ sessionID: "ses_1", title: "renamed" }); + }); + + test("switches agent and model before sending the prompt and returns the admitted id", async () => { + const stub = stubClient(); + const result = await connect(stub).prompt({ + sessionId: "ses_1", + parts: [{ type: "text", text: "hi" }], + agent: "plan", + model: { providerID: "anthropic", modelID: "claude-opus-5" }, + variant: "thinking", + delivery: "queue", + }); + + expect(result).toEqual({ messageId: "inp_1" }); + expect(stub.calls.map((call) => call.method)) + .toEqual(["v2.session.switchAgent", "v2.session.switchModel", "v2.session.prompt"]); + expect(stub.calls[1]?.params).toEqual({ + sessionID: "ses_1", + model: { id: "claude-opus-5", providerID: "anthropic", variant: "thinking" }, + }); + expect(stub.calls[2]?.params).toEqual({ + sessionID: "ses_1", + prompt: { text: "hi" }, + delivery: "queue", + }); + }); + + test("maps pending permissions and questions", async () => { + const connection = connect(stubClient()); + const permissions = await connection.listPermissions("ses_1"); + expect(permissions).toHaveLength(1); + expect(permissions[0]).toMatchObject({ id: "per_1", kind: "read", resources: ["/a"] }); + + const questions = await connection.listQuestions("ses_1"); + expect(questions).toHaveLength(1); + expect(questions[0]?.questions[0]).toMatchObject({ question: "q?", header: "h", options: [] }); + }); + + test("sends question answers in the v2 body shape", async () => { + const stub = stubClient(); + await connect(stub).replyQuestion({ sessionId: "ses_1", questionId: "qst_1", answers: [["main"]] }); + expect(stub.calls[0]).toEqual({ + method: "v2.session.question.reply", + params: { sessionID: "ses_1", requestID: "qst_1", questionV2Reply: { answers: [["main"]] } }, + }); + }); + + test("subscribes with the resume cursor and restores the tool name on completion", async () => { + const stub = stubClient(); + const controller = new AbortController(); + const events: Array<{ type: string; seq?: string; tool?: string }> = []; + + await connect(stub).subscribe({ + sessionId: "ses_1", + after: "7", + signal: controller.signal, + onEvent: (event) => { + events.push({ + type: event.type, + seq: event.seq, + ...("tool" in event ? { tool: event.tool } : {}), + }); + }, + }); + + expect(stub.calls[0]?.params).toEqual({ sessionID: "ses_1", after: "7" }); + expect(events).toEqual([ + { type: "tool.called", seq: "1", tool: "bash" }, + { type: "tool.completed", seq: "3", tool: "bash" }, + { type: "session.idle", seq: "4" }, + ]); + }); + + test("stops delivering events once the signal aborts", async () => { + const stub = stubClient(); + const controller = new AbortController(); + const seen: string[] = []; + + await connect(stub).subscribe({ + sessionId: "ses_1", + signal: controller.signal, + onEvent: (event) => { + seen.push(event.type); + controller.abort(); + }, + }); + + expect(seen).toEqual(["tool.called"]); + }); + + test("surfaces a 204 interrupt as success", async () => { + const stub = stubClient(); + expect(await connect(stub).interrupt("ses_1")).toBe(true); + expect(stub.calls[0]?.method).toBe("v2.session.interrupt"); + }); + + test("wraps SDK failures through the injected unwrap", async () => { + const stub = stubClient(); + stub.client.v2.session.get = (() => + Promise.resolve({ data: undefined, error: { message: "nope" }, response: new Response(null) })) as never; + await expect(connect(stub).getSession("ses_1")).rejects.toThrow("opencode_request_failed"); + }); +}); diff --git a/apps/server/src/api/engine/opencode.ts b/apps/server/src/api/engine/opencode.ts new file mode 100644 index 000000000..6b5989ae3 --- /dev/null +++ b/apps/server/src/api/engine/opencode.ts @@ -0,0 +1,644 @@ +/** + * OpenCode engine adapter. + * + * Maps the OpenCode **v2** SDK surface onto the server-side `EngineConnection` + * contract in `./types.ts`. Everything session-scoped goes through + * `client.v2.session.*`; the two operations v2 does not expose (permanent delete and + * title update) fall back to the classic `client.session.*` endpoints. + * + * The event translation lives in the pure `mapOpencodeEvent` below so it can be tested + * without a live OpenCode process, mirroring how the browser adapter splits + * `opencode-conversation-engine.ts` from `opencode-conversation-mapper.ts`. + */ + +import type { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import type { WorkspaceInfo } from "../../types.js"; +import type { + EngineAdapter, + EngineCapabilities, + EngineConnection, + EngineEvent, + EngineMessage, + EngineMessagePart, + EnginePermission, + EnginePromptInput, + EngineQuestion, + EngineQuestionInfo, + EngineSession, + EngineSessionStatus, + EngineSubscribeInput, +} from "./types.js"; + +export type OpencodeEngineClient = ReturnType; + +type OpencodeClientResult = + | { data: T | undefined; error: undefined; response: Response } + | { data: undefined; error: E; response: Response }; + +export type UnwrapOpencodeResult = (result: OpencodeClientResult, path: string) => NonNullable; + +export interface OpencodeEngineAdapterDeps { + /** Per-workspace client factory. The server injects `createWorkspaceOpencodeClient`. */ + createClient: (workspace: WorkspaceInfo) => OpencodeEngineClient; + /** The server's `unwrapOpencodeResult`, which turns SDK failures into `ApiError`. */ + unwrap: UnwrapOpencodeResult; +} + +export const OPENCODE_ENGINE_CAPABILITIES: EngineCapabilities = { + streaming: true, + resumableStreaming: true, + permissions: true, + questions: true, + interrupt: true, + wait: true, + // `client.v2.session.prompt` takes only `{sessionID, id?, prompt, delivery?, resume?}`, + // and `PromptInput` is `{text, files?, agents?}` — there is nowhere to put a per-turn + // system prompt or a reasoning-effort hint. The classic `session.promptAsync` accepts + // both, but using it would give up the durable event cursor that makes `?after=` + // resumption work, which is the more valuable property for a public API. + // `variant` survives because it rides along on `switchModel`. + promptOptions: { system: false, reasoningEffort: false, variant: true }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +/** The v2 envelope carries `data`; the classic envelope carries `properties`. */ +function readEventPayload(event: Record): Record { + if (isRecord(event.data)) return event.data; + if (isRecord(event.properties)) return event.properties; + return {}; +} + +function readDurableSeq(event: Record): string | undefined { + const durable = isRecord(event.durable) ? event.durable : undefined; + if (!durable) return undefined; + const seq = readNumber(durable.seq); + return seq === undefined ? undefined : String(seq); +} + +/** + * Flattens the several error envelopes OpenCode uses: the v2 named error + * (`{name, data:{message}}`), the effect-tagged error (`{_tag, message}`) and the + * inline session error (`{type:"unknown", message}`). + */ +export function describeOpencodeError(value: unknown): { code?: string; message: string } { + if (typeof value === "string" && value.length > 0) return { message: value }; + if (!isRecord(value)) return { message: "OpenCode reported an unknown error" }; + + const code = readString(value.name) ?? readString(value._tag) ?? readString(value.type); + const inner = isRecord(value.data) ? value.data : undefined; + const message = readString(value.message) + ?? readString(inner?.message) + ?? code + ?? "OpenCode reported an unknown error"; + return code ? { code, message } : { message }; +} + +/** Normalizes a v2 permission action into the browser adapter's permission kinds. */ +export function opencodePermissionKind(action: string): string { + if (action === "external_directory" || action.endsWith(".external_directory")) return "external_directory"; + if (action === "file.read") return "read"; + if (action === "file.edit" || action === "file.write") return "edit"; + return action; +} + +function mapSessionStatus(value: unknown): EngineSessionStatus { + if (!isRecord(value)) return { type: "idle" }; + if (value.type === "retry") { + return { + type: "retry", + attempt: readNumber(value.attempt) ?? 0, + message: readString(value.message) ?? "", + next: readNumber(value.next) ?? 0, + }; + } + return value.type === "idle" ? { type: "idle" } : { type: "busy" }; +} + +/** Maps either `SessionV2Info` (v2) or the classic `Session` onto `EngineSession`. */ +export function mapOpencodeSession(value: unknown): EngineSession | null { + if (!isRecord(value)) return null; + const id = readString(value.id); + if (!id) return null; + const time = isRecord(value.time) ? value.time : undefined; + const location = isRecord(value.location) ? value.location : undefined; + return { + id, + title: typeof value.title === "string" ? value.title : "", + parentId: readString(value.parentID) ?? null, + directory: readString(value.directory) ?? readString(location?.directory) ?? null, + createdAt: readNumber(time?.created) ?? null, + updatedAt: readNumber(time?.updated) ?? null, + archivedAt: readNumber(time?.archived) ?? null, + }; +} + +/** Maps a v2 `PermissionV2Request` or a classic `PermissionRequest`. */ +export function mapOpencodePermission(value: unknown, receivedAt: number): EnginePermission | null { + if (!isRecord(value)) return null; + const id = readString(value.id); + const sessionId = readString(value.sessionID); + if (!id || !sessionId) return null; + + const action = readString(value.action); + if (action !== undefined) { + const metadata: Record = { ...(isRecord(value.metadata) ? value.metadata : {}), action }; + const save = readStringArray(value.save); + if (save.length > 0) metadata.save = save.join(", "); + if (isRecord(value.source)) { + metadata.tool = { messageID: value.source.messageID, callID: value.source.callID }; + } + return { + id, + sessionId, + kind: opencodePermissionKind(action), + resources: readStringArray(value.resources), + remember: save, + metadata, + receivedAt, + }; + } + + return { + id, + sessionId, + kind: readString(value.permission) ?? "unknown", + resources: readStringArray(value.patterns), + remember: readStringArray(value.always), + metadata: isRecord(value.metadata) ? value.metadata : {}, + receivedAt, + }; +} + +/** Maps a v2 `QuestionV2Request` or a classic `QuestionRequest`. */ +export function mapOpencodeQuestion(value: unknown, receivedAt: number): EngineQuestion | null { + if (!isRecord(value)) return null; + const id = readString(value.id); + const sessionId = readString(value.sessionID); + if (!id || !sessionId || !Array.isArray(value.questions)) return null; + + const questions: EngineQuestionInfo[] = []; + for (const entry of value.questions) { + if (!isRecord(entry)) continue; + const question = typeof entry.question === "string" ? entry.question : ""; + const options = Array.isArray(entry.options) + ? entry.options + .filter(isRecord) + .map((option) => ({ + label: typeof option.label === "string" ? option.label : "", + ...(readString(option.description) ? { description: option.description as string } : {}), + })) + : []; + questions.push({ + ...(readString(entry.header) ? { header: entry.header as string } : {}), + question, + options, + ...(typeof entry.multiple === "boolean" ? { multiple: entry.multiple } : {}), + ...(typeof entry.custom === "boolean" ? { custom: entry.custom } : {}), + }); + } + + return { id, sessionId, questions, receivedAt }; +} + +function mapMessageInfo(value: unknown): EngineMessage | null { + if (!isRecord(value)) return null; + const id = readString(value.id); + const role = value.role; + if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null; + const time = isRecord(value.time) ? value.time : undefined; + return { + id, + role, + parts: [], + createdAt: readNumber(time?.created) ?? null, + completedAt: readNumber(time?.completed) ?? null, + }; +} + +/** + * Translates one OpenCode event envelope into the normalized engine event. + * + * Returns `null` for every event the public API does not model, which is most of the + * OpenCode stream (pty, lsp, tui, installation, workspace, ...). The function is pure: + * `subscribe` layers the call-id → tool-name memory on top of it, because + * `session.next.tool.success` / `.failed` do not repeat the tool name. + */ +export function mapOpencodeEvent(event: unknown): EngineEvent | null { + if (!isRecord(event)) return null; + const type = readString(event.type); + if (!type) return null; + + const data = readEventPayload(event); + const seq = readDurableSeq(event); + const emit = (value: EngineEvent): EngineEvent => (seq === undefined ? value : { ...value, seq }); + + const sessionId = readString(data.sessionID); + + switch (type) { + case "session.created": + case "session.updated": { + const session = mapOpencodeSession(data.info); + if (!session) return null; + return emit({ type: "session.updated", sessionId: sessionId ?? session.id, session }); + } + + case "session.deleted": { + const id = sessionId ?? mapOpencodeSession(data.info)?.id; + return id ? emit({ type: "session.deleted", sessionId: id }) : null; + } + + case "session.error": + case "session.next.step.failed": { + if (!sessionId) return null; + return emit({ type: "session.error", sessionId, error: describeOpencodeError(data.error) }); + } + + case "session.status": { + if (!sessionId) return null; + return emit({ type: "session.status", sessionId, status: mapSessionStatus(data.status) }); + } + + case "session.idle": { + return sessionId ? emit({ type: "session.idle", sessionId }) : null; + } + + case "session.next.compaction.started": { + return sessionId ? emit({ type: "session.compaction", sessionId, running: true }) : null; + } + + case "session.next.compaction.ended": + case "session.compacted": { + return sessionId ? emit({ type: "session.compaction", sessionId, running: false }) : null; + } + + case "todo.updated": { + if (!sessionId || !Array.isArray(data.todos)) return null; + return emit({ type: "todo.updated", sessionId, todos: data.todos }); + } + + case "permission.asked": + case "permission.v2.asked": { + const permission = mapOpencodePermission(data, Date.now()); + return permission ? emit({ type: "permission.asked", permission }) : null; + } + + case "permission.replied": + case "permission.v2.replied": { + const requestId = readString(data.requestID); + if (!sessionId || !requestId) return null; + return emit({ type: "permission.replied", sessionId, requestId }); + } + + case "question.asked": + case "question.v2.asked": { + const question = mapOpencodeQuestion(data, Date.now()); + return question ? emit({ type: "question.asked", question }) : null; + } + + case "question.replied": + case "question.rejected": + case "question.v2.replied": + case "question.v2.rejected": { + const requestId = readString(data.requestID); + if (!sessionId || !requestId) return null; + return emit({ type: "question.replied", sessionId, requestId }); + } + + case "message.updated": { + const message = mapMessageInfo(data.info); + if (!message) return null; + const id = sessionId ?? readString(isRecord(data.info) ? data.info.sessionID : undefined); + return id ? emit({ type: "message.upsert", sessionId: id, message }) : null; + } + + case "message.removed": { + const messageId = readString(data.messageID); + if (!sessionId || !messageId) return null; + return emit({ type: "message.removed", sessionId, messageId }); + } + + case "message.part.updated": { + const part = isRecord(data.part) ? data.part : undefined; + const partId = readString(part?.id); + const messageId = readString(part?.messageID); + const id = sessionId ?? readString(part?.sessionID); + if (!part || !partId || !messageId || !id) return null; + return emit({ + type: "message.part", + sessionId: id, + messageId, + partId, + part: part as EngineMessagePart, + }); + } + + case "message.part.delta": { + const messageId = readString(data.messageID); + const partId = readString(data.partID); + const delta = readString(data.delta); + if (!sessionId || !messageId || !partId || !delta) return null; + return emit({ + type: "message.delta", + sessionId, + messageId, + partId, + kind: data.field === "reasoning" ? "reasoning" : "text", + delta, + }); + } + + case "session.next.text.delta": { + const messageId = readString(data.assistantMessageID); + const partId = readString(data.textID); + const delta = readString(data.delta); + if (!sessionId || !messageId || !partId || !delta) return null; + return emit({ type: "message.delta", sessionId, messageId, partId, kind: "text", delta }); + } + + case "session.next.reasoning.delta": { + const messageId = readString(data.assistantMessageID); + const partId = readString(data.reasoningID); + const delta = readString(data.delta); + if (!sessionId || !messageId || !partId || !delta) return null; + return emit({ type: "message.delta", sessionId, messageId, partId, kind: "reasoning", delta }); + } + + case "session.next.tool.called": { + const messageId = readString(data.assistantMessageID); + const callId = readString(data.callID); + const tool = readString(data.tool); + if (!sessionId || !messageId || !callId || !tool) return null; + return emit({ type: "tool.called", sessionId, messageId, callId, tool, input: data.input }); + } + + case "session.next.tool.success": { + const messageId = readString(data.assistantMessageID); + const callId = readString(data.callID); + if (!sessionId || !messageId || !callId) return null; + return emit({ + type: "tool.completed", + sessionId, + messageId, + callId, + // `session.next.tool.success` omits the tool name; `subscribe` fills it in + // from the matching `session.next.tool.called`. + tool: readString(data.tool) ?? "", + status: "success", + output: data.result !== undefined ? data.result : data.structured, + }); + } + + case "session.next.tool.failed": { + const messageId = readString(data.assistantMessageID); + const callId = readString(data.callID); + if (!sessionId || !messageId || !callId) return null; + return emit({ + type: "tool.completed", + sessionId, + messageId, + callId, + tool: readString(data.tool) ?? "", + status: "failed", + error: describeOpencodeError(data.error).message, + }); + } + + default: + return null; + } +} + +/** Builds the v2 `PromptInput` body from the engine-neutral prompt parts. */ +export function buildOpencodePromptInput(parts: EnginePromptInput["parts"]) { + const texts: string[] = []; + const files: Array<{ uri: string; name?: string }> = []; + const agents: Array<{ name: string }> = []; + + for (const part of parts) { + if (part.type === "text") { + if (part.text.length > 0) texts.push(part.text); + continue; + } + if (part.type === "file") { + files.push({ uri: part.url, ...(part.filename ? { name: part.filename } : {}) }); + continue; + } + if (part.type === "agent") agents.push({ name: part.name }); + } + + return { + text: texts.join("\n\n"), + ...(files.length > 0 ? { files } : {}), + ...(agents.length > 0 ? { agents } : {}), + }; +} + +function sessionPath(sessionId: string): string { + return `/session/${encodeURIComponent(sessionId)}`; +} + +function createOpencodeEngineConnection( + deps: OpencodeEngineAdapterDeps, + workspace: WorkspaceInfo, +): EngineConnection { + const client = deps.createClient(workspace); + const unwrap = deps.unwrap; + + /** Throws through `unwrap` when the SDK reported a failure; tolerates 204 bodies. */ + const ensureOk = (result: OpencodeClientResult, path: string): void => { + if (result.error !== undefined) unwrap(result, path); + }; + + const readSession = async (sessionId: string): Promise => { + const body = unwrap(await client.v2.session.get({ sessionID: sessionId }), sessionPath(sessionId)); + const session = mapOpencodeSession(body.data); + if (!session) throw new Error(`OpenCode returned an invalid session: ${sessionId}`); + return session; + }; + + return { + engineId: DEFAULT_ENGINE_ID, + capabilities: OPENCODE_ENGINE_CAPABILITIES, + + async createSession(input) { + const body = unwrap( + await client.v2.session.create({ + ...(input.agent ? { agent: input.agent } : {}), + ...(input.model ? { model: { id: input.model.modelID, providerID: input.model.providerID } } : {}), + }), + "/session", + ); + const session = mapOpencodeSession(body.data); + if (!session) throw new Error("OpenCode returned an invalid session"); + if (!input.title) return session; + // v2 has no title field on create or on the session resource, so the classic + // `session.update` endpoint is the only way to name a session. + await this.renameSession(session.id, input.title); + return { ...session, title: input.title }; + }, + + getSession: readSession, + + async deleteSession(sessionId) { + // `client.v2.session` exposes no delete; `Session2.delete` is the only one. + ensureOk(await client.session.delete({ sessionID: sessionId }), sessionPath(sessionId)); + }, + + async renameSession(sessionId, title) { + // `client.v2.session` exposes no update; `Session2.update` carries `title`. + ensureOk(await client.session.update({ sessionID: sessionId, title }), sessionPath(sessionId)); + }, + + async prompt(input) { + const path = `${sessionPath(input.sessionId)}/prompt`; + if (input.agent) { + ensureOk( + await client.v2.session.switchAgent({ sessionID: input.sessionId, agent: input.agent }), + `${sessionPath(input.sessionId)}/agent`, + ); + } + if (input.model) { + ensureOk( + await client.v2.session.switchModel({ + sessionID: input.sessionId, + model: { + id: input.model.modelID, + providerID: input.model.providerID, + ...(input.variant ? { variant: input.variant } : {}), + }, + }), + `${sessionPath(input.sessionId)}/model`, + ); + } + + const body = unwrap( + await client.v2.session.prompt({ + sessionID: input.sessionId, + prompt: buildOpencodePromptInput(input.parts), + ...(input.delivery ? { delivery: input.delivery } : {}), + }), + path, + ); + const messageId = readString(isRecord(body.data) ? body.data.id : undefined); + return messageId ? { messageId } : {}; + }, + + async interrupt(sessionId) { + // 204 No Content: there is no body to unwrap, only an error to surface. + ensureOk(await client.v2.session.interrupt({ sessionID: sessionId }), `${sessionPath(sessionId)}/interrupt`); + return true; + }, + + async wait(sessionId, signal) { + ensureOk( + await client.v2.session.wait({ sessionID: sessionId }, signal ? { signal } : {}), + `${sessionPath(sessionId)}/wait`, + ); + }, + + async listPermissions(sessionId) { + const body = unwrap( + await client.v2.session.permission.list({ sessionID: sessionId }), + `${sessionPath(sessionId)}/permission`, + ); + const receivedAt = Date.now(); + return (Array.isArray(body.data) ? body.data : []) + .map((permission) => mapOpencodePermission(permission, receivedAt)) + .filter((permission): permission is EnginePermission => permission !== null); + }, + + async replyPermission(input) { + ensureOk( + await client.v2.session.permission.reply({ + sessionID: input.sessionId, + requestID: input.permissionId, + reply: input.reply, + }), + `${sessionPath(input.sessionId)}/permission/${encodeURIComponent(input.permissionId)}`, + ); + }, + + async listQuestions(sessionId) { + const body = unwrap( + await client.v2.session.question.list({ sessionID: sessionId }), + `${sessionPath(sessionId)}/question`, + ); + const receivedAt = Date.now(); + return (Array.isArray(body.data) ? body.data : []) + .map((question) => mapOpencodeQuestion(question, receivedAt)) + .filter((question): question is EngineQuestion => question !== null); + }, + + async replyQuestion(input) { + ensureOk( + await client.v2.session.question.reply({ + sessionID: input.sessionId, + requestID: input.questionId, + questionV2Reply: { answers: input.answers }, + }), + `${sessionPath(input.sessionId)}/question/${encodeURIComponent(input.questionId)}`, + ); + }, + + async subscribe(input: EngineSubscribeInput) { + // `events` returns `{ stream }` (see `core/serverSentEvents.gen.d.ts`), an async + // generator yielding the already-parsed `data:` payload of each SSE frame. + const subscription = await client.v2.session.events( + { sessionID: input.sessionId, ...(input.after ? { after: input.after } : {}) }, + { signal: input.signal }, + ); + + // `session.next.tool.success` / `.failed` omit the tool name, so remember it + // from the matching `.called` event and restore it on completion. + const toolNames = new Map(); + + try { + for await (const raw of subscription.stream) { + if (input.signal.aborted) return; + const event = mapOpencodeEvent(raw as unknown); + if (!event) continue; + if (event.type === "tool.called") { + toolNames.set(event.callId, event.tool); + } else if (event.type === "tool.completed") { + if (!event.tool) { + const remembered = toolNames.get(event.callId); + if (remembered) event.tool = remembered; + } + toolNames.delete(event.callId); + } + input.onEvent(event); + } + } catch (error) { + if (input.signal.aborted) return; + throw error; + } + }, + }; +} + +export function createOpencodeEngineAdapter(deps: OpencodeEngineAdapterDeps): EngineAdapter { + return { + id: DEFAULT_ENGINE_ID, + connect(workspace: unknown): EngineConnection { + return createOpencodeEngineConnection(deps, workspace as WorkspaceInfo); + }, + }; +} diff --git a/apps/server/src/api/engine/types.ts b/apps/server/src/api/engine/types.ts new file mode 100644 index 000000000..4b7cc6b38 --- /dev/null +++ b/apps/server/src/api/engine/types.ts @@ -0,0 +1,316 @@ +import { ApiError } from "../../errors.js"; + +/** + * Server-side conversation engine abstraction. + * + * iPolloWork already unifies its engines behind a `ConversationEngineAdapter` in the + * browser (`apps/app/src/react-app/domains/session/engine/conversation-engine.ts`). + * That abstraction is what lets the UI drive OpenCode and DeepSeek Harness through one + * set of calls. The public API needs the same thing on the server, so this module + * mirrors that contract — same event names, same permission and question vocabulary — + * rather than inventing a second, competing one. + * + * The engine-specific mapping lives in `opencode.ts` and `harness.ts`. + */ + +export type EngineSessionStatus = + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; + +export interface EngineSession { + id: string; + title: string; + parentId?: string | null; + directory?: string | null; + createdAt?: number | null; + updatedAt?: number | null; + archivedAt?: number | null; +} + +export interface EnginePermission { + id: string; + sessionId: string; + /** Engine-specific permission kind, e.g. a tool name or an action id. */ + kind: string; + resources: string[]; + /** Scopes the caller may persist an answer for. */ + remember: string[]; + metadata: Record; + receivedAt: number; +} + +export type EnginePermissionReply = "once" | "always" | "reject"; + +export interface EngineQuestionOption { + label: string; + description?: string; +} + +export interface EngineQuestionInfo { + header?: string; + question: string; + options: EngineQuestionOption[]; + multiple?: boolean; + custom?: boolean; +} + +export interface EngineQuestion { + id: string; + sessionId: string; + questions: EngineQuestionInfo[]; + receivedAt: number; +} + +export type EnginePromptPart = + | { type: "text"; text: string } + | { type: "file"; mime: string; url: string; filename?: string } + | { type: "agent"; name: string }; + +export interface EngineModelRef { + providerID: string; + modelID: string; +} + +export interface EnginePromptInput { + sessionId: string; + parts: EnginePromptPart[]; + model?: EngineModelRef; + /** Agent / mode identifier. */ + agent?: string; + system?: string; + reasoningEffort?: string; + variant?: string; + /** OpenCode v2 delivery semantics: steer an in-flight turn or queue after it. */ + delivery?: "steer" | "queue"; +} + +export interface EngineMessagePart { + id?: string; + type: string; + [key: string]: unknown; +} + +export interface EngineMessage { + id: string; + role: "user" | "assistant" | "system"; + parts: EngineMessagePart[]; + createdAt?: number | null; + completedAt?: number | null; +} + +/** + * Normalized event stream. + * + * Names follow the browser `ConversationEvent` union so a client that already speaks + * iPolloWork's UI vocabulary needs no translation table. `seq` carries the engine's + * durable cursor where one exists (OpenCode v2 exposes `durable.seq`), which is what + * makes `?after=` resumption possible. + */ +export type EngineEvent = + | { type: "session.updated"; sessionId: string; session: EngineSession; seq?: string } + | { type: "session.deleted"; sessionId: string; seq?: string } + | { type: "session.error"; sessionId: string; error: { code?: string; message: string }; seq?: string } + | { type: "session.status"; sessionId: string; status: EngineSessionStatus; seq?: string } + | { type: "session.idle"; sessionId: string; seq?: string } + | { type: "session.compaction"; sessionId: string; running: boolean; seq?: string } + | { type: "todo.updated"; sessionId: string; todos: unknown[]; seq?: string } + | { type: "permission.asked"; permission: EnginePermission; seq?: string } + | { type: "permission.replied"; sessionId: string; requestId: string; seq?: string } + | { type: "question.asked"; question: EngineQuestion; seq?: string } + | { type: "question.replied"; sessionId: string; requestId: string; seq?: string } + | { type: "message.upsert"; sessionId: string; message: EngineMessage; seq?: string } + | { type: "message.completed"; sessionId: string; messageId: string; completedAt: number; seq?: string } + | { type: "message.removed"; sessionId: string; messageId: string; seq?: string } + | { + type: "message.part"; + sessionId: string; + messageId: string; + partId: string; + part: EngineMessagePart; + seq?: string; + } + | { + type: "message.delta"; + sessionId: string; + messageId: string; + partId: string; + kind: "text" | "reasoning"; + delta: string; + seq?: string; + } + | { + type: "tool.called"; + sessionId: string; + messageId: string; + callId: string; + tool: string; + input?: unknown; + seq?: string; + } + | { + type: "tool.completed"; + sessionId: string; + messageId: string; + callId: string; + tool: string; + status: "success" | "failed"; + output?: unknown; + error?: string; + seq?: string; + }; + +export type EngineEventType = EngineEvent["type"]; + +export const ENGINE_EVENT_TYPES: readonly EngineEventType[] = [ + "session.updated", + "session.deleted", + "session.error", + "session.status", + "session.idle", + "session.compaction", + "todo.updated", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "message.upsert", + "message.completed", + "message.removed", + "message.part", + "message.delta", + "tool.called", + "tool.completed", +] as const; + +export interface EngineSubscribeInput { + sessionId: string; + /** Durable cursor to resume from, as previously reported in `EngineEvent.seq`. */ + after?: string; + signal: AbortSignal; + onEvent: (event: EngineEvent) => void; +} + +/** + * What every engine must be able to do for the public API. + * + * Capability gaps are reported through `capabilities` rather than by throwing from a + * missing method, so a caller can discover what an engine supports before trying it. + */ +export interface EngineCapabilities { + /** Streaming session events are available. */ + streaming: boolean; + /** `after` resumption is honoured by `subscribe`. */ + resumableStreaming: boolean; + permissions: boolean; + questions: boolean; + interrupt: boolean; + /** A blocking "run until idle" primitive exists. */ + wait: boolean; + /** + * Optional `prompt` fields the engine actually applies. + * + * The engines diverge here — OpenCode's v2 prompt endpoint has no field for a per-turn + * system prompt, while DeepSeek Harness does — and an engine-agnostic API that quietly + * ignored the difference would be lying: the caller would set a system prompt, get a + * normal-looking 200, and never learn it had no effect. A field reported `false` is + * rejected at the edge instead of dropped in the adapter. + */ + promptOptions: EnginePromptOptionSupport; +} + +export interface EnginePromptOptionSupport { + /** A per-turn system prompt override. */ + system: boolean; + /** A reasoning-effort hint. */ + reasoningEffort: boolean; + /** A model variant selector. */ + variant: boolean; +} + +export type EnginePromptOption = keyof EnginePromptOptionSupport; + +export const ENGINE_PROMPT_OPTIONS: readonly EnginePromptOption[] = [ + "system", + "reasoningEffort", + "variant", +] as const; + +export interface EngineConnection { + readonly engineId: string; + readonly capabilities: EngineCapabilities; + + createSession(input: { title?: string; agent?: string; model?: EngineModelRef }): Promise; + getSession(sessionId: string): Promise; + deleteSession(sessionId: string): Promise; + renameSession(sessionId: string, title: string): Promise; + + prompt(input: EnginePromptInput): Promise<{ messageId?: string }>; + interrupt(sessionId: string): Promise; + /** Resolves once the session goes idle. Only valid when `capabilities.wait`. */ + wait(sessionId: string, signal?: AbortSignal): Promise; + + listPermissions(sessionId: string): Promise; + replyPermission(input: { sessionId: string; permissionId: string; reply: EnginePermissionReply }): Promise; + + listQuestions(sessionId: string): Promise; + replyQuestion(input: { sessionId: string; questionId: string; answers: string[][] }): Promise; + + subscribe(input: EngineSubscribeInput): Promise; +} + +export interface EngineAdapter { + readonly id: string; + /** Builds a connection for one workspace. */ + connect(workspace: unknown): EngineConnection; +} + +/** + * Adapter lookup by engine id. + * + * Follows the two registries this codebase already has — `PluginEngineAdapterRegistry` + * (`../../plugin-engine-adapter.ts`) and the browser's `ConversationEngineAdapterRegistry` + * — including their fail-fast construction: an empty or duplicate id is a programming + * error and is rejected at startup rather than at the first request. A lookup miss is a + * request-time condition, so it surfaces as an `ApiError` the same way the plugin engine + * registry reports an unregistered engine. + */ +export class EngineAdapterRegistry { + readonly #adapters: ReadonlyMap; + readonly #defaultEngineId: string; + + constructor(defaultEngineId: string, adapters: readonly EngineAdapter[]) { + const entries = new Map(); + for (const adapter of adapters) { + const id = adapter.id.trim(); + if (!id) throw new Error("Engine adapter ID is required"); + if (entries.has(id)) throw new Error(`Duplicate engine adapter: ${id}`); + entries.set(id, adapter); + } + if (!entries.has(defaultEngineId)) { + throw new Error(`Default engine adapter is not registered: ${defaultEngineId}`); + } + this.#adapters = entries; + this.#defaultEngineId = defaultEngineId; + } + + get(id?: string | null): EngineAdapter { + const resolved = id?.trim() || this.#defaultEngineId; + const adapter = this.#adapters.get(resolved); + if (!adapter) { + throw new ApiError(409, "engine_not_registered", `Engine is not registered: ${resolved}`, { + engine: resolved, + registeredEngines: [...this.#adapters.keys()], + }); + } + return adapter; + } + + has(id?: string | null): boolean { + return this.#adapters.has(id?.trim() || this.#defaultEngineId); + } + + ids(): string[] { + return [...this.#adapters.keys()]; + } +} diff --git a/apps/server/src/api/index.test.ts b/apps/server/src/api/index.test.ts new file mode 100644 index 000000000..79e606de1 --- /dev/null +++ b/apps/server/src/api/index.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, test } from "bun:test"; + +import { ApiError, isApiError } from "../errors.js"; +import { addRoute, matchRoute, type RequestContext, type Route } from "../routes/registry.js"; +import type { ServerConfig, WorkspaceInfo } from "../types.js"; +import type { HarnessRuntimeLike } from "./engine/harness.js"; +import type { OpencodeEngineClient, UnwrapOpencodeResult } from "./engine/opencode.js"; +import { + API_MODULES, + bridgeTaskEventsToWebhooks, + createEngineRegistry, + createPolicyEnforcer, + registerApiV1, + taskWebhookEventType, + type RegisterApiV1Input, +} from "./index.js"; +import { defaultScopeForEffect } from "./module.js"; +import { createTaskStore, type TaskEvent, type TaskRecord } from "./modules/tasks/store.js"; +import { MemoryWebhookStore } from "./modules/webhooks/store.js"; + +/** Awaits a rejection and narrows it to an ApiError, so assertions stay typed. */ +async function expectRejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (isApiError(error)) return error as ApiError; + throw error; + } + throw new Error("expected the call to reject"); +} + +const config = { workspaces: [], readOnly: false, authorizedRoots: [] } as unknown as ServerConfig; + +const workspace = { id: "w1", name: "w1", path: "/tmp/w1", preset: "starter" } as unknown as WorkspaceInfo; + +const harnessRuntime: HarnessRuntimeLike = { + async call(): Promise { + throw new Error("not reached"); + }, + async respond() {}, + async events(): Promise { + throw new Error("not reached"); + }, +}; + +const unwrap: UnwrapOpencodeResult = (result, path) => { + if (result.data == null) throw new Error(`empty ${path}`); + return result.data as NonNullable; +}; + +const createClient = (): OpencodeEngineClient => ({}) as OpencodeEngineClient; + +/** A legacy route table with one entry, so `compat` has something to delegate to. */ +function legacyTable(): Route[] { + const routes: Route[] = []; + addRoute(routes, "GET", "/workspaces", "client", async () => new Response("legacy", { status: 200 })); + return routes; +} + +function baseInput(routes: Route[], overrides: Partial = {}): RegisterApiV1Input { + return { + routes, + config, + serverVersion: "9.9.9", + ensureWritable: () => {}, + requireClientScope: () => {}, + jsonResponse: (data, status = 200) => + new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }), + readJsonBody: async (request) => (await request.json()) as Record, + resolveWorkspace: async () => workspace, + createWorkspaceOpencodeClient: createClient, + unwrapOpencodeResult: unwrap, + deepseekHarness: harnessRuntime, + // Deliberately empty so the ambient IPOLLOWORK_API_MODULES* vars cannot change the + // result of a test run. + env: {}, + ...overrides, + }; +} + +describe("createEngineRegistry", () => { + test("registers both built-in engines and defaults to opencode", () => { + const engines = createEngineRegistry({ + config, + createWorkspaceOpencodeClient: createClient, + unwrapOpencodeResult: unwrap, + deepseekHarness: harnessRuntime, + }); + + expect(engines.ids().sort()).toEqual(["deepseek-harness", "opencode"]); + expect(engines.get(null).id).toBe("opencode"); + expect(engines.get(undefined).id).toBe("opencode"); + expect(engines.get("deepseek-harness").id).toBe("deepseek-harness"); + }); + + test("an unregistered engine id is a 409, not a silent fallback", () => { + const engines = createEngineRegistry({ + config, + createWorkspaceOpencodeClient: createClient, + unwrapOpencodeResult: unwrap, + deepseekHarness: harnessRuntime, + }); + + try { + engines.get("codex"); + throw new Error("expected a throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + if (isApiError(error)) { + expect(error.status).toBe(409); + expect(error.code).toBe("engine_not_registered"); + } + } + }); +}); + +describe("registerApiV1", () => { + test("registers every module in the catalogue by default", () => { + const routes = legacyTable(); + const result = registerApiV1(baseInput(routes)); + + expect(result.modules.map((entry) => entry.module.id)).toEqual([ + "sessions", + "tasks", + "webhooks", + "policy", + "openapi", + "compat", + ]); + expect(result.modules.map((entry) => entry.module.id)).toEqual(API_MODULES.map((module) => module.id)); + expect(result.operations.length).toBeGreaterThan(0); + }); + + test("appends routes without removing or reordering the legacy table", () => { + const routes = legacyTable(); + const before = routes.length; + const legacy = routes[0]; + + const result = registerApiV1(baseInput(routes)); + + expect(routes.length).toBe(before + result.operations.length); + expect(routes[0]).toBe(legacy); + // matchRoute returns the first match, so a legacy path still resolves to its own + // handler after v1 is mounted. + expect(matchRoute(routes, "GET", "/workspaces")?.handler).toBe(legacy.handler); + }); + + test("every v1 operation is reachable and lands under /api/v1", () => { + const routes = legacyTable(); + const result = registerApiV1(baseInput(routes)); + + for (const operation of result.operations) { + expect(operation.path.startsWith("/api/v1/")).toBe(true); + const concrete = operation.path.replace(/:([A-Za-z0-9_]+)/g, "x-$1"); + expect(matchRoute(routes, operation.method, concrete)).not.toBeNull(); + } + }); + + test("operation ids and method+path pairs are unique across modules", () => { + const result = registerApiV1(baseInput(legacyTable())); + + const ids = result.operations.map((operation) => operation.operationId); + expect(new Set(ids).size).toBe(ids.length); + + const keys = result.operations.map((operation) => `${operation.method} ${operation.path}`); + expect(new Set(keys).size).toBe(keys.length); + + // Two different patterns can still compile to the same regex (`:id` vs `:workspaceId`), + // which the registry's string key cannot see but a router would shadow. + const patterns = result.operations.map( + (operation) => `${operation.method} ${operation.path.replace(/:[A-Za-z0-9_]+/g, ":p")}`, + ); + expect(new Set(patterns).size).toBe(patterns.length); + }); + + test("first-class modules never weaken the default scope for a write", () => { + const result = registerApiV1(baseInput(legacyTable())); + const rank = { viewer: 1, collaborator: 2, owner: 3 } as const; + let checked = 0; + + for (const { module, operations } of result.modules) { + // `compat` is the documented exception: an alias copies the legacy handler's own + // check so the alias can never reject a request the legacy path would accept. + if (module.id === "compat") continue; + for (const operation of operations) { + if (operation.effect === "read") continue; + if ((operation.auth ?? "client") !== "client") continue; + const scope = operation.scope ?? defaultScopeForEffect(operation.effect); + expect(rank[scope]).toBeGreaterThanOrEqual(rank[defaultScopeForEffect(operation.effect)]); + checked += 1; + } + } + expect(checked).toBeGreaterThan(0); + }); + + test("every compat alias declares its scope explicitly", () => { + // The alias scope is copied from the legacy handler's own check rather than derived + // from the effect (`compat/module.test.ts` verifies that against the route sources), + // so an alias that fell back to the effect default would be an unreviewed gate. + const result = registerApiV1(baseInput(legacyTable())); + const compat = result.modules.find((entry) => entry.module.id === "compat"); + expect(compat).toBeDefined(); + expect(compat!.operations.length).toBeGreaterThan(0); + + for (const operation of compat!.operations) { + expect(operation.scope).toBeDefined(); + } + }); + + test("IPOLLOWORK_API_MODULES narrows the enabled set", () => { + const routes = legacyTable(); + const result = registerApiV1( + baseInput(routes, { env: { IPOLLOWORK_API_MODULES: "sessions,openapi" } }), + ); + + expect(result.modules.map((entry) => entry.module.id)).toEqual(["sessions", "openapi"]); + expect(result.taskStore).toBeUndefined(); + expect(result.webhookStore).toBeUndefined(); + }); + + test("IPOLLOWORK_API_MODULES_DISABLED removes a module", () => { + const result = registerApiV1( + baseInput(legacyTable(), { env: { IPOLLOWORK_API_MODULES_DISABLED: "compat,webhooks" } }), + ); + + const ids = result.modules.map((entry) => entry.module.id); + expect(ids).not.toContain("compat"); + expect(ids).not.toContain("webhooks"); + expect(ids).toContain("sessions"); + }); + + test("an unknown module id fails startup instead of being ignored", () => { + try { + registerApiV1(baseInput(legacyTable(), { env: { IPOLLOWORK_API_MODULES: "sessions,typo" } })); + throw new Error("expected a throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + if (isApiError(error)) expect(error.code).toBe("api_module_unknown"); + } + }); + + test("disabling a dependency of an enabled module fails startup", () => { + try { + registerApiV1(baseInput(legacyTable(), { env: { IPOLLOWORK_API_MODULES: "tasks" } })); + throw new Error("expected a throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + if (isApiError(error)) expect(error.code).toBe("api_module_dependency_missing"); + } + }); + + test("compat delegates to the legacy table through the injected getter", async () => { + const routes = legacyTable(); + registerApiV1(baseInput(routes, { legacyRoutes: () => routes })); + + const matched = matchRoute(routes, "GET", "/api/v1/workspaces"); + expect(matched).not.toBeNull(); + const response = await matched!.handler({ + request: new Request("http://local/api/v1/workspaces"), + url: new URL("http://local/api/v1/workspaces"), + params: matched!.params, + config, + } as unknown as RequestContext); + expect(await response.text()).toBe("legacy"); + }); + + test("the openapi module sees the completed registry, including its own operations", async () => { + const routes = legacyTable(); + const result = registerApiV1(baseInput(routes)); + + const matched = matchRoute(routes, "GET", "/api/v1/openapi.json"); + expect(matched).not.toBeNull(); + const response = await matched!.handler({ + request: new Request("http://local/api/v1/openapi.json"), + url: new URL("http://local/api/v1/openapi.json"), + params: {}, + config, + } as unknown as RequestContext); + const document = (await response.json()) as { info: { version: string }; paths: Record }; + + expect(response.status).toBe(200); + expect(document.info.version).toBe("9.9.9"); + expect(document.paths["/api/v1/openapi.json"]).toBeDefined(); + const documented = result.operations.filter((operation) => !operation.internal); + expect(Object.keys(document.paths).length).toBeGreaterThan(0); + expect(Object.keys(document.paths).length).toBeLessThanOrEqual(documented.length); + }); + + test("service overrides win over the ones the composition root builds", () => { + const taskStore = createTaskStore(); + const webhookStore = new MemoryWebhookStore(); + const result = registerApiV1(baseInput(legacyTable(), { services: { taskStore, webhookStore } })); + + expect(result.taskStore).toBe(taskStore); + expect(result.webhookStore).toBe(webhookStore); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Task -> webhook bridge */ +/* -------------------------------------------------------------------------- */ + +function taskEvent(overrides: Partial & { task?: Partial } = {}): TaskEvent { + const task = { id: "t1", workspaceId: "w1", state: "queued", goal: "g", ...overrides.task } as TaskRecord; + return { seq: 1, at: 0, taskId: task.id, type: "task.created", ...overrides, task } as TaskEvent; +} + +describe("taskWebhookEventType", () => { + test("maps the four subscribable task events", () => { + expect(taskWebhookEventType(taskEvent())).toBe("task.created"); + expect(taskWebhookEventType(taskEvent({ type: "task.state", to: "done" }))).toBe("task.completed"); + expect(taskWebhookEventType(taskEvent({ type: "task.state", to: "failed" }))).toBe("task.failed"); + expect(taskWebhookEventType(taskEvent({ type: "task.state", to: "awaiting_approval" }))) + .toBe("task.awaiting_approval"); + }); + + test("emits nothing for transitions with no webhook name", () => { + expect(taskWebhookEventType(taskEvent({ type: "task.updated" }))).toBeNull(); + expect(taskWebhookEventType(taskEvent({ type: "task.state", to: "running" }))).toBeNull(); + expect(taskWebhookEventType(taskEvent({ type: "task.state", to: "cancelled" }))).toBeNull(); + }); +}); + +describe("bridgeTaskEventsToWebhooks", () => { + test("dispatches the mapped events and detaches on dispose", () => { + const tasks = createTaskStore(); + const webhooks = new MemoryWebhookStore(); + const sent: Array<{ workspaceId: string; type: string }> = []; + + const detach = bridgeTaskEventsToWebhooks({ + tasks, + webhooks, + allowPrivate: () => true, + dispatch: async (_store, event) => { + sent.push({ workspaceId: event.workspaceId, type: event.type }); + return []; + }, + }); + + const task = tasks.add({ workspaceId: "w1", goal: "ship it" }); + tasks.update(task.id, { state: "running" }); + tasks.update(task.id, { state: "done" }); + + expect(sent).toEqual([ + { workspaceId: "w1", type: "task.created" }, + { workspaceId: "w1", type: "task.completed" }, + ]); + + detach(); + const second = tasks.add({ workspaceId: "w1", goal: "again" }); + expect(second.id).not.toBe(task.id); + expect(sent.length).toBe(2); + }); + + test("a failing delivery never escapes into the task store", () => { + const tasks = createTaskStore(); + const webhooks = new MemoryWebhookStore(); + + bridgeTaskEventsToWebhooks({ + tasks, + webhooks, + dispatch: async () => { + throw new Error("endpoint is gone"); + }, + }); + + expect(() => tasks.add({ workspaceId: "w1", goal: "ship it" })).not.toThrow(); + }); +}); + +describe("createPolicyEnforcer", () => { + function ctxWith(params: Record, tokenHash?: string): RequestContext { + return { + request: new Request("http://localhost/api/v1/x"), + url: new URL("http://localhost/api/v1/x"), + params, + config, + approvals: {} as never, + reloadEvents: {} as never, + tokens: { + async findByHash(hash: string) { + return hash === "hash-ci" ? { id: "tok_ci", scope: "collaborator" as const, label: "ci" } : null; + }, + } as never, + ...(tokenHash ? { actor: { type: "remote" as const, tokenHash, scope: "collaborator" as const } } : {}), + }; + } + + const store = { + async get(tokenId: string) { + if (tokenId === "tok_ci") return { workspaces: ["w1"], expiresAt: null }; + if (tokenId === "tok_expired") return { expiresAt: 1 }; + return {}; + }, + }; + + test("allows a workspace the token is bound to", async () => { + await expect(createPolicyEnforcer(store)(ctxWith({ workspaceId: "w1" }, "hash-ci"))).resolves.toBeUndefined(); + }); + + test("rejects a workspace outside the binding", async () => { + const error = await expectRejection(createPolicyEnforcer(store)(ctxWith({ workspaceId: "w2" }, "hash-ci"))); + + expect(error.status).toBe(403); + expect(error.code).toBe("workspace_forbidden"); + expect(error.details).toMatchObject({ workspaceId: "w2" }); + }); + + test("reads the legacy `:id` param too, so compat aliases are covered", async () => { + const error = await expectRejection(createPolicyEnforcer(store)(ctxWith({ id: "w2" }, "hash-ci"))); + expect(error.code).toBe("workspace_forbidden"); + }); + + test("a route naming no workspace still passes for a bound token", async () => { + await expect(createPolicyEnforcer(store)(ctxWith({}, "hash-ci"))).resolves.toBeUndefined(); + }); + + test("an expired token is rejected even on a route naming no workspace", async () => { + const expiredStore = { async get() { return { expiresAt: 1 }; } }; + const error = await expectRejection(createPolicyEnforcer(expiredStore)(ctxWith({}, "hash-ci"))); + + expect(error.status).toBe(403); + expect(error.code).toBe("token_expired"); + }); + + test("the shared config token has no record and is left alone", async () => { + await expect(createPolicyEnforcer(store)(ctxWith({ workspaceId: "w2" }))).resolves.toBeUndefined(); + }); + + test("an unknown token hash is left to the auth layer, not silently bound", async () => { + await expect(createPolicyEnforcer(store)(ctxWith({ workspaceId: "w2" }, "hash-unknown"))) + .resolves.toBeUndefined(); + }); +}); + +describe("policy enforcement is applied by the registry", () => { + test("a bound token cannot reach another workspace through a registered route", async () => { + const routes: Route[] = []; + let handlerRan = false; + + registerApiV1({ + ...baseInput(routes), + services: { + tokenPolicies: { + async get(tokenId: string) { + return tokenId === "tok_ci" ? { workspaces: ["w1"] } : {}; + }, + }, + }, + } as unknown as RegisterApiV1Input); + + const route = matchRoute(routes, "POST", "/api/v1/workspaces/w2/sessions"); + expect(route).not.toBeNull(); + + const ctx: RequestContext = { + request: new Request("http://localhost/api/v1/workspaces/w2/sessions", { + method: "POST", + body: "{}", + headers: { "content-type": "application/json" }, + }), + url: new URL("http://localhost/api/v1/workspaces/w2/sessions"), + params: route!.params, + config, + approvals: {} as never, + reloadEvents: {} as never, + tokens: { + async findByHash() { + return { id: "tok_ci", scope: "collaborator" as const, label: "ci" }; + }, + } as never, + actor: { type: "remote", tokenHash: "hash-ci", scope: "collaborator" }, + }; + + const error = await expectRejection( + route!.handler(ctx).then(() => { + handlerRan = true; + }), + ); + + expect(handlerRan).toBe(false); + expect(error.status).toBe(403); + expect(error.code).toBe("workspace_forbidden"); + }); +}); diff --git a/apps/server/src/api/index.ts b/apps/server/src/api/index.ts new file mode 100644 index 000000000..58ae1b75f --- /dev/null +++ b/apps/server/src/api/index.ts @@ -0,0 +1,297 @@ +/** + * Composition root for the public API component. + * + * Everything above this file is declarative: an engine adapter says how to talk to one + * engine, a module says which operations it exposes. Nothing in either half knows how the + * server is wired. This file is the one place that knows, so the component can be mounted + * with a single call and, in a test, mounted against stubs without touching a real server. + * + * Mounting is additive. `matchRoute` (`../routes/registry.ts`) scans the route array in + * order and returns the first match, so appending v1 routes after the legacy registrations + * cannot shadow an existing route — the legacy table is matched first, unchanged. + */ + +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import type { RequestContext, Route } from "../routes/registry.js"; +import type { ServerConfig, TokenScope, WorkspaceInfo } from "../types.js"; +import { createHarnessEngineAdapter, type HarnessRuntimeLike } from "./engine/harness.js"; +import { + createOpencodeEngineAdapter, + type OpencodeEngineClient, + type UnwrapOpencodeResult, +} from "./engine/opencode.js"; +import { EngineAdapterRegistry } from "./engine/types.js"; +import { + registerApiModules, + resolveEnabledModules, + type ApiModule, + type ApiModuleContext, + type ApiModuleRegistryResult, + type ApiModuleServices, +} from "./module.js"; +import { compatModule } from "./modules/compat/module.js"; +import { openApiModule } from "./modules/openapi/module.js"; +import { assertPolicyAllowsWorkspace, policyModule, TokenPolicyStore } from "./modules/policy/module.js"; +import { sessionsModule } from "./modules/sessions/module.js"; +import { serializeTask, tasksModule } from "./modules/tasks/module.js"; +import { createTaskRunner, type TaskRunner } from "./modules/tasks/runner.js"; +import { createTaskStore, type TaskEvent, type TaskStore } from "./modules/tasks/store.js"; +import { webhookPrivateNetworkAllowed } from "./modules/webhooks/delivery.js"; +import { dispatchWebhookEvent, webhooksModule } from "./modules/webhooks/module.js"; +import { WebhookStore, type WebhookStoreLike } from "./modules/webhooks/store.js"; + +/** + * Registration order. + * + * `compat` is last on purpose: its aliases are the broadest patterns in the component, so + * a first-class module always wins a path both could serve. `tasks` declares + * `dependsOn: ["sessions"]`, which `registerApiModules` checks against the enabled set + * rather than against order, so this list stays readable rather than topologically sorted. + */ +export const API_MODULES: readonly ApiModule[] = [ + sessionsModule, + tasksModule, + webhooksModule, + policyModule, + openApiModule, + compatModule, +]; + +/** Env var narrowing the enabled set to an allowlist. */ +export const API_MODULES_ENV = "IPOLLOWORK_API_MODULES"; +/** Env var removing individual modules. */ +export const API_MODULES_DISABLED_ENV = "IPOLLOWORK_API_MODULES_DISABLED"; + +export interface RegisterApiV1Input { + /** The shared route table. v1 routes are appended to it. */ + routes: Route[]; + config: ServerConfig; + /** Reported as the OpenAPI document's `info.version`. */ + serverVersion: string; + + /* The server's own gates and helpers, injected rather than imported: the server owns + them, and a test needs to be able to substitute them. */ + ensureWritable: (config: ServerConfig) => void; + requireClientScope: (ctx: RequestContext, required: TokenScope) => void; + jsonResponse: (data: unknown, status?: number) => Response; + readJsonBody: (request: Request) => Promise>; + resolveWorkspace: (config: ServerConfig, id: string) => Promise; + + /** Per-workspace OpenCode client factory (`createWorkspaceOpencodeClient`). */ + createWorkspaceOpencodeClient: (config: ServerConfig, workspace: WorkspaceInfo) => OpencodeEngineClient; + /** The server's `unwrapOpencodeResult`. */ + unwrapOpencodeResult: UnwrapOpencodeResult; + /** The shared `DeepSeekHarnessRuntime`. */ + deepseekHarness: HarnessRuntimeLike; + + /** + * Returns the full legacy route table, read at request time by `compat`. + * Defaults to the same array v1 is being appended to, which is what makes the + * delegation see every legacy route regardless of registration order. + */ + legacyRoutes?: () => Route[]; + + /** Environment used for the module toggles. Defaults to `process.env`. */ + env?: Record; + /** Overrides the module catalogue. Tests only. */ + modules?: readonly ApiModule[]; + /** Extra or overriding services merged into `ApiModuleContext.services`. Tests only. */ + services?: ApiModuleServices; +} + +export interface RegisterApiV1Result extends ApiModuleRegistryResult { + engines: EngineAdapterRegistry; + /** Present when the `tasks` module is enabled. */ + taskStore?: TaskStore; + /** Present when the `tasks` module is enabled. Call `shutdown()` to abort in-flight runs. */ + taskRunner?: TaskRunner; + /** Present when the `webhooks` module is enabled. */ + webhookStore?: WebhookStoreLike; + /** Detaches the task -> webhook bridge. No-op when the bridge was not installed. */ + dispose: () => void; +} + +/** + * Builds the engine registry. + * + * `DEFAULT_ENGINE_ID` ("opencode") is the default because `WorkspaceWire.engineId` is + * optional and the legacy session routes treat every workspace that is not + * `DEEPSEEK_HARNESS_ENGINE_ID` as an OpenCode workspace. + */ +export function createEngineRegistry(input: { + config: ServerConfig; + createWorkspaceOpencodeClient: (config: ServerConfig, workspace: WorkspaceInfo) => OpencodeEngineClient; + unwrapOpencodeResult: UnwrapOpencodeResult; + deepseekHarness: HarnessRuntimeLike; +}): EngineAdapterRegistry { + return new EngineAdapterRegistry(DEFAULT_ENGINE_ID, [ + createOpencodeEngineAdapter({ + createClient: (workspace) => input.createWorkspaceOpencodeClient(input.config, workspace), + unwrap: input.unwrapOpencodeResult, + }), + createHarnessEngineAdapter({ runtime: input.deepseekHarness }), + ]); +} + +/** + * Builds the per-request token-policy gate. + * + * Enforcement lives at the registry level rather than in individual handlers because a + * restriction that only some routes remember to apply is worse than none: `whoami` reports + * the workspace binding as being in force, so a caller has every reason to believe a token + * scoped to one workspace cannot touch another. + * + * Only the workspace binding and expiry are enforced here. The approval policy is consumed + * where approvals happen, and a route with no `workspaceId` parameter has nothing to bind. + */ +export function createPolicyEnforcer( + store: Pick, +): (ctx: RequestContext) => Promise { + return async (ctx) => { + const tokenHash = ctx.actor?.tokenHash; + // The shared config token has no stored record and therefore no policy — it is the + // deployment-wide credential, and narrowing it is what `POST /tokens` is for. + if (!tokenHash) return; + + const record = await ctx.tokens.findByHash(tokenHash); + if (!record) return; + + const policy = await store.get(record.id); + const workspaceId = ctx.params.workspaceId ?? ctx.params.id; + assertPolicyAllowsWorkspace(policy, workspaceId ?? "", Date.now(), { + // A route that names no workspace still has to honour expiry. + skipWorkspaceCheck: workspaceId === undefined, + }); + }; +} + +/** + * Maps a task store event onto the webhook event name subscribers can name. + * + * Pure and exported so the mapping is testable without a store. `task.updated` and a + * transition to `running` or `cancelled` deliberately produce nothing: neither is in + * `WEBHOOK_TASK_EVENT_TYPES`, and inventing a name here would make a subscription filter + * that cannot be expressed. + */ +export function taskWebhookEventType(event: TaskEvent): string | null { + if (event.type === "task.created") return "task.created"; + if (event.type !== "task.state") return null; + if (event.to === "done") return "task.completed"; + if (event.to === "failed") return "task.failed"; + if (event.to === "awaiting_approval") return "task.awaiting_approval"; + return null; +} + +/** + * Connects the task store's event log to webhook delivery. + * + * Without this the `task.*` events in `WEBHOOK_EVENT_TYPES` would be subscribable but + * never sent. Delivery is fire-and-forget: a task run must not slow down or fail because a + * subscriber's endpoint is slow or gone, so the promise is detached and every rejection is + * swallowed here rather than escaping into the store's listener loop. + */ +export function bridgeTaskEventsToWebhooks(input: { + tasks: TaskStore; + webhooks: WebhookStoreLike; + dispatch?: typeof dispatchWebhookEvent; + allowPrivate?: () => boolean; +}): () => void { + const dispatch = input.dispatch ?? dispatchWebhookEvent; + const allowPrivate = input.allowPrivate ?? webhookPrivateNetworkAllowed; + return input.tasks.subscribe({ + onEvent: (event) => { + const type = taskWebhookEventType(event); + if (!type) return; + try { + void dispatch( + input.webhooks, + { workspaceId: event.task.workspaceId, type, data: serializeTask(event.task) }, + { allowPrivate: allowPrivate() }, + ).catch(() => undefined); + } catch { + // A synchronous throw from a stubbed dispatcher must not break the task run. + } + }, + }); +} + +/** + * Mounts the `/api/v1` surface onto an existing route table. + * + * Call it after the legacy `register*Routes` calls: nothing here depends on registration + * order for matching (appended routes cannot shadow earlier ones), but `compat` re-dispatches + * into the legacy table and must be able to see all of it. + */ +export function registerApiV1(input: RegisterApiV1Input): RegisterApiV1Result { + const env = input.env ?? process.env; + const available = input.modules ?? API_MODULES; + const enabled = resolveEnabledModules(available, { + enabled: env[API_MODULES_ENV] ?? null, + disabled: env[API_MODULES_DISABLED_ENV] ?? null, + }); + const enabledIds = new Set(enabled.map((module) => module.id)); + + const engines = createEngineRegistry({ + config: input.config, + createWorkspaceOpencodeClient: input.createWorkspaceOpencodeClient, + unwrapOpencodeResult: input.unwrapOpencodeResult, + deepseekHarness: input.deepseekHarness, + }); + + const overrides = input.services ?? {}; + + // The stores are owned here rather than inside their modules so the task -> webhook + // bridge can hold the same two instances the request handlers use. + const taskStore = (overrides.taskStore as TaskStore | undefined) + ?? (enabledIds.has("tasks") ? createTaskStore() : undefined); + const taskRunner = (overrides.taskRunner as TaskRunner | undefined) + ?? (taskStore ? createTaskRunner({ store: taskStore }) : undefined); + const webhookStore = (overrides.webhookStore as WebhookStoreLike | undefined) + ?? (enabledIds.has("webhooks") ? new WebhookStore(input.config) : undefined); + const tokenPolicies = (overrides.tokenPolicies as TokenPolicyStore | undefined) + ?? (enabledIds.has("policy") ? new TokenPolicyStore(input.config) : undefined); + + // Resolved after `registerApiModules` returns; the openapi module only reads it per + // request, so a null here means "registration is still in flight" and surfaces as a 500 + // rather than as a document missing half the API. + let registry: ApiModuleRegistryResult | null = null; + + const services: ApiModuleServices = { + engines, + legacyRoutes: input.legacyRoutes ?? (() => input.routes), + getApiRegistry: () => registry, + serverVersion: input.serverVersion, + ...(taskStore ? { taskStore } : {}), + ...(taskRunner ? { taskRunner } : {}), + ...(webhookStore ? { webhookStore } : {}), + ...(tokenPolicies ? { tokenPolicies } : {}), + ...overrides, + }; + + const context: ApiModuleContext = { + config: input.config, + ensureWritable: input.ensureWritable, + requireClientScope: input.requireClientScope, + jsonResponse: input.jsonResponse, + readJsonBody: input.readJsonBody, + resolveWorkspace: input.resolveWorkspace, + ...(tokenPolicies ? { enforcePolicy: createPolicyEnforcer(tokenPolicies) } : {}), + services, + }; + + registry = registerApiModules(input.routes, enabled, context); + + const detach = taskStore && webhookStore + ? bridgeTaskEventsToWebhooks({ tasks: taskStore, webhooks: webhookStore }) + : undefined; + + return { + ...registry, + engines, + ...(taskStore ? { taskStore } : {}), + ...(taskRunner ? { taskRunner } : {}), + ...(webhookStore ? { webhookStore } : {}), + dispose: () => detach?.(), + }; +} diff --git a/apps/server/src/api/module.test.ts b/apps/server/src/api/module.test.ts new file mode 100644 index 000000000..e30c05239 --- /dev/null +++ b/apps/server/src/api/module.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "bun:test"; + +import { isApiError } from "../errors.js"; +import type { RequestContext, Route } from "../routes/registry.js"; +import { matchRoute } from "../routes/registry.js"; +import type { ServerConfig, TokenScope } from "../types.js"; +import { + defaultScopeForEffect, + describeModules, + registerApiModules, + resolveEnabledModules, + type ApiModule, + type ApiModuleContext, + type ApiOperation, +} from "./module.js"; + +const config = { workspaces: [], readOnly: false } as unknown as ServerConfig; + +function createContext(overrides: Partial = {}): ApiModuleContext { + return { + config, + ensureWritable: () => {}, + requireClientScope: () => {}, + jsonResponse: (data, status = 200) => Response.json(data as never, { status }), + readJsonBody: async (request) => (await request.json()) as Record, + resolveWorkspace: async () => ({ id: "w1" }), + services: {}, + ...overrides, + }; +} + +function createContextRecordingGates() { + const calls: string[] = []; + const context = createContext({ + ensureWritable: () => { + calls.push("ensureWritable"); + }, + requireClientScope: (_ctx, required) => { + calls.push(`requireClientScope:${required}`); + }, + }); + return { context, calls }; +} + +function operation(overrides: Partial = {}): ApiOperation { + return { + operationId: "getThing", + method: "GET", + path: "/api/v1/thing", + summary: "Get a thing", + effect: "read", + handler: async () => Response.json({ ok: true }), + ...overrides, + }; +} + +function moduleWith(id: string, operations: ApiOperation[], extra: Partial = {}): ApiModule { + return { + id, + title: id, + description: `${id} module`, + version: "1.0.0", + stability: "stable", + register: () => operations, + ...extra, + }; +} + +function requestContext(request: Request, params: Record = {}): RequestContext { + return { + request, + url: new URL(request.url), + params, + config, + approvals: {} as never, + reloadEvents: {} as never, + tokens: {} as never, + }; +} + +describe("defaultScopeForEffect", () => { + test("reads require viewer and writes require collaborator", () => { + expect(defaultScopeForEffect("read")).toBe("viewer"); + expect(defaultScopeForEffect("write")).toBe("collaborator"); + expect(defaultScopeForEffect("destructive")).toBe("collaborator"); + }); +}); + +describe("resolveEnabledModules", () => { + const available = [moduleWith("sessions", []), moduleWith("tasks", []), moduleWith("webhooks", [])]; + + test("enables everything by default", () => { + expect(resolveEnabledModules(available).map((m) => m.id)).toEqual(["sessions", "tasks", "webhooks"]); + }); + + test("an allowlist narrows the set", () => { + expect(resolveEnabledModules(available, { enabled: "sessions,webhooks" }).map((m) => m.id)) + .toEqual(["sessions", "webhooks"]); + }); + + test("a denylist removes modules", () => { + expect(resolveEnabledModules(available, { disabled: "tasks" }).map((m) => m.id)) + .toEqual(["sessions", "webhooks"]); + }); + + test("the denylist wins over the allowlist", () => { + expect(resolveEnabledModules(available, { enabled: "sessions,tasks", disabled: "tasks" }).map((m) => m.id)) + .toEqual(["sessions"]); + }); + + test("whitespace and empty entries are ignored", () => { + expect(resolveEnabledModules(available, { enabled: " sessions , , tasks " }).map((m) => m.id)) + .toEqual(["sessions", "tasks"]); + }); + + test("an unknown id fails loudly instead of silently dropping a surface", () => { + expect(() => resolveEnabledModules(available, { enabled: "sessions,nope" })).toThrow(/Unknown API module/); + expect(() => resolveEnabledModules(available, { disabled: "nope" })).toThrow(/Unknown API module/); + }); +}); + +describe("registerApiModules", () => { + test("adds one route per operation and reports them", () => { + const routes: Route[] = []; + const result = registerApiModules( + routes, + [moduleWith("thing", [operation(), operation({ operationId: "createThing", method: "POST", effect: "write" })])], + createContext(), + ); + + expect(routes).toHaveLength(2); + expect(result.operations.map((op) => op.operationId)).toEqual(["getThing", "createThing"]); + expect(matchRoute(routes, "GET", "/api/v1/thing")).not.toBeNull(); + expect(matchRoute(routes, "POST", "/api/v1/thing")).not.toBeNull(); + }); + + test("rejects duplicate module ids", () => { + expect(() => registerApiModules([], [moduleWith("dup", []), moduleWith("dup", [])], createContext())) + .toThrow(/Duplicate API module/); + }); + + test("rejects duplicate operation ids across modules", () => { + expect(() => + registerApiModules( + [], + [moduleWith("a", [operation()]), moduleWith("b", [operation({ path: "/api/v1/other" })])], + createContext(), + ), + ).toThrow(/Duplicate API operationId/); + }); + + test("rejects two operations claiming the same method and path", () => { + expect(() => + registerApiModules( + [], + [moduleWith("a", [operation()]), moduleWith("b", [operation({ operationId: "otherId" })])], + createContext(), + ), + ).toThrow(/Duplicate API route/); + }); + + test("rejects an unauthenticated write", () => { + expect(() => + registerApiModules( + [], + [moduleWith("thing", [operation({ auth: "none", effect: "write", method: "POST" })])], + createContext(), + ), + ).toThrow(/auth "none" with a write effect/); + }); + + test("allows an unauthenticated read", () => { + expect(() => + registerApiModules([], [moduleWith("thing", [operation({ auth: "none" })])], createContext()), + ).not.toThrow(); + }); + + test("rejects a module whose dependency is not enabled", () => { + expect(() => + registerApiModules([], [moduleWith("tasks", [], { dependsOn: ["sessions"] })], createContext()), + ).toThrow(/requires sessions/); + }); + + test("accepts a dependency that is enabled", () => { + expect(() => + registerApiModules( + [], + [moduleWith("sessions", []), moduleWith("tasks", [], { dependsOn: ["sessions"] })], + createContext(), + ), + ).not.toThrow(); + }); +}); + +describe("operation gating", () => { + test("read operations skip the write gate and require viewer", async () => { + const routes: Route[] = []; + const { context, calls } = createContextRecordingGates(); + registerApiModules(routes, [moduleWith("thing", [operation()])], context); + + const matched = matchRoute(routes, "GET", "/api/v1/thing"); + await matched!.handler(requestContext(new Request("http://localhost/api/v1/thing"))); + + expect(calls).toEqual(["requireClientScope:viewer"]); + }); + + test("write operations run the write gate before the handler", async () => { + const routes: Route[] = []; + const { context, calls } = createContextRecordingGates(); + registerApiModules( + routes, + [moduleWith("thing", [operation({ operationId: "createThing", method: "POST", effect: "write" })])], + context, + ); + + const matched = matchRoute(routes, "POST", "/api/v1/thing"); + await matched!.handler(requestContext(new Request("http://localhost/api/v1/thing", { method: "POST" }))); + + expect(calls).toEqual(["ensureWritable", "requireClientScope:collaborator"]); + }); + + test("an explicit scope overrides the effect default", async () => { + const routes: Route[] = []; + const { context, calls } = createContextRecordingGates(); + registerApiModules( + routes, + [moduleWith("thing", [operation({ effect: "write", scope: "owner" as TokenScope, method: "POST" })])], + context, + ); + + await matchRoute(routes, "POST", "/api/v1/thing")! + .handler(requestContext(new Request("http://localhost/api/v1/thing", { method: "POST" }))); + + expect(calls).toEqual(["ensureWritable", "requireClientScope:owner"]); + }); + + test("host-authenticated operations skip the client scope check", async () => { + const routes: Route[] = []; + const { context, calls } = createContextRecordingGates(); + registerApiModules( + routes, + [moduleWith("thing", [operation({ auth: "host", effect: "write", method: "POST" })])], + context, + ); + + await matchRoute(routes, "POST", "/api/v1/thing")! + .handler(requestContext(new Request("http://localhost/api/v1/thing", { method: "POST" }))); + + // The write gate still applies; only the client-token scope check is skipped. + expect(calls).toEqual(["ensureWritable"]); + }); + + test("a failing gate prevents the handler from running", async () => { + const routes: Route[] = []; + let handlerRan = false; + const context = createContext({ + requireClientScope: () => { + throw new Error("insufficient scope"); + }, + }); + registerApiModules( + routes, + [moduleWith("thing", [operation({ handler: async () => { + handlerRan = true; + return Response.json({}); + } })])], + context, + ); + + await expect( + matchRoute(routes, "GET", "/api/v1/thing")!.handler(requestContext(new Request("http://localhost/api/v1/thing"))), + ).rejects.toThrow(/insufficient scope/); + expect(handlerRan).toBe(false); + }); + + test("the route is registered with the declared auth mode", () => { + const routes: Route[] = []; + registerApiModules(routes, [moduleWith("thing", [operation({ auth: "host" })])], createContext()); + expect(routes[0]?.auth).toBe("host"); + }); + + test("client is the default auth mode", () => { + const routes: Route[] = []; + registerApiModules(routes, [moduleWith("thing", [operation()])], createContext()); + expect(routes[0]?.auth).toBe("client"); + }); +}); + +describe("registration errors", () => { + test("are ApiErrors so the server reports them with a code", () => { + try { + registerApiModules([], [moduleWith("dup", []), moduleWith("dup", [])], createContext()); + throw new Error("expected a throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + expect((error as { code: string }).code).toBe("api_module_duplicate"); + } + }); +}); + +describe("describeModules", () => { + test("publishes the module and operation catalogue", () => { + const routes: Route[] = []; + const result = registerApiModules( + routes, + [ + moduleWith("sessions", [operation({ operationId: "listSessions", summary: "List sessions" })]), + moduleWith("tasks", [operation({ + operationId: "createTask", + method: "POST", + path: "/api/v1/tasks", + effect: "write", + summary: "Create a task", + streaming: "sse", + deprecated: true, + })], { dependsOn: ["sessions"], stability: "preview" }), + ], + createContext(), + ); + + expect(describeModules(result)).toEqual([ + { + id: "sessions", + title: "sessions", + description: "sessions module", + version: "1.0.0", + stability: "stable", + dependsOn: [], + operations: [{ + operationId: "listSessions", + method: "GET", + path: "/api/v1/thing", + effect: "read", + scope: "viewer", + summary: "List sessions", + streaming: null, + deprecated: false, + }], + }, + { + id: "tasks", + title: "tasks", + description: "tasks module", + version: "1.0.0", + stability: "preview", + dependsOn: ["sessions"], + operations: [{ + operationId: "createTask", + method: "POST", + path: "/api/v1/tasks", + effect: "write", + scope: "collaborator", + summary: "Create a task", + streaming: "sse", + deprecated: true, + }], + }, + ]); + }); +}); diff --git a/apps/server/src/api/module.ts b/apps/server/src/api/module.ts new file mode 100644 index 000000000..aa38fc92e --- /dev/null +++ b/apps/server/src/api/module.ts @@ -0,0 +1,292 @@ +import { ApiError } from "../errors.js"; +import { addRoute, type AuthMode, type RequestContext, type Route } from "../routes/registry.js"; +import type { ServerConfig, TokenScope } from "../types.js"; + +/** + * API modules are the pluggable unit of the public API surface. + * + * A module owns a coherent slice of functionality (sessions, tasks, webhooks, ...), + * declares every operation it exposes, and can be enabled or disabled independently. + * The declaration is the single source of truth: the registry turns it into both the + * live route table and the published OpenAPI document, so the two cannot drift. + * + * The vocabulary deliberately mirrors `ipollowork.plugin.json` resource actions + * (`id` / `title` / `description` / `effect` / `inputSchema`) so a module reads as the + * same species of component as an installable plugin package. + */ + +export type JsonSchema = Record; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * Mirrors the plugin action effect vocabulary from `plugin-service-runtime.ts`. + * `read` operations skip the write gates; `write` and `destructive` require a + * writable server and a sufficient token scope. + */ +export type ApiEffect = "read" | "write" | "destructive"; + +export type ApiStability = "stable" | "preview" | "experimental"; + +export interface ApiResponseSpec { + description: string; + schema?: JsonSchema; + /** Content type override. Defaults to `application/json`, or `text/event-stream` when streaming. */ + contentType?: string; +} + +export interface ApiOperation { + /** Unique across all modules. Becomes the OpenAPI `operationId` and the SDK method name. */ + operationId: string; + method: HttpMethod; + /** Route path in `addRoute` syntax, e.g. `/api/v1/workspaces/:id/sessions`. */ + path: string; + summary: string; + description?: string; + effect: ApiEffect; + /** Route-level auth mode handed to `addRoute`. Defaults to `client`. */ + auth?: AuthMode; + /** + * Minimum client token scope. Enforced before the handler runs. + * Defaults to `viewer` for `read` and `collaborator` for `write` / `destructive`, + * matching the convention in `routes/deepseek-harness.ts`. + */ + scope?: TokenScope; + /** Marks the response as an SSE stream for documentation and SDK generation. */ + streaming?: "sse"; + requestBody?: JsonSchema; + query?: JsonSchema; + pathParams?: JsonSchema; + responses?: Record; + /** Excludes the operation from the published OpenAPI document (still routed). */ + internal?: boolean; + /** Marks a compatibility alias of another operation. */ + deprecated?: boolean; + handler: (ctx: RequestContext) => Promise; +} + +export interface ApiModuleContext { + config: ServerConfig; + /** Throws `403 read_only` when the server runs read-only. */ + ensureWritable: (config: ServerConfig) => void; + /** Throws `401` / `403` when the actor's token scope is insufficient. */ + requireClientScope: (ctx: RequestContext, required: TokenScope) => void; + jsonResponse: (data: unknown, status?: number) => Response; + readJsonBody: (request: Request) => Promise>; + /** Resolves a workspace id from a route param, throwing `404` when unknown. */ + resolveWorkspace: (config: ServerConfig, id: string) => Promise; + /** + * Applies the caller's token policy — workspace binding and expiry — before the handler + * runs. Supplied by the composition root when the `policy` module is enabled. + * + * It belongs here, next to the scope and writability gates, rather than in each handler: + * a per-token restriction that only some routes remembered to check would be worse than + * no restriction, because the API reports it as being in force. + */ + enforcePolicy?: (ctx: RequestContext) => Promise; + /** Everything else a module needs is passed through here by `registerApiModules`. */ + services: ApiModuleServices; +} + +/** + * Late-bound services. Kept as a separate bag so adding a service does not force + * every module signature to change. + */ +export interface ApiModuleServices { + [key: string]: unknown; +} + +export interface ApiModule { + /** Stable identifier, e.g. `sessions`. Used in config toggles and the docs. */ + readonly id: string; + readonly title: string; + readonly description: string; + /** Module version, independent of the server version. */ + readonly version: string; + readonly stability: ApiStability; + /** Module ids that must also be enabled. Enforced at registration time. */ + readonly dependsOn?: readonly string[]; + /** Returns every operation the module exposes. */ + register(context: ApiModuleContext): ApiOperation[]; +} + +export interface RegisteredApiModule { + module: ApiModule; + operations: ApiOperation[]; +} + +export interface ApiModuleRegistryResult { + modules: RegisteredApiModule[]; + operations: ApiOperation[]; +} + +const DEFAULT_SCOPE: Record = { + read: "viewer", + write: "collaborator", + destructive: "collaborator", +}; + +export function defaultScopeForEffect(effect: ApiEffect): TokenScope { + return DEFAULT_SCOPE[effect]; +} + +/** + * Resolves which modules are enabled. + * + * Every module is enabled by default; `IPOLLOWORK_API_MODULES` narrows the set to a + * comma-separated allowlist and `IPOLLOWORK_API_MODULES_DISABLED` removes individual + * modules. An unknown id in either list is a startup error rather than a silent no-op, + * so a typo cannot quietly drop an API surface. + */ +export function resolveEnabledModules( + available: readonly ApiModule[], + input: { enabled?: string | null; disabled?: string | null } = {}, +): ApiModule[] { + const ids = new Set(available.map((module) => module.id)); + const parse = (value: string | null | undefined, label: string): string[] => { + const entries = (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + for (const entry of entries) { + if (!ids.has(entry)) { + throw new ApiError(500, "api_module_unknown", `Unknown API module in ${label}: ${entry}`, { + module: entry, + available: [...ids], + }); + } + } + return entries; + }; + + const allowlist = parse(input.enabled, "IPOLLOWORK_API_MODULES"); + const denylist = new Set(parse(input.disabled, "IPOLLOWORK_API_MODULES_DISABLED")); + + const selected = allowlist.length > 0 + ? available.filter((module) => allowlist.includes(module.id)) + : available.slice(); + + return selected.filter((module) => !denylist.has(module.id)); +} + +/** + * Registers modules onto the shared route table. + * + * Wraps every handler so that scope and writability are enforced from the operation + * declaration rather than repeated in each handler, and so a module cannot accidentally + * expose a write without a gate. + */ +export function registerApiModules( + routes: Route[], + modules: readonly ApiModule[], + context: ApiModuleContext, +): ApiModuleRegistryResult { + const enabledIds = new Set(modules.map((module) => module.id)); + const seenModuleIds = new Set(); + const seenOperationIds = new Set(); + const seenRouteKeys = new Set(); + + const registered: RegisteredApiModule[] = []; + const allOperations: ApiOperation[] = []; + + for (const module of modules) { + if (seenModuleIds.has(module.id)) { + throw new ApiError(500, "api_module_duplicate", `Duplicate API module: ${module.id}`, { module: module.id }); + } + seenModuleIds.add(module.id); + + for (const dependency of module.dependsOn ?? []) { + if (!enabledIds.has(dependency)) { + throw new ApiError( + 500, + "api_module_dependency_missing", + `API module ${module.id} requires ${dependency}, which is not enabled`, + { module: module.id, dependency }, + ); + } + } + + const operations = module.register(context); + + for (const operation of operations) { + if (seenOperationIds.has(operation.operationId)) { + throw new ApiError( + 500, + "api_operation_duplicate", + `Duplicate API operationId: ${operation.operationId}`, + { operationId: operation.operationId, module: module.id }, + ); + } + seenOperationIds.add(operation.operationId); + + // An unauthenticated mutation is never intended, and the mistake is invisible at + // review time: `auth: "none"` reads as "public", `effect: "write"` reads as + // "gated", and only together do they mean "anyone may change this". + if ((operation.auth ?? "client") === "none" && operation.effect !== "read") { + throw new ApiError( + 500, + "api_operation_unauthenticated_write", + `API operation ${operation.operationId} declares auth "none" with a ${operation.effect} effect`, + { operationId: operation.operationId, module: module.id, effect: operation.effect }, + ); + } + + const routeKey = `${operation.method} ${operation.path}`; + if (seenRouteKeys.has(routeKey)) { + throw new ApiError(500, "api_route_duplicate", `Duplicate API route: ${routeKey}`, { + route: routeKey, + module: module.id, + }); + } + seenRouteKeys.add(routeKey); + + addRoute(routes, operation.method, operation.path, operation.auth ?? "client", createGuardedHandler(operation, context)); + allOperations.push(operation); + } + + registered.push({ module, operations }); + } + + return { modules: registered, operations: allOperations }; +} + +function createGuardedHandler(operation: ApiOperation, context: ApiModuleContext): ApiOperation["handler"] { + const scope = operation.scope ?? defaultScopeForEffect(operation.effect); + const needsWriteGate = operation.effect !== "read"; + + return async (ctx: RequestContext) => { + if (needsWriteGate) { + context.ensureWritable(context.config); + } + // `host` and `host-token` routes authenticate as the host rather than as a + // scoped client token, so the client scope check does not apply to them. + const auth = operation.auth ?? "client"; + if (auth === "client") { + context.requireClientScope(ctx, scope); + await context.enforcePolicy?.(ctx); + } + return operation.handler(ctx); + }; +} + +/** Serializable module descriptor, published at `GET /api/v1/modules`. */ +export function describeModules(result: ApiModuleRegistryResult) { + return result.modules.map(({ module, operations }) => ({ + id: module.id, + title: module.title, + description: module.description, + version: module.version, + stability: module.stability, + dependsOn: module.dependsOn ?? [], + operations: operations.map((operation) => ({ + operationId: operation.operationId, + method: operation.method, + path: operation.path, + effect: operation.effect, + scope: operation.scope ?? defaultScopeForEffect(operation.effect), + summary: operation.summary, + streaming: operation.streaming ?? null, + deprecated: operation.deprecated ?? false, + })), + })); +} diff --git a/apps/server/src/api/modules/compat/module.test.ts b/apps/server/src/api/modules/compat/module.test.ts new file mode 100644 index 000000000..4f0ab6329 --- /dev/null +++ b/apps/server/src/api/modules/compat/module.test.ts @@ -0,0 +1,460 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { isApiError } from "../../../errors.js"; +import { addRoute, type RequestContext, type Route } from "../../../routes/registry.js"; +import type { ServerConfig } from "../../../types.js"; +import { registerApiModules, type ApiModuleContext } from "../../module.js"; +import { + buildLegacyPath, + COMPAT_ALIASES, + COMPAT_EXCLUDED_LEGACY_ROUTES, + COMPAT_READ_ONLY_STRICTER, + COMPAT_RESERVED_V1_PREFIXES, + compatModule, + createCompatHandler, + extractPathParams, + toV1Path, + type CompatAlias, +} from "./module.js"; + +const SERVER_SRC = join(import.meta.dir, "..", "..", ".."); + +const LEGACY_ROUTE_FILES = [ + "server.ts", + "routes/core.ts", + "routes/files.ts", + "routes/sessions.ts", + "routes/workspaces.ts", + "routes/operations.ts", + "routes/deepseek-harness.ts", +]; + +interface LegacyRouteFact { + method: string; + path: string; + auth: string; + /** Calls `ensureWritable(config)` unconditionally, as its own statement. */ + ensuresWritable: boolean; + /** Scope demanded unconditionally by the handler itself, if any. */ + handlerScope: string | null; +} + +/** + * Re-derives the legacy route table straight from the source files. + * + * The alias table hard-codes each legacy route's auth mode, write gate and scope; without + * this the two could silently drift, which is exactly the failure mode a compatibility + * layer must not have. + */ +function readLegacyRouteFacts(): LegacyRouteFact[] { + const facts: LegacyRouteFact[] = []; + for (const file of LEGACY_ROUTE_FILES) { + const text = readFileSync(join(SERVER_SRC, file), "utf8"); + const pattern = /addRoute\(\s*routes\s*,\s*(?:"(\w+)"|(\w+))\s*,\s*"([^"]+)"\s*,\s*"([a-z-]+)"/g; + const hits: { index: number; method: string | null; path: string; auth: string }[] = []; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + hits.push({ index: match.index, method: match[1] ?? null, path: match[3], auth: match[4] }); + } + hits.forEach((hit, position) => { + const end = position + 1 < hits.length ? hits[position + 1].index : text.length; + // Four spaces is the top level of a handler body: `addRoute(` is always indented by + // two. Anchoring on it keeps a gate that only runs on some branches - such as the + // `if (persist) ensureWritable(config);` in `POST /workspaces/:id/activate` - from + // being read as an unconditional one. + const lines = text.slice(hit.index, end).split("\n"); + const ensuresWritable = lines.some((line) => /^ {4}ensureWritable\(config\);$/.test(line)); + let handlerScope: string | null = null; + for (const line of lines) { + const scopeMatch = line.match(/^ {4}requireClientScope\(ctx,\s*"(\w+)"\);$/); + if (scopeMatch) { + handlerScope = scopeMatch[1]; + break; + } + } + // A single `addRoute` inside a `for (const method of [...])` loop registers one + // route per method; the only such loop is the MCP proxy. + const methods = hit.method ? [hit.method] : ["GET", "POST", "DELETE"]; + for (const method of methods) { + facts.push({ method, path: hit.path, auth: hit.auth, ensuresWritable, handlerScope }); + } + }); + } + return facts; +} + +const legacyFacts = readLegacyRouteFacts(); +const legacyKey = (method: string, path: string) => `${method} ${path}`; +const legacyByKey = new Map(legacyFacts.map((fact) => [legacyKey(fact.method, fact.path), fact])); + +const config = { workspaces: [], readOnly: false } as unknown as ServerConfig; + +function moduleContext(legacyRoutes: () => Route[], overrides: Partial = {}): ApiModuleContext { + return { + config, + ensureWritable: () => {}, + requireClientScope: () => {}, + jsonResponse: (data, status = 200) => Response.json(data as never, { status }), + readJsonBody: async (request) => (await request.json()) as Record, + resolveWorkspace: async () => ({ id: "w1" }), + services: { legacyRoutes }, + ...overrides, + }; +} + +function requestContext(method: string, path: string, params: Record, body?: string): RequestContext { + const url = new URL(`http://127.0.0.1:8787${path}`); + return { + request: new Request(url, body === undefined ? { method } : { method, body }), + url, + params, + config, + approvals: {} as RequestContext["approvals"], + reloadEvents: {} as RequestContext["reloadEvents"], + tokens: {} as RequestContext["tokens"], + actor: { type: "remote", scope: "collaborator" } as RequestContext["actor"], + }; +} + +function aliasFor(method: string, legacyPath: string): CompatAlias { + const alias = COMPAT_ALIASES.find((entry) => entry.method === method && entry.legacyPath === legacyPath); + if (!alias) throw new Error(`No alias for ${method} ${legacyPath}`); + return alias; +} + +describe("compat alias table", () => { + test("every alias path follows the documented mapping rules", () => { + const wrong = COMPAT_ALIASES.filter((alias) => alias.path !== toV1Path(alias.legacyPath)); + expect(wrong).toEqual([]); + }); + + test("maps a representative sample of legacy paths", () => { + const samples: [string, string, string][] = [ + ["GET", "/workspaces", "/api/v1/workspaces"], + ["POST", "/workspaces/local", "/api/v1/workspaces/local"], + ["DELETE", "/workspaces/:id", "/api/v1/workspaces/:workspaceId"], + ["PATCH", "/workspaces/:id/display-name", "/api/v1/workspaces/:workspaceId/display-name"], + ["GET", "/workspace/:id/config", "/api/v1/workspaces/:workspaceId/config"], + ["DELETE", "/workspace/:id/mcp/:name", "/api/v1/workspaces/:workspaceId/mcp/:name"], + ["GET", "/health", "/api/v1/health"], + ["GET", "/status", "/api/v1/status"], + ["GET", "/capabilities", "/api/v1/capabilities"], + ["GET", "/tokens", "/api/v1/tokens"], + ["DELETE", "/tokens/:id", "/api/v1/tokens/:id"], + ["GET", "/approvals", "/api/v1/approvals"], + ["POST", "/approvals/:id", "/api/v1/approvals/:id"], + ["POST", "/files/sessions/:sessionId/read-batch", "/api/v1/files/sessions/:sessionId/read-batch"], + ["GET", "/files/sessions/:sessionId/catalog/snapshot", "/api/v1/files/sessions/:sessionId/catalog/snapshot"], + ]; + for (const [method, legacyPath, expected] of samples) { + expect({ method, legacyPath, path: aliasFor(method, legacyPath).path }).toEqual({ + method, + legacyPath, + path: expected, + }); + } + }); + + test("route keys and operation ids are unique", () => { + const routeKeys = COMPAT_ALIASES.map((alias) => `${alias.method} ${alias.path}`); + const operationIds = COMPAT_ALIASES.map((alias) => alias.operationId); + expect(new Set(routeKeys).size).toBe(routeKeys.length); + expect(new Set(operationIds).size).toBe(operationIds.length); + expect(operationIds.every((id) => id.startsWith("compat"))).toBe(true); + }); + + test("alias and legacy templates carry the same parameters in the same order", () => { + for (const alias of COMPAT_ALIASES) { + const aliasParams = extractPathParams(alias.path); + const legacyParams = extractPathParams(alias.legacyPath); + expect({ path: alias.path, count: aliasParams.length }).toEqual({ + path: alias.path, + count: legacyParams.length, + }); + } + }); + + test("no alias falls inside a prefix owned by another module", () => { + const colliding = COMPAT_ALIASES.filter((alias) => + COMPAT_RESERVED_V1_PREFIXES.some((prefix) => alias.path === prefix || alias.path.startsWith(`${prefix}/`)), + ); + expect(colliding).toEqual([]); + }); + + test("template-sessions and blueprint routes are not mistaken for reserved session routes", () => { + expect(aliasFor("GET", "/workspace/:id/template-sessions").path).toBe( + "/api/v1/workspaces/:workspaceId/template-sessions", + ); + expect(aliasFor("POST", "/workspace/:id/blueprint/sessions/materialize").path).toBe( + "/api/v1/workspaces/:workspaceId/blueprint/sessions/materialize", + ); + }); +}); + +describe("compat coverage of the legacy route table", () => { + test("every legacy route is either aliased or explicitly excluded", () => { + const aliased = new Set(COMPAT_ALIASES.map((alias) => legacyKey(alias.method, alias.legacyPath))); + const excluded = new Set(COMPAT_EXCLUDED_LEGACY_ROUTES.map((entry) => legacyKey(entry.method, entry.path))); + const unaccounted = legacyFacts + .map((fact) => legacyKey(fact.method, fact.path)) + .filter((key) => !aliased.has(key) && !excluded.has(key)); + expect(unaccounted).toEqual([]); + expect(aliased.size + excluded.size).toBe(legacyFacts.length); + }); + + test("every aliased and excluded entry refers to a real legacy route", () => { + const missing = [ + ...COMPAT_ALIASES.map((alias) => legacyKey(alias.method, alias.legacyPath)), + ...COMPAT_EXCLUDED_LEGACY_ROUTES.map((entry) => legacyKey(entry.method, entry.path)), + ].filter((key) => !legacyByKey.has(key)); + expect(missing).toEqual([]); + }); + + test("excluded routes really are excluded", () => { + const aliased = new Set(COMPAT_ALIASES.map((alias) => legacyKey(alias.method, alias.legacyPath))); + for (const excluded of COMPAT_EXCLUDED_LEGACY_ROUTES) { + const key = legacyKey(excluded.method, excluded.path); + expect({ key, aliased: aliased.has(key) }).toEqual({ key, aliased: false }); + } + const excludedPaths = COMPAT_EXCLUDED_LEGACY_ROUTES.map((entry) => entry.path); + expect(excludedPaths).toContain("/ui"); + expect(excludedPaths).toContain("/w/:id/ui"); + expect(excludedPaths).toContain("/ui/assets/toy.js"); + expect(excludedPaths).toContain("/mcp-proxy/:workspaceId/:name"); + expect(excludedPaths).toContain("/whoami"); + expect(excludedPaths).toContain("/workspace/:id/sessions"); + // The `/opencode/*` proxy never reaches the route table at all. + expect(legacyFacts.some((fact) => fact.path.startsWith("/opencode"))).toBe(false); + }); +}); + +describe("compat gating parity", () => { + test("auth mode is copied verbatim from the legacy route", () => { + const mismatches = COMPAT_ALIASES.filter( + (alias) => legacyByKey.get(legacyKey(alias.method, alias.legacyPath))?.auth !== alias.auth, + ).map((alias) => `${alias.method} ${alias.legacyPath}`); + expect(mismatches).toEqual([]); + }); + + test("declared scope is never stricter than the legacy handler's own check", () => { + const rank: Record = { viewer: 1, collaborator: 2, owner: 3 }; + const stricter = COMPAT_ALIASES.filter((alias) => { + const fact = legacyByKey.get(legacyKey(alias.method, alias.legacyPath)); + if (!fact) return true; + if (fact.auth !== "client") return alias.scope !== "owner"; + const legacyRequirement = fact.handlerScope ?? "viewer"; + return rank[alias.scope] > rank[legacyRequirement]; + }).map((alias) => `${alias.method} ${alias.legacyPath}`); + expect(stricter).toEqual([]); + }); + + test("the write gate is stricter than legacy only for the documented read-only cases", () => { + const stricter = COMPAT_ALIASES.filter((alias) => { + if (alias.effect === "read") return false; + return legacyByKey.get(legacyKey(alias.method, alias.legacyPath))?.ensuresWritable !== true; + }).map((alias) => `${alias.method} ${alias.legacyPath}`); + expect(stricter.sort()).toEqual([...COMPAT_READ_ONLY_STRICTER].sort()); + }); + + test("every alias whose legacy handler gates writes declares a non-read effect", () => { + const understated = COMPAT_ALIASES.filter( + (alias) => + alias.effect === "read" && legacyByKey.get(legacyKey(alias.method, alias.legacyPath))?.ensuresWritable === true, + ).map((alias) => `${alias.method} ${alias.legacyPath}`); + expect(understated).toEqual([]); + }); + + test("effect follows the HTTP method unless the alias is a documented read-shaped POST", () => { + for (const alias of COMPAT_ALIASES) { + if (alias.method === "GET") expect(alias.effect).toBe("read"); + if (alias.method === "DELETE") expect(["destructive", "read"]).toContain(alias.effect); + } + expect(aliasFor("POST", "/workspace/:id/import/preview").effect).toBe("read"); + expect(aliasFor("POST", "/workspace/:id/plugin-packages/validate").effect).toBe("read"); + expect(aliasFor("POST", "/files/sessions/:sessionId/read-batch").effect).toBe("read"); + expect(aliasFor("POST", "/workspace/:id/engine/deepseek-harness/rpc").effect).toBe("read"); + expect(aliasFor("POST", "/files/sessions/:sessionId/write-batch").effect).toBe("write"); + expect(aliasFor("POST", "/workspace/:id/skills").effect).toBe("write"); + expect(aliasFor("DELETE", "/workspace/:id/skills/:name").effect).toBe("destructive"); + }); +}); + +describe("buildLegacyPath", () => { + test("renames :workspaceId back to the legacy :id", () => { + const alias = aliasFor("GET", "/workspace/:id/config"); + expect(buildLegacyPath(alias, { workspaceId: "w1" })).toBe("/workspace/w1/config"); + }); + + test("substitutes multiple parameters positionally and percent-encodes them", () => { + const alias = aliasFor("POST", "/workspace/:id/plugin-packages/:pluginId/authorization/:methodId/start"); + expect(buildLegacyPath(alias, { workspaceId: "w 1", pluginId: "a/b", methodId: "oauth" })).toBe( + "/workspace/w%201/plugin-packages/a%2Fb/authorization/oauth/start", + ); + }); + + test("throws 400 when a path parameter is missing", () => { + const alias = aliasFor("GET", "/workspace/:id/config"); + try { + buildLegacyPath(alias, {}); + throw new Error("expected buildLegacyPath to throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + expect((error as { status: number }).status).toBe(400); + expect((error as { code: string }).code).toBe("compat_missing_path_param"); + } + }); +}); + +describe("compat re-dispatch", () => { + function stubLegacyRoutes() { + const seen: { path: string; params: Record; query: string; body: string; method: string }[] = []; + const routes: Route[] = []; + addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => { + seen.push({ + path: ctx.url.pathname, + params: ctx.params, + query: ctx.url.search, + body: "", + method: ctx.request.method, + }); + return Response.json({ route: "config", workspace: ctx.params.id }); + }); + addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => { + seen.push({ + path: ctx.url.pathname, + params: ctx.params, + query: ctx.url.search, + body: await ctx.request.text(), + method: ctx.request.method, + }); + return Response.json({ route: "skills" }, { status: 201 }); + }); + addRoute(routes, "GET", "/workspace/:id/skills/:name", "client", async (ctx) => + Response.json({ route: "skill", name: ctx.params.name }), + ); + return { routes, seen }; + } + + test("re-dispatches to the matching legacy handler with params preserved", async () => { + const { routes, seen } = stubLegacyRoutes(); + const handler = createCompatHandler(aliasFor("GET", "/workspace/:id/config"), () => routes); + const response = await handler( + requestContext("GET", "/api/v1/workspaces/w1/config?deep=true", { workspaceId: "w1" }), + ); + + expect(await response.json()).toEqual({ route: "config", workspace: "w1" }); + expect(seen).toHaveLength(1); + expect(seen[0].path).toBe("/workspace/w1/config"); + expect(seen[0].params).toEqual({ id: "w1" }); + expect(seen[0].query).toBe("?deep=true"); + }); + + test("preserves method, status and request body", async () => { + const { routes, seen } = stubLegacyRoutes(); + const handler = createCompatHandler(aliasFor("POST", "/workspace/:id/skills"), () => routes); + const response = await handler( + requestContext("POST", "/api/v1/workspaces/w1/skills", { workspaceId: "w1" }, JSON.stringify({ name: "demo" })), + ); + + expect(response.status).toBe(201); + expect(seen[0].method).toBe("POST"); + expect(seen[0].body).toBe(JSON.stringify({ name: "demo" })); + }); + + test("picks the legacy route that matches the alias, not a sibling", async () => { + const { routes } = stubLegacyRoutes(); + const handler = createCompatHandler(aliasFor("GET", "/workspace/:id/skills/:name"), () => routes); + const response = await handler( + requestContext("GET", "/api/v1/workspaces/w1/skills/demo", { workspaceId: "w1", name: "demo" }), + ); + expect(await response.json()).toEqual({ route: "skill", name: "demo" }); + }); + + test("decodes an encoded path parameter exactly as the legacy dispatcher would", async () => { + const { routes } = stubLegacyRoutes(); + const handler = createCompatHandler(aliasFor("GET", "/workspace/:id/skills/:name"), () => routes); + const response = await handler( + requestContext("GET", "/api/v1/workspaces/w1/skills/a%2Fb", { workspaceId: "w1", name: "a/b" }), + ); + expect(await response.json()).toEqual({ route: "skill", name: "a/b" }); + }); + + test("fails loudly when the legacy route is gone", async () => { + const handler = createCompatHandler(aliasFor("GET", "/workspace/:id/config"), () => []); + try { + await handler(requestContext("GET", "/api/v1/workspaces/w1/config", { workspaceId: "w1" })); + throw new Error("expected the handler to throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + expect((error as { code: string }).code).toBe("compat_legacy_route_missing"); + expect((error as { status: number }).status).toBe(500); + } + }); + + test("reads the legacy route table lazily, so it may be populated after registration", async () => { + const routes: Route[] = []; + const handler = createCompatHandler(aliasFor("GET", "/workspace/:id/config"), () => routes); + addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => + Response.json({ late: true, workspace: ctx.params.id }), + ); + const response = await handler(requestContext("GET", "/api/v1/workspaces/w1/config", { workspaceId: "w1" })); + expect(await response.json()).toEqual({ late: true, workspace: "w1" }); + }); +}); + +describe("compat module registration", () => { + test("registers one route per alias with the declared auth mode", () => { + const routes: Route[] = []; + const result = registerApiModules(routes, [compatModule], moduleContext(() => [])); + expect(result.operations).toHaveLength(COMPAT_ALIASES.length); + expect(routes).toHaveLength(COMPAT_ALIASES.length); + const healthOperation = result.operations.find((operation) => operation.path === "/api/v1/health"); + expect(healthOperation?.auth).toBe("none"); + expect(healthOperation?.summary).toBe("Alias of GET /health"); + const hostOperation = result.operations.find((operation) => operation.path === "/api/v1/tokens" && operation.method === "POST"); + expect(hostOperation?.auth).toBe("host"); + }); + + test("refuses to register without the legacy route table", () => { + try { + registerApiModules([], [compatModule], moduleContext(() => [], { services: {} })); + throw new Error("expected registration to throw"); + } catch (error) { + expect(isApiError(error)).toBe(true); + expect((error as { code: string }).code).toBe("compat_legacy_routes_unavailable"); + } + }); + + test("the registry gates apply the declared scope and skip the write gate for read aliases", async () => { + const calls: string[] = []; + const legacy: Route[] = []; + addRoute(legacy, "POST", "/files/sessions/:sessionId/read-batch", "client", async () => Response.json({ ok: true })); + addRoute(legacy, "POST", "/files/sessions/:sessionId/write-batch", "client", async () => Response.json({ ok: true })); + + const routes: Route[] = []; + registerApiModules( + routes, + [compatModule], + moduleContext(() => legacy, { + ensureWritable: () => { + calls.push("ensureWritable"); + }, + requireClientScope: (_ctx, required) => { + calls.push(`scope:${required}`); + }, + }), + ); + + const readRoute = routes.find((route) => route.method === "POST" && route.regex.test("/api/v1/files/sessions/s1/read-batch")); + await readRoute?.handler(requestContext("POST", "/api/v1/files/sessions/s1/read-batch", { sessionId: "s1" }, "{}")); + expect(calls).toEqual(["scope:viewer"]); + + calls.length = 0; + const writeRoute = routes.find((route) => route.method === "POST" && route.regex.test("/api/v1/files/sessions/s1/write-batch")); + await writeRoute?.handler(requestContext("POST", "/api/v1/files/sessions/s1/write-batch", { sessionId: "s1" }, "{}")); + expect(calls).toEqual(["ensureWritable", "scope:collaborator"]); + }); +}); diff --git a/apps/server/src/api/modules/compat/module.ts b/apps/server/src/api/modules/compat/module.ts new file mode 100644 index 000000000..d5438d58b --- /dev/null +++ b/apps/server/src/api/modules/compat/module.ts @@ -0,0 +1,421 @@ +import { ApiError } from "../../../errors.js"; +import { matchRoute, type AuthMode, type RequestContext, type Route } from "../../../routes/registry.js"; +import type { TokenScope } from "../../../types.js"; +import type { ApiEffect, ApiModule, ApiModuleContext, ApiOperation, HttpMethod } from "../../module.js"; + +/** + * The `compat` module republishes the pre-existing (legacy) route table under the + * versioned `/api/v1` prefix, so an integrator has one coherent surface instead of a + * mix of old and new paths. Nothing is reimplemented: every alias re-dispatches into + * the very same legacy handler, so the two paths cannot drift. + * + * Path mapping rules + * ------------------ + * - `/workspaces` -> `/api/v1/workspaces` + * - `/workspaces/:id/...` -> `/api/v1/workspaces/:workspaceId/...` + * - `/workspace/:id/...` -> `/api/v1/workspaces/:workspaceId/...` (singular becomes plural) + * - everything else `/x/...` -> `/api/v1/x/...` (`/tokens`, `/approvals`, + * `/health`, `/status`, `/capabilities`, `/env`, `/files/sessions/...`, ...) + * + * Deliberately NOT aliased (see `COMPAT_EXCLUDED_LEGACY_ROUTES`) + * ------------------------------------------------------------- + * - the toy UI (`/ui`, `/w/:id/ui`, `/ui/assets/*`) - a bundled demo page, not a contract; + * - the raw OpenCode proxy (`/opencode/*`, `/w/:id/opencode/*`) - it forwards an upstream + * product's private surface verbatim and is intercepted in `server.ts` before the route + * table is consulted, so it is not even a `Route` this module could alias; + * - the `/w/:id/...` mount aliases - a base-URL convenience that already duplicates the + * unprefixed routes; a versioned surface should have exactly one spelling per operation; + * - browser redirect targets (`/mcp/oauth/callback`, the unauthenticated plugin + * authorization callback) and the raw `/mcp-proxy/*` passthrough - these URLs are handed + * to third parties or to an MCP transport, so a second spelling buys nothing; + * - `/dev/log` - an unauthenticated local development log sink. + * + * Reserved paths (owned by other modules, see `COMPAT_RESERVED_V1_PREFIXES`) + * ------------------------------------------------------------------------- + * `registerApiModules` throws on a duplicate route, so the legacy session routes and + * `/whoami` are excluded here and re-published by the `sessions` / `policy` modules with + * first-class schemas. + * + * Gating: how double-gating is avoided + * ------------------------------------ + * `registerApiModules` wraps every operation with `ensureWritable` (when `effect !== "read"`) + * and `requireClientScope(scope)` (when `auth === "client"`), while the legacy handlers run + * their own copies of those checks. Double-gating is only a problem if the wrapper is + * *stricter* than the legacy route, so each alias is declared to be at most as strict: + * + * - `auth` is copied verbatim from the legacy `addRoute` call, so the dispatcher performs + * exactly the same authentication (`none` / `client` / `host` / `host-token`). + * - `scope` is the scope the legacy handler itself demands *unconditionally* + * (`requireClientScope(ctx, "collaborator")` as the first statement); where the legacy + * handler applies no check, or applies one only on some branches (e.g. the DeepSeek + * Harness RPC only demands `collaborator` for non-read methods), the alias declares + * `viewer` - the weakest scope any authenticated client token already has - and lets the + * legacy handler perform its own, finer-grained check. For `host` / `host-token` routes + * the wrapper skips the scope check entirely; `owner` is declared for documentation only. + * - `effect` follows the HTTP method (GET -> read, DELETE -> destructive, else write), + * except for the non-GET legacy routes that never mutate anything (validate / preview / + * export / package / batch-read / connectivity probe) or that decide read-vs-write from + * the request payload inside the handler (`/experimental/extensions/call`, the DeepSeek + * Harness RPC envelope). Those declare `read` so the alias does not reject on a read-only + * server what the legacy path serves. + * + * `COMPAT_READ_ONLY_STRICTER` lists the 13 aliases that remain stricter than their legacy + * twin, and only when the server runs read-only: their legacy handler mutates state but + * forgot to call `ensureWritable`. The divergence is fail-closed (never fail-open) and is + * documented rather than silently papered over. + */ + +export interface CompatAlias { + /** Unique across all modules; prefixed with `compat` so it cannot clash with a first-class module. */ + operationId: string; + method: HttpMethod; + /** Path in the legacy route table, in `addRoute` syntax. */ + legacyPath: string; + /** Path published under `/api/v1`, in `addRoute` syntax. */ + path: string; + /** Copied verbatim from the legacy `addRoute` call. */ + auth: AuthMode; + effect: ApiEffect; + /** Never stricter than the legacy handler's own unconditional check. */ + scope: TokenScope; +} + +/** Returns the assembled legacy route table. Late-bound: the table is built after modules register. */ +export type LegacyRoutesProvider = () => Route[]; + +export interface CompatModuleServices { + legacyRoutes: LegacyRoutesProvider; +} + +export const COMPAT_MODULE_ID = "compat"; + +/** Path prefixes owned by other API modules. No alias may fall inside one of these. */ +export const COMPAT_RESERVED_V1_PREFIXES: readonly string[] = [ + "/api/v1/workspaces/:workspaceId/sessions", + "/api/v1/tasks", + "/api/v1/webhooks", + "/api/v1/whoami", + "/api/v1/tokens/:tokenId/policy", +]; + +export type CompatExclusionReason = + | "toy-ui" + | "mount-alias" + | "dev-only" + | "browser-callback" + | "raw-proxy" + | "reserved-sessions" + | "reserved-policy"; + +export interface CompatExcludedRoute { + method: string; + path: string; + reason: CompatExclusionReason; +} + +/** Legacy routes intentionally left off `/api/v1`. */ +export const COMPAT_EXCLUDED_LEGACY_ROUTES: readonly CompatExcludedRoute[] = [ + { method: "GET", path: "/workspace/:id/plugin-packages/:pluginId/authorization/callback", reason: "browser-callback" }, + { method: "GET", path: "/mcp/oauth/callback", reason: "browser-callback" }, + { method: "GET", path: "/mcp-proxy/:workspaceId/:name", reason: "raw-proxy" }, + { method: "POST", path: "/mcp-proxy/:workspaceId/:name", reason: "raw-proxy" }, + { method: "DELETE", path: "/mcp-proxy/:workspaceId/:name", reason: "raw-proxy" }, + { method: "GET", path: "/w/:id/health", reason: "mount-alias" }, + { method: "POST", path: "/dev/log", reason: "dev-only" }, + { method: "GET", path: "/dev/log", reason: "dev-only" }, + { method: "GET", path: "/ui", reason: "toy-ui" }, + { method: "GET", path: "/w/:id/ui", reason: "mount-alias" }, + { method: "GET", path: "/ui/assets/toy.css", reason: "toy-ui" }, + { method: "GET", path: "/ui/assets/toy.js", reason: "toy-ui" }, + { method: "GET", path: "/ui/assets/ipollowork-mark.svg", reason: "toy-ui" }, + { method: "GET", path: "/w/:id/status", reason: "mount-alias" }, + { method: "GET", path: "/w/:id/capabilities", reason: "mount-alias" }, + { method: "GET", path: "/w/:id/workspaces", reason: "mount-alias" }, + { method: "GET", path: "/w/:id/runtime/versions", reason: "mount-alias" }, + { method: "POST", path: "/w/:id/runtime/upgrade", reason: "mount-alias" }, + { method: "GET", path: "/whoami", reason: "reserved-policy" }, + { method: "GET", path: "/workspace/:id/sessions", reason: "reserved-sessions" }, + { method: "GET", path: "/workspace/:id/sessions/:sessionId", reason: "reserved-sessions" }, + { method: "GET", path: "/workspace/:id/sessions/:sessionId/messages", reason: "reserved-sessions" }, + { method: "GET", path: "/workspace/:id/sessions/:sessionId/snapshot", reason: "reserved-sessions" }, + { method: "DELETE", path: "/workspace/:id/sessions/:sessionId", reason: "reserved-sessions" }, +]; + +/** + * `" "` for aliases whose write gate is stricter than the legacy route. + * Every entry mutates state but its legacy handler never calls `ensureWritable`, so the + * alias rejects with `403 read_only` on a read-only server where the legacy path succeeds. + */ +export const COMPAT_READ_ONLY_STRICTER: readonly string[] = [ + "POST /runtime/upgrade", + "POST /experimental/google-workspace/connect/start", + "POST /experimental/google-workspace/disconnect", + "POST /experimental/google-workspace/active-account", + "PUT /env/status", + "POST /voice/realtime/session", + "POST /workspace/:id/files/sessions", + "POST /files/sessions/:sessionId/renew", + "DELETE /files/sessions/:sessionId", + "POST /workspaces/:id/activate", + "POST /workspace/:id/engine/reload", + "POST /approvals/:id", + "POST /workspace/:id/engine/deepseek-harness/respond", +]; + +/** The full legacy -> v1 alias table. */ +export const COMPAT_ALIASES: readonly CompatAlias[] = [ + { operationId: "compatGetWorkspacesByWorkspaceIdTemplates", method: "GET", legacyPath: "/workspace/:id/templates", path: "/api/v1/workspaces/:workspaceId/templates", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdHyperframesCatalog", method: "GET", legacyPath: "/workspace/:id/hyperframes-catalog", path: "/api/v1/workspaces/:workspaceId/hyperframes-catalog", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdTemplatesByTemplateIdCover", method: "GET", legacyPath: "/workspace/:id/templates/:templateId/cover", path: "/api/v1/workspaces/:workspaceId/templates/:templateId/cover", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdTemplatesByTemplateIdPackage", method: "GET", legacyPath: "/workspace/:id/templates/:templateId/package", path: "/api/v1/workspaces/:workspaceId/templates/:templateId/package", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesImport", method: "POST", legacyPath: "/workspace/:id/templates/import", path: "/api/v1/workspaces/:workspaceId/templates/import", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesFromSessionPackage", method: "POST", legacyPath: "/workspace/:id/templates/from-session/package", path: "/api/v1/workspaces/:workspaceId/templates/from-session/package", auth: "client", effect: "read", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesFromSession", method: "POST", legacyPath: "/workspace/:id/templates/from-session", path: "/api/v1/workspaces/:workspaceId/templates/from-session", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesAuthoringSessions", method: "POST", legacyPath: "/workspace/:id/templates/authoring-sessions", path: "/api/v1/workspaces/:workspaceId/templates/authoring-sessions", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesFromSessionValidate", method: "POST", legacyPath: "/workspace/:id/templates/from-session/validate", path: "/api/v1/workspaces/:workspaceId/templates/from-session/validate", auth: "client", effect: "read", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesByTemplateIdInstall", method: "POST", legacyPath: "/workspace/:id/templates/:templateId/install", path: "/api/v1/workspaces/:workspaceId/templates/:templateId/install", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdTemplatesByTemplateId", method: "DELETE", legacyPath: "/workspace/:id/templates/:templateId", path: "/api/v1/workspaces/:workspaceId/templates/:templateId", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplatesByTemplateIdMaterialize", method: "POST", legacyPath: "/workspace/:id/templates/:templateId/materialize", path: "/api/v1/workspaces/:workspaceId/templates/:templateId/materialize", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdTemplateSessionsBySessionIdAdoptVideo", method: "POST", legacyPath: "/workspace/:id/template-sessions/:sessionId/adopt-video", path: "/api/v1/workspaces/:workspaceId/template-sessions/:sessionId/adopt-video", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdTemplateSessions", method: "GET", legacyPath: "/workspace/:id/template-sessions", path: "/api/v1/workspaces/:workspaceId/template-sessions", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdTemplateSessionsBySessionId", method: "GET", legacyPath: "/workspace/:id/template-sessions/:sessionId", path: "/api/v1/workspaces/:workspaceId/template-sessions/:sessionId", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdConfig", method: "GET", legacyPath: "/workspace/:id/config", path: "/api/v1/workspaces/:workspaceId/config", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdDesktopCloudSync", method: "GET", legacyPath: "/workspace/:id/desktop-cloud-sync", path: "/api/v1/workspaces/:workspaceId/desktop-cloud-sync", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdDesktopCloudSync", method: "POST", legacyPath: "/workspace/:id/desktop-cloud-sync", path: "/api/v1/workspaces/:workspaceId/desktop-cloud-sync", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdCloudPlugins", method: "GET", legacyPath: "/workspace/:id/cloud-plugins", path: "/api/v1/workspaces/:workspaceId/cloud-plugins", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdCloudPlugins", method: "POST", legacyPath: "/workspace/:id/cloud-plugins", path: "/api/v1/workspaces/:workspaceId/cloud-plugins", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdClaudePlugins", method: "POST", legacyPath: "/workspace/:id/claude-plugins", path: "/api/v1/workspaces/:workspaceId/claude-plugins", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdCloudPluginsByPluginId", method: "DELETE", legacyPath: "/workspace/:id/cloud-plugins/:pluginId", path: "/api/v1/workspaces/:workspaceId/cloud-plugins/:pluginId", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdPluginPackages", method: "GET", legacyPath: "/workspace/:id/plugin-packages", path: "/api/v1/workspaces/:workspaceId/plugin-packages", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdPluginPackagesCatalog", method: "GET", legacyPath: "/workspace/:id/plugin-packages/catalog", path: "/api/v1/workspaces/:workspaceId/plugin-packages/catalog", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesCatalogByPluginIdInstall", method: "POST", legacyPath: "/workspace/:id/plugin-packages/catalog/:pluginId/install", path: "/api/v1/workspaces/:workspaceId/plugin-packages/catalog/:pluginId/install", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesImportValidate", method: "POST", legacyPath: "/workspace/:id/plugin-packages/import/validate", path: "/api/v1/workspaces/:workspaceId/plugin-packages/import/validate", auth: "client", effect: "read", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesImport", method: "POST", legacyPath: "/workspace/:id/plugin-packages/import", path: "/api/v1/workspaces/:workspaceId/plugin-packages/import", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesValidate", method: "POST", legacyPath: "/workspace/:id/plugin-packages/validate", path: "/api/v1/workspaces/:workspaceId/plugin-packages/validate", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackages", method: "POST", legacyPath: "/workspace/:id/plugin-packages", path: "/api/v1/workspaces/:workspaceId/plugin-packages", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdUpdate", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/update", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/update", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdRollback", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/rollback", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/rollback", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPatchWorkspacesByWorkspaceIdPluginPackagesByPluginId", method: "PATCH", legacyPath: "/workspace/:id/plugin-packages/:pluginId", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPatchWorkspacesByWorkspaceIdPluginPackagesByPluginIdResourcesByResourceId", method: "PATCH", legacyPath: "/workspace/:id/plugin-packages/:pluginId/resources/:resourceId", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/resources/:resourceId", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdPluginPackagesByPluginId", method: "DELETE", legacyPath: "/workspace/:id/plugin-packages/:pluginId", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorization", method: "GET", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationByMethodIdCredentials", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/:methodId/credentials", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/:methodId/credentials", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationByMethodIdStart", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/:methodId/start", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/:methodId/start", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationCallback", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/callback", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/callback", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationDeviceByFlowIdPoll", method: "POST", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/device/:flowId/poll", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/device/:flowId/poll", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationFlowsByFlowId", method: "DELETE", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/flows/:flowId", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/flows/:flowId", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdPluginPackagesByPluginIdAuthorizationByAccountId", method: "DELETE", legacyPath: "/workspace/:id/plugin-packages/:pluginId/authorization/:accountId", path: "/api/v1/workspaces/:workspaceId/plugin-packages/:pluginId/authorization/:accountId", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdAuthorizedFolders", method: "GET", legacyPath: "/workspace/:id/authorized-folders", path: "/api/v1/workspaces/:workspaceId/authorized-folders", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPutWorkspacesByWorkspaceIdAuthorizedFolders", method: "PUT", legacyPath: "/workspace/:id/authorized-folders", path: "/api/v1/workspaces/:workspaceId/authorized-folders", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdRuntimeConfigMigrate", method: "POST", legacyPath: "/workspace/:id/runtime-config/migrate", path: "/api/v1/workspaces/:workspaceId/runtime-config/migrate", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdRuntimeConfig", method: "GET", legacyPath: "/workspace/:id/runtime-config", path: "/api/v1/workspaces/:workspaceId/runtime-config", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdOpencodeConfig", method: "GET", legacyPath: "/workspace/:id/opencode-config", path: "/api/v1/workspaces/:workspaceId/opencode-config", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdOpencodeConfig", method: "POST", legacyPath: "/workspace/:id/opencode-config", path: "/api/v1/workspaces/:workspaceId/opencode-config", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdAudit", method: "GET", legacyPath: "/workspace/:id/audit", path: "/api/v1/workspaces/:workspaceId/audit", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPatchWorkspacesByWorkspaceIdConfig", method: "PATCH", legacyPath: "/workspace/:id/config", path: "/api/v1/workspaces/:workspaceId/config", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdPlugins", method: "GET", legacyPath: "/workspace/:id/plugins", path: "/api/v1/workspaces/:workspaceId/plugins", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdPlugins", method: "POST", legacyPath: "/workspace/:id/plugins", path: "/api/v1/workspaces/:workspaceId/plugins", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdPluginsByName", method: "DELETE", legacyPath: "/workspace/:id/plugins/:name", path: "/api/v1/workspaces/:workspaceId/plugins/:name", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetHubSkills", method: "GET", legacyPath: "/hub/skills", path: "/api/v1/hub/skills", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdSkills", method: "GET", legacyPath: "/workspace/:id/skills", path: "/api/v1/workspaces/:workspaceId/skills", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdSkillsHubByName", method: "POST", legacyPath: "/workspace/:id/skills/hub/:name", path: "/api/v1/workspaces/:workspaceId/skills/hub/:name", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdSkillsByName", method: "GET", legacyPath: "/workspace/:id/skills/:name", path: "/api/v1/workspaces/:workspaceId/skills/:name", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdSkills", method: "POST", legacyPath: "/workspace/:id/skills", path: "/api/v1/workspaces/:workspaceId/skills", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdSkillsByName", method: "DELETE", legacyPath: "/workspace/:id/skills/:name", path: "/api/v1/workspaces/:workspaceId/skills/:name", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdMcp", method: "GET", legacyPath: "/workspace/:id/mcp", path: "/api/v1/workspaces/:workspaceId/mcp", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdExtensionsExport", method: "POST", legacyPath: "/workspace/:id/extensions/export", path: "/api/v1/workspaces/:workspaceId/extensions/export", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdMcp", method: "POST", legacyPath: "/workspace/:id/mcp", path: "/api/v1/workspaces/:workspaceId/mcp", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdMcpByName", method: "DELETE", legacyPath: "/workspace/:id/mcp/:name", path: "/api/v1/workspaces/:workspaceId/mcp/:name", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdMcpByNameEnabled", method: "POST", legacyPath: "/workspace/:id/mcp/:name/enabled", path: "/api/v1/workspaces/:workspaceId/mcp/:name/enabled", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdMcpByNameAuth", method: "DELETE", legacyPath: "/workspace/:id/mcp/:name/auth", path: "/api/v1/workspaces/:workspaceId/mcp/:name/auth", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdMcpByNameAuthStart", method: "POST", legacyPath: "/workspace/:id/mcp/:name/auth/start", path: "/api/v1/workspaces/:workspaceId/mcp/:name/auth/start", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdMcpByNameAuth", method: "GET", legacyPath: "/workspace/:id/mcp/:name/auth", path: "/api/v1/workspaces/:workspaceId/mcp/:name/auth", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdCommands", method: "GET", legacyPath: "/workspace/:id/commands", path: "/api/v1/workspaces/:workspaceId/commands", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdCommands", method: "POST", legacyPath: "/workspace/:id/commands", path: "/api/v1/workspaces/:workspaceId/commands", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatDeleteWorkspacesByWorkspaceIdCommandsByName", method: "DELETE", legacyPath: "/workspace/:id/commands/:name", path: "/api/v1/workspaces/:workspaceId/commands/:name", auth: "client", effect: "destructive", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdExport", method: "GET", legacyPath: "/workspace/:id/export", path: "/api/v1/workspaces/:workspaceId/export", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdImportPreview", method: "POST", legacyPath: "/workspace/:id/import/preview", path: "/api/v1/workspaces/:workspaceId/import/preview", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdImport", method: "POST", legacyPath: "/workspace/:id/import", path: "/api/v1/workspaces/:workspaceId/import", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdBlueprintSessionsMaterialize", method: "POST", legacyPath: "/workspace/:id/blueprint/sessions/materialize", path: "/api/v1/workspaces/:workspaceId/blueprint/sessions/materialize", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetHealth", method: "GET", legacyPath: "/health", path: "/api/v1/health", auth: "none", effect: "read", scope: "owner" }, + { operationId: "compatGetStatus", method: "GET", legacyPath: "/status", path: "/api/v1/status", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetRuntimeVersions", method: "GET", legacyPath: "/runtime/versions", path: "/api/v1/runtime/versions", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostRuntimeUpgrade", method: "POST", legacyPath: "/runtime/upgrade", path: "/api/v1/runtime/upgrade", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatGetCapabilities", method: "GET", legacyPath: "/capabilities", path: "/api/v1/capabilities", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetExperimentalConnectState", method: "GET", legacyPath: "/experimental/connect/state", path: "/api/v1/experimental/connect/state", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPutExperimentalConnectState", method: "PUT", legacyPath: "/experimental/connect/state", path: "/api/v1/experimental/connect/state", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatGetExperimentalExtensionsActions", method: "GET", legacyPath: "/experimental/extensions/actions", path: "/api/v1/experimental/extensions/actions", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostExperimentalExtensionsCall", method: "POST", legacyPath: "/experimental/extensions/call", path: "/api/v1/experimental/extensions/call", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetExperimentalGoogleWorkspaceStatus", method: "GET", legacyPath: "/experimental/google-workspace/status", path: "/api/v1/experimental/google-workspace/status", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostExperimentalGoogleWorkspaceConnectStart", method: "POST", legacyPath: "/experimental/google-workspace/connect/start", path: "/api/v1/experimental/google-workspace/connect/start", auth: "client", effect: "write", scope: "viewer" }, + { operationId: "compatGetExperimentalGoogleWorkspaceConnectStatusByFlowId", method: "GET", legacyPath: "/experimental/google-workspace/connect/status/:flowId", path: "/api/v1/experimental/google-workspace/connect/status/:flowId", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostExperimentalGoogleWorkspaceDisconnect", method: "POST", legacyPath: "/experimental/google-workspace/disconnect", path: "/api/v1/experimental/google-workspace/disconnect", auth: "client", effect: "write", scope: "viewer" }, + { operationId: "compatPostExperimentalGoogleWorkspaceActiveAccount", method: "POST", legacyPath: "/experimental/google-workspace/active-account", path: "/api/v1/experimental/google-workspace/active-account", auth: "client", effect: "write", scope: "viewer" }, + { operationId: "compatPostExperimentalGoogleWorkspaceTest", method: "POST", legacyPath: "/experimental/google-workspace/test", path: "/api/v1/experimental/google-workspace/test", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostExperimentalGoogleWorkspaceSmokeTest", method: "POST", legacyPath: "/experimental/google-workspace/smoke-test", path: "/api/v1/experimental/google-workspace/smoke-test", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspaces", method: "GET", legacyPath: "/workspaces", path: "/api/v1/workspaces", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetTokens", method: "GET", legacyPath: "/tokens", path: "/api/v1/tokens", auth: "host", effect: "read", scope: "owner" }, + { operationId: "compatPostTokens", method: "POST", legacyPath: "/tokens", path: "/api/v1/tokens", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatDeleteTokensById", method: "DELETE", legacyPath: "/tokens/:id", path: "/api/v1/tokens/:id", auth: "host", effect: "destructive", scope: "owner" }, + { operationId: "compatGetEnv", method: "GET", legacyPath: "/env", path: "/api/v1/env", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatGetEnvKeys", method: "GET", legacyPath: "/env/keys", path: "/api/v1/env/keys", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatGetEnvStatus", method: "GET", legacyPath: "/env/status", path: "/api/v1/env/status", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatPutEnvStatus", method: "PUT", legacyPath: "/env/status", path: "/api/v1/env/status", auth: "host-token", effect: "write", scope: "owner" }, + { operationId: "compatGetEnvByKey", method: "GET", legacyPath: "/env/:key", path: "/api/v1/env/:key", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatPutEnv", method: "PUT", legacyPath: "/env", path: "/api/v1/env", auth: "host-token", effect: "write", scope: "owner" }, + { operationId: "compatDeleteEnvByKey", method: "DELETE", legacyPath: "/env/:key", path: "/api/v1/env/:key", auth: "host-token", effect: "destructive", scope: "owner" }, + { operationId: "compatGetAuthorizationServices", method: "GET", legacyPath: "/authorization-services", path: "/api/v1/authorization-services", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatPutAuthorizationServicesByServiceIdCredentials", method: "PUT", legacyPath: "/authorization-services/:serviceId/credentials", path: "/api/v1/authorization-services/:serviceId/credentials", auth: "host-token", effect: "write", scope: "owner" }, + { operationId: "compatPostAuthorizationServicesByServiceIdTest", method: "POST", legacyPath: "/authorization-services/:serviceId/test", path: "/api/v1/authorization-services/:serviceId/test", auth: "host-token", effect: "read", scope: "owner" }, + { operationId: "compatPostVoiceRealtimeSession", method: "POST", legacyPath: "/voice/realtime/session", path: "/api/v1/voice/realtime/session", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatGetWorkspacesByWorkspaceIdInbox", method: "GET", legacyPath: "/workspace/:id/inbox", path: "/api/v1/workspaces/:workspaceId/inbox", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdInboxByInboxId", method: "GET", legacyPath: "/workspace/:id/inbox/:inboxId", path: "/api/v1/workspaces/:workspaceId/inbox/:inboxId", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdInbox", method: "POST", legacyPath: "/workspace/:id/inbox", path: "/api/v1/workspaces/:workspaceId/inbox", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdArtifacts", method: "GET", legacyPath: "/workspace/:id/artifacts", path: "/api/v1/workspaces/:workspaceId/artifacts", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdArtifactsByArtifactId", method: "GET", legacyPath: "/workspace/:id/artifacts/:artifactId", path: "/api/v1/workspaces/:workspaceId/artifacts/:artifactId", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdArtifactsResolve", method: "POST", legacyPath: "/workspace/:id/artifacts/resolve", path: "/api/v1/workspaces/:workspaceId/artifacts/resolve", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdFilesSessions", method: "POST", legacyPath: "/workspace/:id/files/sessions", path: "/api/v1/workspaces/:workspaceId/files/sessions", auth: "client", effect: "write", scope: "viewer" }, + { operationId: "compatPostFilesSessionsBySessionIdRenew", method: "POST", legacyPath: "/files/sessions/:sessionId/renew", path: "/api/v1/files/sessions/:sessionId/renew", auth: "client", effect: "write", scope: "viewer" }, + { operationId: "compatDeleteFilesSessionsBySessionId", method: "DELETE", legacyPath: "/files/sessions/:sessionId", path: "/api/v1/files/sessions/:sessionId", auth: "client", effect: "destructive", scope: "viewer" }, + { operationId: "compatGetFilesSessionsBySessionIdCatalogSnapshot", method: "GET", legacyPath: "/files/sessions/:sessionId/catalog/snapshot", path: "/api/v1/files/sessions/:sessionId/catalog/snapshot", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetFilesSessionsBySessionIdCatalogEvents", method: "GET", legacyPath: "/files/sessions/:sessionId/catalog/events", path: "/api/v1/files/sessions/:sessionId/catalog/events", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostFilesSessionsBySessionIdReadBatch", method: "POST", legacyPath: "/files/sessions/:sessionId/read-batch", path: "/api/v1/files/sessions/:sessionId/read-batch", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostFilesSessionsBySessionIdWriteBatch", method: "POST", legacyPath: "/files/sessions/:sessionId/write-batch", path: "/api/v1/files/sessions/:sessionId/write-batch", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostFilesSessionsBySessionIdOps", method: "POST", legacyPath: "/files/sessions/:sessionId/ops", path: "/api/v1/files/sessions/:sessionId/ops", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdFilesContent", method: "GET", legacyPath: "/workspace/:id/files/content", path: "/api/v1/workspaces/:workspaceId/files/content", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdFilesStat", method: "GET", legacyPath: "/workspace/:id/files/stat", path: "/api/v1/workspaces/:workspaceId/files/stat", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatGetWorkspacesByWorkspaceIdFilesRaw", method: "GET", legacyPath: "/workspace/:id/files/raw", path: "/api/v1/workspaces/:workspaceId/files/raw", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdFilesRaw", method: "POST", legacyPath: "/workspace/:id/files/raw", path: "/api/v1/workspaces/:workspaceId/files/raw", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesByWorkspaceIdFilesContent", method: "POST", legacyPath: "/workspace/:id/files/content", path: "/api/v1/workspaces/:workspaceId/files/content", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatPostWorkspacesLocal", method: "POST", legacyPath: "/workspaces/local", path: "/api/v1/workspaces/local", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatPostWorkspacesRemote", method: "POST", legacyPath: "/workspaces/remote", path: "/api/v1/workspaces/remote", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatPatchWorkspacesByWorkspaceIdDisplayName", method: "PATCH", legacyPath: "/workspaces/:id/display-name", path: "/api/v1/workspaces/:workspaceId/display-name", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatPostWorkspacesByWorkspaceIdActivate", method: "POST", legacyPath: "/workspaces/:id/activate", path: "/api/v1/workspaces/:workspaceId/activate", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatDeleteWorkspacesByWorkspaceId", method: "DELETE", legacyPath: "/workspaces/:id", path: "/api/v1/workspaces/:workspaceId", auth: "host", effect: "destructive", scope: "owner" }, + { operationId: "compatGetWorkspacesByWorkspaceIdEvents", method: "GET", legacyPath: "/workspace/:id/events", path: "/api/v1/workspaces/:workspaceId/events", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdEngineReload", method: "POST", legacyPath: "/workspace/:id/engine/reload", path: "/api/v1/workspaces/:workspaceId/engine/reload", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetApprovals", method: "GET", legacyPath: "/approvals", path: "/api/v1/approvals", auth: "host", effect: "read", scope: "owner" }, + { operationId: "compatPostApprovalsById", method: "POST", legacyPath: "/approvals/:id", path: "/api/v1/approvals/:id", auth: "host", effect: "write", scope: "owner" }, + { operationId: "compatPostWorkspacesByWorkspaceIdEngineDeepseekHarnessRpc", method: "POST", legacyPath: "/workspace/:id/engine/deepseek-harness/rpc", path: "/api/v1/workspaces/:workspaceId/engine/deepseek-harness/rpc", auth: "client", effect: "read", scope: "viewer" }, + { operationId: "compatPostWorkspacesByWorkspaceIdEngineDeepseekHarnessRespond", method: "POST", legacyPath: "/workspace/:id/engine/deepseek-harness/respond", path: "/api/v1/workspaces/:workspaceId/engine/deepseek-harness/respond", auth: "client", effect: "write", scope: "collaborator" }, + { operationId: "compatGetWorkspacesByWorkspaceIdEngineDeepseekHarnessEventsByStream", method: "GET", legacyPath: "/workspace/:id/engine/deepseek-harness/events/:stream", path: "/api/v1/workspaces/:workspaceId/engine/deepseek-harness/events/:stream", auth: "client", effect: "read", scope: "viewer" }, +]; + +/** + * Applies the path mapping rules to a legacy path. Exported so the alias table can be + * checked against the rules rather than trusted. + */ +export function toV1Path(legacyPath: string): string { + if (legacyPath === "/workspaces") return "/api/v1/workspaces"; + if (legacyPath.startsWith("/workspaces/")) { + return `/api/v1/workspaces/${legacyPath.slice("/workspaces/".length).replace(/^:id\b/, ":workspaceId")}`; + } + if (legacyPath.startsWith("/workspace/:id")) { + return `/api/v1/workspaces/:workspaceId${legacyPath.slice("/workspace/:id".length)}`; + } + return `/api/v1${legacyPath}`; +} + +/** Ordered `:param` names of a route path. */ +export function extractPathParams(path: string): string[] { + return [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]); +} + +/** + * Rebuilds the concrete legacy path for a request that arrived on the alias. + * + * The two templates always carry the same parameters in the same order (the v1 path is + * derived from the legacy one), so parameters are substituted positionally; that keeps the + * `:id` -> `:workspaceId` rename from needing a per-route lookup table. + */ +export function buildLegacyPath(alias: CompatAlias, params: Record): string { + const aliasParams = extractPathParams(alias.path); + let index = 0; + return alias.legacyPath.replace(/:([A-Za-z0-9_]+)/g, () => { + const name = aliasParams[index]; + index += 1; + const value = name === undefined ? undefined : params[name]; + if (value === undefined) { + throw new ApiError(400, "compat_missing_path_param", `Missing path parameter: ${name ?? "?"}`, { + operationId: alias.operationId, + path: alias.path, + }); + } + return encodeURIComponent(value); + }); +} + +/** + * Re-dispatches a `/api/v1` request into the legacy route table. + * + * Method, headers and body travel on the original `Request` object, which is passed through + * untouched (no clone, so a streamed body is not buffered). Only `url` and `params` are + * rebuilt for the legacy path; the query string is preserved verbatim. Authentication has + * already happened in the dispatcher against the alias' own `auth` mode, which is identical + * to the legacy route's, so `ctx.actor` is exactly what the legacy handler would have seen. + */ +export function createCompatHandler( + alias: CompatAlias, + legacyRoutes: LegacyRoutesProvider, +): (ctx: RequestContext) => Promise { + return async (ctx: RequestContext) => { + const legacyPath = buildLegacyPath(alias, ctx.params); + const matched = matchRoute(legacyRoutes(), alias.method, legacyPath); + if (!matched) { + throw new ApiError(500, "compat_legacy_route_missing", "Legacy route for this alias is not registered", { + operationId: alias.operationId, + method: alias.method, + legacyPath, + }); + } + const legacyUrl = new URL(ctx.url.href); + legacyUrl.pathname = legacyPath; + return matched.handler({ ...ctx, url: legacyUrl, params: matched.params }); + }; +} + +function resolveLegacyRoutes(context: ApiModuleContext): LegacyRoutesProvider { + const provider = (context.services as Partial).legacyRoutes; + if (typeof provider !== "function") { + throw new ApiError( + 500, + "compat_legacy_routes_unavailable", + "The compat module requires services.legacyRoutes: () => Route[]", + { module: COMPAT_MODULE_ID }, + ); + } + return provider; +} + +export const compatModule: ApiModule = { + id: COMPAT_MODULE_ID, + title: "Legacy compatibility", + description: + "Republishes every legacy iPolloWork route under /api/v1 by re-dispatching into the legacy " + + "route table. Mapping: /workspaces -> /api/v1/workspaces, /workspace/:id/... and " + + "/workspaces/:id/... -> /api/v1/workspaces/:workspaceId/..., everything else keeps its path " + + "under the /api/v1 prefix. The toy UI (/ui, /w/:id/ui, /ui/assets/*), the raw /opencode/* " + + "proxy, the /w/:id/* mount aliases, browser OAuth callbacks, /mcp-proxy/* and /dev/log are " + + "deliberately not aliased: they are not part of a stable public contract. Routes owned by the " + + "sessions and policy modules are excluded to avoid duplicate registrations. Each alias copies " + + "the legacy route's auth mode and declares a scope no stricter than the legacy handler's own " + + "check, so the module gates never reject a request the legacy path would accept.", + version: "1.0.0", + stability: "stable", + register(context: ApiModuleContext): ApiOperation[] { + const legacyRoutes = resolveLegacyRoutes(context); + return COMPAT_ALIASES.map((alias) => ({ + operationId: alias.operationId, + method: alias.method, + path: alias.path, + summary: `Alias of ${alias.method} ${alias.legacyPath}`, + description: + `Compatibility alias. Re-dispatches to the legacy route ${alias.method} ${alias.legacyPath}, ` + + "which stays available at its original path.", + effect: alias.effect, + auth: alias.auth, + scope: alias.scope, + handler: createCompatHandler(alias, legacyRoutes), + })); + }, +}; diff --git a/apps/server/src/api/modules/openapi/module.ts b/apps/server/src/api/modules/openapi/module.ts new file mode 100644 index 000000000..62672eb31 --- /dev/null +++ b/apps/server/src/api/modules/openapi/module.ts @@ -0,0 +1,534 @@ +import { ApiError } from "../../../errors.js"; +import { + describeModules, + type ApiModule, + type ApiModuleContext, + type ApiModuleRegistryResult, + type ApiOperation, + type JsonSchema, +} from "../../module.js"; +import { buildOpenApiDocument, type OpenApiDocument } from "../../openapi.js"; + +/** + * The `openapi` module publishes the API's own description. + * + * It is the one module that has to read the registry it is registered into, so it takes a + * late-bound accessor from the services bag rather than a snapshot: at `register()` time the + * other modules have not necessarily been registered yet, and a snapshot would document a + * half-built API. + */ + +export const OPENAPI_SPEC_PATH = "/api/v1/openapi.json"; +export const API_DOCS_PATH = "/api/v1/docs"; +export const API_MODULES_PATH = "/api/v1/modules"; + +export interface OpenApiModuleServices { + /** + * Resolves the completed registry. Called per request, never at registration time. + * Returning `null` (registration still in flight) surfaces as a 500 rather than a + * document that quietly omits half the API. + */ + getApiRegistry?: () => ApiModuleRegistryResult | null | undefined; + /** Version reported in `info.version`. Defaults to `0.0.0`. */ + serverVersion?: string; +} + +/** Response schema for `listApiModules`, matching `describeModules` exactly. */ +const MODULE_CATALOGUE_SCHEMA: JsonSchema = { + type: "array", + items: { + type: "object", + required: ["id", "title", "description", "version", "stability", "dependsOn", "operations"], + properties: { + id: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + version: { type: "string" }, + stability: { type: "string", enum: ["stable", "preview", "experimental"] }, + dependsOn: { type: "array", items: { type: "string" } }, + operations: { + type: "array", + items: { + type: "object", + required: ["operationId", "method", "path", "effect", "scope", "summary", "streaming", "deprecated"], + properties: { + operationId: { type: "string" }, + method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, + path: { type: "string" }, + effect: { type: "string", enum: ["read", "write", "destructive"] }, + scope: { type: "string", enum: ["viewer", "collaborator", "owner"] }, + summary: { type: "string" }, + streaming: { type: ["string", "null"], enum: ["sse", null] }, + deprecated: { type: "boolean" }, + }, + }, + }, + }, + }, +}; + +export function createOpenApiModule(): ApiModule { + return { + id: "openapi", + title: "API description", + description: + "Publishes the OpenAPI 3.1 document, a dependency-free HTML reference, and the enabled module catalogue.", + version: "1.0.0", + stability: "stable", + register(context: ApiModuleContext): ApiOperation[] { + const services = context.services as OpenApiModuleServices; + // Built documents are cached per registry identity: the generator is deterministic, + // so the only reason to rebuild is a registry that was actually replaced. + let cache: { registry: ApiModuleRegistryResult; document: OpenApiDocument } | null = null; + + const resolveRegistry = (): ApiModuleRegistryResult => { + const registry = services.getApiRegistry?.(); + if (!registry) { + throw new ApiError( + 500, + "openapi_registry_unavailable", + "The API module registry is not available yet, so no description can be produced.", + ); + } + return registry; + }; + + const resolveDocument = (): OpenApiDocument => { + const registry = resolveRegistry(); + if (cache && cache.registry === registry) return cache.document; + const document = buildOpenApiDocument({ + operations: registry.operations, + modules: registry.modules, + serverVersion: services.serverVersion ?? "0.0.0", + }); + cache = { registry, document }; + return document; + }; + + return [ + { + operationId: "getOpenApiDocument", + method: "GET", + path: OPENAPI_SPEC_PATH, + summary: "Get the OpenAPI document", + description: + "Returns the OpenAPI 3.1 description of every enabled, non-internal operation. The output is deterministic: identical input produces byte-identical JSON, so the document can be committed and diffed in CI.", + effect: "read", + responses: { + 200: { + description: "The OpenAPI 3.1 document.", + schema: { type: "object", additionalProperties: true }, + }, + }, + handler: async () => context.jsonResponse(resolveDocument()), + }, + { + operationId: "getApiDocs", + method: "GET", + path: API_DOCS_PATH, + summary: "Browse the API reference", + description: + "A self-contained HTML reference for the OpenAPI document. The page ships no external assets and works offline; the document is embedded so the page renders without a second authenticated request, and a refresh control re-fetches it from the spec endpoint.", + effect: "read", + responses: { + 200: { + description: "The HTML reference page.", + contentType: "text/html", + schema: { type: "string" }, + }, + }, + handler: async () => + new Response( + renderApiDocsHtml({ document: resolveDocument(), specUrl: OPENAPI_SPEC_PATH }), + { + status: 200, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }, + ), + }, + { + operationId: "listApiModules", + method: "GET", + path: API_MODULES_PATH, + summary: "List the enabled API modules", + description: + "The catalogue of enabled modules and the operations each one contributes, including operations marked internal (which the OpenAPI document omits).", + effect: "read", + responses: { + 200: { + description: "The enabled modules, in registration order.", + schema: MODULE_CATALOGUE_SCHEMA, + }, + }, + handler: async () => context.jsonResponse(describeModules(resolveRegistry())), + }, + ]; + }, + }; +} + +/** The module instance registered by the server. */ +export const openApiModule: ApiModule = createOpenApiModule(); + +export interface ApiDocsHtmlInput { + /** The OpenAPI document to embed. */ + document: unknown; + /** Where the refresh control re-fetches the document from. */ + specUrl: string; + /** Page title. Defaults to `iPolloWork API`. */ + title?: string; +} + +/** + * Renders the offline API reference. + * + * No CDN, no bundler, no dependency: the server has to work on a disconnected machine, and a + * docs page that blanks out without network access is worse than no docs page. Everything the + * page needs is in the string this function returns. + * + * Two escaping rules make untrusted text safe here. The embedded document is JSON with `<`, + * `>` and `&` escaped as `\uXXXX`, which is valid JSON and cannot terminate the script block + * — a summary containing `` stays inert. Everything the page then renders goes in + * through `textContent`, never `innerHTML`, so a hostile string cannot become markup. + */ +export function renderApiDocsHtml(input: ApiDocsHtmlInput): string { + const title = input.title ?? "iPolloWork API"; + const embedded = embedJson(input.document); + return ` + + + + +${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+
+ + + + +
+
+ +

+
+ + + + + +`; +} + +/** + * JSON for embedding in a `"; + + function render(): string { + const input = fixture(); + input.modules[1]!.module = moduleOf("sessions", { description: hostile }); + return renderApiDocsHtml({ + document: buildOpenApiDocument(input), + specUrl: "/api/v1/openapi.json", + }); + } + + test("is self-contained: no external asset is referenced", () => { + const html = render(); + expect(html).not.toContain("src="); + expect(html).not.toContain("http://"); + expect(html).not.toContain("https://unpkg"); + expect(html).not.toContain("cdn."); + expect(html).toContain("