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("