diff --git a/bun.lock b/bun.lock index 2d216eed60..5bd675deff 100644 --- a/bun.lock +++ b/bun.lock @@ -393,6 +393,10 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", + "@hono/node-server": "1.19.11", + "@hono/node-ws": "1.3.0", + "@hono/standard-validator": "0.1.5", + "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -433,6 +437,8 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", + "hono": "catalog:", + "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -459,6 +465,7 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", + "zod-to-json-schema": "3.24.5", }, "devDependencies": { "@babel/core": "7.28.4", @@ -491,6 +498,7 @@ "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", + "zod-to-json-schema": "3.24.5", }, }, "packages/plugin": { @@ -1231,6 +1239,8 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], + "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 175c723c5f..9aa4a568da 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -83,6 +88,14 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + OPENCODE_EXPERIMENTAL_HTTPAPI: + truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || + (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c90ae26930..b18880910a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -33,6 +33,11 @@ "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, + "#hono": { + "bun": "./src/server/adapter.bun.ts", + "node": "./src/server/adapter.node.ts", + "default": "./src/server/adapter.bun.ts" + }, "#httpapi-server": { "bun": "./src/server/httpapi-server.node.ts", "node": "./src/server/httpapi-server.node.ts", @@ -69,7 +74,8 @@ "prettier": "3.6.2", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", - "why-is-node-running": "3.2.2" + "why-is-node-running": "3.2.2", + "zod-to-json-schema": "3.24.5" }, "dependencies": { "@actions/core": "1.11.1", @@ -100,6 +106,10 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", + "@hono/node-server": "1.19.11", + "@hono/node-ws": "1.3.0", + "@hono/standard-validator": "0.1.5", + "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -140,6 +150,8 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", + "hono": "catalog:", + "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -165,7 +177,8 @@ "which": "6.0.1", "xdg-basedir": "5.1.0", "yargs": "18.0.0", - "zod": "catalog:" + "zod": "catalog:", + "zod-to-json-schema": "3.24.5" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh new file mode 100755 index 0000000000..b27a31e8c3 --- /dev/null +++ b/packages/opencode/scripts/diff-sdk-types.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Compare SDK types generated from Hono vs HttpApi specs. +# Sorts types alphabetically so only meaningful body differences show. +# +# Usage: ./scripts/diff-sdk-types.sh # full diff +# ./scripts/diff-sdk-types.sh --stat # summary only +set -euo pipefail + +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SDK="$(cd "$DIR/../sdk/js" && pwd)" + +normalize() { + python3 -c " +import re, sys +content = open(sys.argv[1]).read() +blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) +header, body = blocks[0], blocks[1:] +body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') +sys.stdout.write(header + ''.join(body)) +" "$1" +} + +echo "Generating Hono SDK..." >&2 +(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "Generating HttpApi SDK..." >&2 +(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "" >&2 +if [[ "${1:-}" == "--stat" ]]; then + diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true) + honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true) + aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true) + total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ') + echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" + echo "" + if [[ $honly -gt 0 ]]; then + echo "=== Hono-only types ===" + printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /' + echo "" + fi + if [[ $aonly -gt 0 ]]; then + echo "=== HttpApi-only types ===" + printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /' + fi +else + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true +fi diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md new file mode 100644 index 0000000000..99b7f1b156 --- /dev/null +++ b/packages/opencode/specs/effect/http-api.md @@ -0,0 +1,401 @@ +# HttpApi migration + +Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition. + +## End State + +- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`. +- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware. +- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge. +- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline. +- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction. + +## Current State + +- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`. +- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup. +- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation. +- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono. +- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4. +- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module. +- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`. +- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`. +- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`. +- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`). + +## Migration Rules + +- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs. +- Migrate one route group, or one coherent subset of a route group, at a time. +- Reuse existing services. Do not re-architect service logic during HTTP boundary migration. +- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces. +- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected. +- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry. + +## Route Slice Checklist + +Use this checklist for each small HttpApi migration PR: + +1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes. +2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`. +3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block. +4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals. +5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances. +6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation. +7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context. +8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root. + +## Hono Deletion Checklist + +Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough. + +1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status. +2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. +3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed. +4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path. +5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted. +6. The legacy Hono `describeRoute`, validator, and handler for that path are removed. +7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema. +8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects. +9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown. +10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit. + +Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior. + +## Experimental Read Slice Guidance + +For the experimental route group, port read-only JSON routes before mutations: + +- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`. +- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`. +- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior. +- Preserve response headers such as pagination cursors if a route is ported. +- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization. + +## Schema Notes + +- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects. +- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional. +- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful. +- Avoid parallel hand-written Zod and Effect definitions for the same route boundary. + +## Phases + +### 1. Stabilize The Bridge + +Before porting more routes, cover the bridge behavior that every route depends on. + +- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly. +- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials. +- Cover `directory` and `x-opencode-directory` instance selection. +- Verify generated SDK output remains unchanged for non-SDK work. +- Fix or remove any implemented-but-unmounted `HttpApi` groups. + +### 2. Complete The Inventory + +Create a route inventory from the actual Hono registrations and classify each route. + +Statuses: + +- `bridged`: served through the `HttpApi` bridge when the flag is on. +- `implemented`: `HttpApi` group exists but is not mounted through Hono. +- `next`: good JSON candidate for near-term porting. +- `later`: portable, but needs schema/service cleanup first. +- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`. + +### 3. Finish JSON Route Parity + +Port remaining JSON routes in small batches. + +Good near-term candidates: + +- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter` +- simple mutations: `POST /instance/dispose` +- experimental JSON reads: console, tool, worktree list, resource list +- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows + +Keep large or stateful groups for later: + +- `session` +- `sync` +- process-level experimental routes + +### 4. Move OpenAPI And SDK Generation + +Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. + +Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip: + +- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing). +- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing). +- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names). +- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix. +- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output. + +Required before route deletion: + +- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path. +- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. +- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate. +- Compare generated SDK output against `dev` for every route group deletion. +- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. + +V2 cleanup once SDK compatibility no longer needs the legacy Hono contract: + +- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses. +- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape. +- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks. +- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape. +- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec. + +### 5. Make HttpApi Default For JSON Routes + +After JSON parity and SDK generation are covered: + +- Flip the bridge default for ported JSON routes. +- Keep a short-lived fallback flag for the old Hono implementation. +- Run the same tests against both the default and fallback path during rollout. +- Stop adding new Hono handlers for JSON routes once the default flips. + +### 6. Delete Hono Route Implementations + +Delete Hono routes group-by-group after each group meets the deletion criteria. + +Deletion criteria: + +- `HttpApi` route is mounted by default. +- Behavior is covered by bridge-level tests. +- OpenAPI/SDK generation comes from Effect for that path. +- SDK diff is zero or explicitly accepted. +- Legacy Hono route is no longer needed as a fallback. + +After deleting a group: + +- Remove its Hono route file or dead endpoints. +- Remove its `.route(...)` registration from `instance/index.ts`. +- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type. +- Regenerate SDK and verify output. + +### 7. Replace Special Routes + +Special routes need explicit designs before Hono can disappear completely. + +- `event`: SSE +- `pty`: websocket +- `tui`: UI/control bridge behavior +- streaming `session` endpoints + +Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`. + +## Current Route Status + +| Area | Status | Notes | +| ------------------------- | ----------------- | -------------------------------------------------------------------------- | +| `question` | `bridged` | `GET /question`, reply, reject | +| `permission` | `bridged` | list and reply | +| `provider` | `bridged` | list, auth, OAuth authorize/callback | +| `config` | `bridged` | read, providers, update | +| `project` | `bridged` | list, current, git init, update | +| `file` | `bridged` partial | find text/file/symbol, list/content/status | +| `mcp` | `bridged` | status, add, OAuth, connect/disconnect | +| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | +| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | +| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | +| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | +| `sync` | `bridged` | start/replay/history | +| `event` | `bridged` | SSE via raw Effect HTTP | +| `pty` | `special` | websocket | +| `tui` | `special` | UI bridge | + +## Full Route Checklist + +This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above. + +### Top-Level Instance Routes + +- [x] `POST /instance/dispose` - dispose active instance after response. +- [x] `GET /path` - current directory and worktree paths. +- [x] `GET /vcs` - current VCS status. +- [x] `GET /vcs/diff` - VCS diff summary. +- [x] `GET /command` - command catalog. +- [x] `GET /agent` - agent catalog. +- [x] `GET /skill` - skill catalog. +- [x] `GET /lsp` - LSP status. +- [x] `GET /formatter` - formatter status. + +### Config Routes + +- [x] `GET /config` - read config. +- [x] `PATCH /config` - update config and dispose active instance after response. +- [x] `GET /config/providers` - config provider summary. + +### Project Routes + +- [x] `GET /project` - list projects. +- [x] `GET /project/current` - current project. +- [x] `POST /project/git/init` - initialize git and reload active instance after response. +- [x] `PATCH /project/:projectID` - update project metadata. + +### Provider Routes + +- [x] `GET /provider` - list providers. +- [x] `GET /provider/auth` - list provider auth methods. +- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth. +- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth. + +### Question Routes + +- [x] `GET /question` - list questions. +- [x] `POST /question/:requestID/reply` - reply to question. +- [x] `POST /question/:requestID/reject` - reject question. + +### Permission Routes + +- [x] `GET /permission` - list permission requests. +- [x] `POST /permission/:requestID/reply` - reply to permission request. + +### File Routes + +- [x] `GET /find` - text search. +- [x] `GET /find/file` - file search. +- [x] `GET /find/symbol` - symbol search. +- [x] `GET /file` - list directory entries. +- [x] `GET /file/content` - read file content. +- [x] `GET /file/status` - file status. + +### MCP Routes + +- [x] `GET /mcp` - MCP status. +- [x] `POST /mcp` - add MCP server at runtime. +- [x] `POST /mcp/:name/auth` - start MCP OAuth. +- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback. +- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow. +- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials. +- [x] `POST /mcp/:name/connect` - connect MCP server. +- [x] `POST /mcp/:name/disconnect` - disconnect MCP server. + +### Experimental Routes + +- [x] `GET /experimental/console` - active Console provider metadata. +- [x] `GET /experimental/console/orgs` - switchable Console orgs. +- [x] `POST /experimental/console/switch` - switch active Console org. +- [x] `GET /experimental/tool/ids` - tool IDs. +- [x] `GET /experimental/tool` - tools for provider/model. +- [x] `GET /experimental/worktree` - list worktrees. +- [x] `POST /experimental/worktree` - create worktree. +- [x] `DELETE /experimental/worktree` - remove worktree. +- [x] `POST /experimental/worktree/reset` - reset worktree. +- [x] `GET /experimental/session` - global session list. +- [x] `GET /experimental/resource` - MCP resources. + +### Workspace Routes + +- [x] `GET /experimental/workspace/adapter` - list workspace adapters. +- [x] `POST /experimental/workspace` - create workspace. +- [x] `GET /experimental/workspace` - list workspaces. +- [x] `GET /experimental/workspace/status` - workspace status. +- [x] `DELETE /experimental/workspace/:id` - remove workspace. +- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace. + +### Sync Routes + +- [x] `POST /sync/start` - start workspace sync. +- [x] `POST /sync/replay` - replay sync events. +- [x] `POST /sync/history` - list sync event history. + +### Session Routes + +- [x] `GET /session` - list sessions. +- [x] `GET /session/status` - session status map. +- [x] `GET /session/:sessionID` - get session. +- [x] `GET /session/:sessionID/children` - get child sessions. +- [x] `GET /session/:sessionID/todo` - get session todos. +- [x] `POST /session` - create session. +- [x] `DELETE /session/:sessionID` - delete session. +- [x] `PATCH /session/:sessionID` - update session metadata. +- [x] `POST /session/:sessionID/init` - run project init command. +- [x] `POST /session/:sessionID/fork` - fork session. +- [x] `POST /session/:sessionID/abort` - abort session. +- [x] `POST /session/:sessionID/share` - share session. +- [x] `GET /session/:sessionID/diff` - session diff. +- [x] `DELETE /session/:sessionID/share` - unshare session. +- [x] `POST /session/:sessionID/summarize` - summarize session. +- [x] `GET /session/:sessionID/message` - list session messages. +- [x] `GET /session/:sessionID/message/:messageID` - get message. +- [x] `DELETE /session/:sessionID/message/:messageID` - delete message. +- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part. +- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part. +- [x] `POST /session/:sessionID/message` - prompt with streaming response. +- [x] `POST /session/:sessionID/prompt_async` - async prompt. +- [x] `POST /session/:sessionID/command` - run command. +- [x] `POST /session/:sessionID/shell` - run shell command. +- [x] `POST /session/:sessionID/revert` - revert message. +- [x] `POST /session/:sessionID/unrevert` - restore reverted messages. +- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route. + +### Event Routes + +- [x] `GET /event` - SSE event stream via raw Effect HTTP. + +### PTY Routes + +- [x] `GET /pty` - list PTY sessions. +- [x] `POST /pty` - create PTY session. +- [x] `GET /pty/:ptyID` - get PTY session. +- [x] `PUT /pty/:ptyID` - update PTY session. +- [x] `DELETE /pty/:ptyID` - remove PTY session. +- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support. + +### TUI Routes + +- [x] `POST /tui/append-prompt` - append prompt. +- [x] `POST /tui/open-help` - open help. +- [x] `POST /tui/open-sessions` - open sessions. +- [x] `POST /tui/open-themes` - open themes. +- [x] `POST /tui/open-models` - open models. +- [x] `POST /tui/submit-prompt` - submit prompt. +- [x] `POST /tui/clear-prompt` - clear prompt. +- [x] `POST /tui/execute-command` - execute command. +- [x] `POST /tui/show-toast` - show toast. +- [x] `POST /tui/publish` - publish TUI event. +- [x] `POST /tui/select-session` - select session. +- [x] `GET /tui/control/next` - get next TUI request. +- [x] `POST /tui/control/response` - submit TUI control response. + +## Remaining PR Plan + +Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable. + +1. [x] Bridge `PATCH /project/:projectID`. +2. [x] Bridge MCP add/connect/disconnect routes. +3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove. +4. [x] Bridge experimental console switch and tool list routes. +5. [x] Bridge experimental global session list. +6. [x] Bridge workspace create/remove/session-restore routes. +7. [x] Bridge sync start/replay/history routes. +8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. +9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. +10. [x] Bridge remaining session mutation and prompt routes. +11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15. +12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend. +13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend. +14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default. +15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files. + +## Checklist + +- [x] Add first `HttpApi` JSON route slices. +- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.) +- [x] Reuse existing Effect services in handlers. +- [x] Provide auth, instance lookup, and observability in the Effect route layer. +- [x] Centralize auth via Effect `Config` for the Effect backend. +- [x] Support `auth_token` as a query security scheme. +- [x] Add bridge-level auth and instance tests. +- [x] Complete exact Hono route inventory. +- [x] Resolve implemented-but-unmounted route groups. +- [x] Port remaining top-level JSON reads. +- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. +- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default. +- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag. +- [ ] Delete replaced Hono route implementations. +- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono). diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 3533706318..3250c166ab 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,4 +1,6 @@ +import z from "zod" import { Schema } from "effect" +import { zodObject } from "@/util/effect-zod" export type Definition = { type: Type @@ -16,6 +18,23 @@ export function define( return result } +export function payloads() { + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + id: z.string(), + type: z.literal(type), + properties: zodObject(def.properties), + }) + .meta({ + ref: `Event.${def.type}`, + }) + }) + .toArray() +} + export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 2555c3ad7b..cb15b484e3 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,13 +1,28 @@ import { Server } from "../../server/server" import type { CommandModule } from "yargs" -type Args = {} +type Args = { + httpapi: boolean + hono: boolean +} export const GenerateCommand = { command: "generate", - builder: (yargs) => yargs, - handler: async () => { - const specs = (await Server.openapi()) as { paths: Record> } + builder: (yargs) => + yargs + .option("httpapi", { + type: "boolean", + default: false, + description: + "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", + }) + .option("hono", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", + }), + handler: async (args) => { + const specs = args.hono ? await Server.openapiHono() : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b7aa16ff1..ebf6ecc591 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -121,12 +121,12 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate }) // The Effect Schema is the canonical source of truth. The `.zod` compatibility -// surface is derived from it so plugin/SDK Zod consumers keep working without -// a parallel hand-maintained Zod definition. +// surface is derived so existing Hono validators keep working without a parallel +// Zod definition. // // The walker emits `z.object({...})` which is non-strict by default. Config // historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward +// so layer that on after derivation. Re-apply the Config ref afterward // since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac..5acb5c827e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,8 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import { zod } from "@/util/effect-zod" +import { type DeepMutable, withStatics } from "@/util/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, @@ -11,19 +12,21 @@ export const WorkspaceInfo = Schema.Struct({ directory: Schema.NullOr(Schema.String), extra: Schema.NullOr(Schema.Unknown), projectID: ProjectID, -}).annotate({ identifier: "Workspace" }) +}) + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> -export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({ - identifier: "WorkspaceListedInfo", -}) +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) + .annotate({ identifier: "WorkspaceListedInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceListedInfo = DeepMutable> export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, -}) +}).pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e825eaddcf..b30536ec02 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -28,6 +28,8 @@ import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" +import { withStatics } from "@/util/schema" +import { zod as effectZod, zodObject } from "@/util/effect-zod" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -35,7 +37,9 @@ import { InstanceBootstrap } from "@/project/bootstrap" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, timeUsed: Schema.Number, -}).annotate({ identifier: "Workspace" }) +}) + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ @@ -84,14 +88,14 @@ export const CreateInput = Schema.Struct({ branch: Info.fields.branch, projectID: ProjectID, extra: Schema.optional(Info.fields.extra), -}) +}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), -}) +}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts new file mode 100644 index 0000000000..b1f3bae27a --- /dev/null +++ b/packages/opencode/src/server/adapter.bun.ts @@ -0,0 +1,44 @@ +import type { Hono } from "hono" +import { createBunWebSocket } from "hono/bun" +import type { Adapter, FetchApp, Opts } from "./adapter" + +function listen(app: FetchApp, opts: Opts, websocket?: ReturnType["websocket"]) { + const start = (port: number) => { + try { + if (websocket) { + return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port }) + } + return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port }) + } catch { + return + } + } + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) { + throw new Error(`Failed to start server on port ${opts.port}`) + } + if (!server.port) { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + return { + port: server.port, + stop(close?: boolean) { + return Promise.resolve(server.stop(close)) + }, + } +} + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createBunWebSocket() + return { + upgradeWebSocket: ws.upgradeWebSocket, + listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)), + } + }, + createFetch(app) { + return { + listen: (opts) => Promise.resolve(listen(app, opts)), + } + }, +} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts new file mode 100644 index 0000000000..55ced40f77 --- /dev/null +++ b/packages/opencode/src/server/adapter.node.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from "node:events" +import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { createNodeWebSocket } from "@hono/node-ws" +import type { Hono } from "hono" +import type { Adapter, FetchApp, Opts } from "./adapter" + +async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) { + const start = (port: number) => + new Promise((resolve, reject) => { + const server = createAdaptorServer({ fetch: app.fetch }) + const events = server as EventEmitter + inject?.(server) + const fail = (err: Error) => { + cleanup() + reject(err) + } + const ready = () => { + cleanup() + resolve(server) + } + const cleanup = () => { + events.off("error", fail) + events.off("listening", ready) + } + events.once("error", fail) + events.once("listening", ready) + server.listen(port, opts.hostname) + }) + + const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) + const addr = server.address() + if (!addr || typeof addr === "string") { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + + let closing: Promise | undefined + return { + port: addr.port, + stop(close?: boolean) { + closing ??= new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err) + return + } + resolve() + }) + if (close) { + if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { + server.closeAllConnections() + } + if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { + server.closeIdleConnections() + } + } + }) + return closing + }, + } +} + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createNodeWebSocket({ app }) + return { + upgradeWebSocket: ws.upgradeWebSocket, + listen: (opts) => listen(app, opts, ws.injectWebSocket), + } + }, + createFetch(app) { + return { + listen: (opts) => listen(app, opts), + } + }, +} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts new file mode 100644 index 0000000000..7f4edd2c17 --- /dev/null +++ b/packages/opencode/src/server/adapter.ts @@ -0,0 +1,26 @@ +import type { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" + +export type FetchApp = { + fetch(request: Request): Response | Promise +} + +export type Opts = { + port: number + hostname: string +} + +export type Listener = { + port: number + stop: (close?: boolean) => Promise +} + +export interface Runtime { + upgradeWebSocket: UpgradeWebSocket + listen(opts: Opts): Promise +} + +export interface Adapter { + create(app: Hono): Runtime + createFetch(app: FetchApp): Omit +} diff --git a/packages/opencode/src/server/backend.ts b/packages/opencode/src/server/backend.ts new file mode 100644 index 0000000000..f456dc0be5 --- /dev/null +++ b/packages/opencode/src/server/backend.ts @@ -0,0 +1,32 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" + +export type Backend = "effect-httpapi" | "hono" + +export type Selection = { + backend: Backend + reason: "env" | "stable" | "explicit" +} + +export type Attributes = ReturnType + +export function select(): Selection { + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" } + return { backend: "hono", reason: "stable" } +} + +export function attributes(selection: Selection): Record { + return { + "opencode.server.backend": selection.backend, + "opencode.server.backend.reason": selection.reason, + "opencode.installation.channel": InstallationChannel, + "opencode.installation.version": InstallationVersion, + } +} + +export function force(selection: Selection, backend: Backend): Selection { + return { + backend, + reason: selection.backend === backend ? selection.reason : "explicit", + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts new file mode 100644 index 0000000000..506e798187 --- /dev/null +++ b/packages/opencode/src/server/error.ts @@ -0,0 +1,39 @@ +import { resolver } from "hono-openapi" +import z from "zod" +import { NotFoundError } from "@/storage/storage" + +export const ERRORS = { + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + z + .object({ + data: z.any(), + errors: z.array(z.record(z.string(), z.any())), + success: z.literal(false), + }) + .meta({ + ref: "BadRequestError", + }), + ), + }, + }, + }, + 403: { + description: "Forbidden", + }, + 404: { + description: "Not found", + content: { + "application/json": { + schema: resolver(NotFoundError.Schema), + }, + }, + }, +} as const + +export function errors(...codes: number[]) { + return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) +} diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts new file mode 100644 index 0000000000..1b8c42c899 --- /dev/null +++ b/packages/opencode/src/server/fence.ts @@ -0,0 +1,20 @@ +import type { MiddlewareHandler } from "hono" +import * as Log from "@opencode-ai/core/util/log" +import { HEADER, diff, load } from "./shared/fence" + +const log = Log.create({ service: "fence-middleware" }) + +export const FenceMiddleware: MiddlewareHandler = async (c, next) => { + if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() + + const prev = load() + await next() + const current = diff(prev, load()) + + if (Object.keys(current).length > 0) { + log.info("header", { + diff: current, + }) + c.res.headers.set(HEADER, JSON.stringify(current)) + } +} diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts index d6c6cbd2fd..5d29fae33f 100644 --- a/packages/opencode/src/server/httpapi-server.node.ts +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -1,14 +1,13 @@ import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" +import type { Opts } from "./adapter" import { Service } from "./httpapi-server" export { Service } export const name = "node-http-server" -export type Opts = { port: number; hostname: string } - export const layer = (opts: Opts) => { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts new file mode 100644 index 0000000000..160d258796 --- /dev/null +++ b/packages/opencode/src/server/middleware.ts @@ -0,0 +1,91 @@ +import { Provider } from "@/provider/provider" +import { NamedError } from "@opencode-ai/core/util/error" +import { NotFoundError } from "@/storage/storage" +import { Session } from "@/session/session" +import type { ContentfulStatusCode } from "hono/utils/http-status" +import type { ErrorHandler, MiddlewareHandler } from "hono" +import { HTTPException } from "hono/http-exception" +import * as Log from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" +import { basicAuth } from "hono/basic-auth" +import { cors } from "hono/cors" +import { compress } from "hono/compress" +import * as ServerBackend from "./backend" +import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" + +const log = Log.create({ service: "server" }) + +export const ErrorMiddleware: ErrorHandler = (err, c) => { + log.error("failed", { + error: err, + }) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name === "ProviderAuthValidationFailed") status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + if (err instanceof Session.BusyError) { + return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) + } + if (err instanceof HTTPException) return err.getResponse() + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, + }) +} + +export const AuthMiddleware: MiddlewareHandler = (c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + + if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) + + return basicAuth({ username, password })(c, next) +} + +export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler { + return async (c, next) => { + const skip = c.req.path === "/log" + if (skip) return next() + const attributes = { + method: c.req.method, + path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. + ...backendAttributes, + } + log.info("request", attributes) + const timer = log.time("request", attributes) + await next() + timer.stop() + } +} + +export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { + return cors({ + maxAge: 86_400, + origin(input) { + if (isAllowedCorsOrigin(input, opts)) return input + }, + }) +} + +const zipped = compress() +export const CompressionMiddleware: MiddlewareHandler = (c, next) => { + const path = c.req.path + const method = c.req.method + if (path === "/event" || path === "/global/event") return next() + if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() + return zipped(c, next) +} diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts new file mode 100644 index 0000000000..069f308512 --- /dev/null +++ b/packages/opencode/src/server/proxy.ts @@ -0,0 +1,149 @@ +import { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import * as Log from "@opencode-ai/core/util/log" +import * as Fence from "./shared/fence" +import type { WorkspaceID } from "@/control-plane/schema" +import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" +import { ProxyUtil } from "./proxy-util" +import { Effect, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" + +type Msg = string | ArrayBuffer | Uint8Array + +function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) { + if (data instanceof Blob) { + return data.arrayBuffer().then((x) => ws.send(x)) + } + return ws.send(data) +} + +const app = (upgrade: UpgradeWebSocket) => + new Hono().get( + "/__workspace_ws", + upgrade((c) => { + const url = c.req.header("x-opencode-proxy-url") + const queue: Msg[] = [] + let remote: WebSocket | undefined + return { + onOpen(_, ws) { + if (!url) { + ws.close(1011, "missing proxy target") + return + } + remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw)) + remote.binaryType = "arraybuffer" + remote.onopen = () => { + for (const item of queue) remote?.send(item) + queue.length = 0 + } + remote.onmessage = (event) => { + void send(ws, event.data) + } + remote.onerror = () => { + ws.close(1011, "proxy error") + } + remote.onclose = (event) => { + ws.close(event.code, event.reason) + } + }, + onMessage(event) { + const data = event.data + if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return + if (remote?.readyState === WebSocket.OPEN) { + remote.send(data) + return + } + queue.push(data) + }, + onClose(event) { + remote?.close(event.code, event.reason) + }, + } + }), + ) + +const log = Log.create({ service: "server-proxy" }) + +function statusText(response: unknown) { + return (response as { source?: Response }).source?.statusText +} + +export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { + return Effect.gen(function* () { + const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID)) + if (!syncing) { + return new Response(`broken sync connection for workspace: ${workspaceID}`, { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + const response = yield* HttpClient.execute( + HttpClientRequest.make(req.method as never)(url, { + headers: ProxyUtil.headers(req, extra), + body: + req.method === "GET" || req.method === "HEAD" + ? HttpBody.empty + : HttpBody.raw(req.body, { + contentType: req.headers.get("content-type") ?? undefined, + contentLength: req.headers.get("content-length") + ? Number(req.headers.get("content-length")) + : undefined, + }), + }), + ) + const next = new Headers(response.headers as HeadersInit) + const sync = Fence.parse(next) + next.delete("content-encoding") + next.delete("content-length") + + if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal) + const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) + return new Response(body, { + status: response.status, + statusText: statusText(response), + headers: next, + }) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))), + ) +} + +export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { + return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID)) +} + +export function websocket( + upgrade: UpgradeWebSocket, + target: string | URL, + extra: HeadersInit | undefined, + req: Request, + env: unknown, +) { + const proxy = new URL(req.url) + proxy.pathname = "/__workspace_ws" + proxy.search = "" + const next = new Headers(req.headers) + next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target)) + for (const [key, value] of new Headers(extra).entries()) { + next.set(key, value) + } + log.info("proxy websocket", { + request: req.url, + target: String(target), + }) + return app(upgrade).fetch( + new Request(proxy, { + method: req.method, + headers: next, + signal: req.signal, + }), + env as never, + ) +} + +export * as ServerProxy from "./proxy" diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts new file mode 100644 index 0000000000..c5b39abde1 --- /dev/null +++ b/packages/opencode/src/server/routes/control/index.ts @@ -0,0 +1,160 @@ +import { Auth } from "@/auth" +import { AppRuntime } from "@/effect/app-runtime" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { ProviderID } from "@/provider/schema" +import { Hono } from "hono" +import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" +import z from "zod" +import { errors } from "../../error" + +export function ControlPlaneRoutes(): Hono { + const app = new Hono() + return app + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + validator("json", Auth.Info.zod), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(providerID, info) + }), + ) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) + return c.json(true) + }, + ) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use( + validator( + "query", + z.object({ + directory: z.string().optional(), + workspace: z.string().optional(), + }), + ), + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) + + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } + + return c.json(true) + }, + ) +} diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts new file mode 100644 index 0000000000..799294b261 --- /dev/null +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -0,0 +1,228 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Effect } from "effect" +import { listAdapters } from "@/control-plane/adapters" +import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" +import { WorkspaceAdapterEntry } from "@/control-plane/types" +import { zodObject } from "@/util/effect-zod" +import { Instance } from "@/project/instance" +import { Vcs } from "@/project/vcs" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" + +export const WorkspaceRoutes = lazy(() => + new Hono() + .get( + "/adapter", + describeRoute({ + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", + operationId: "experimental.workspace.adapter.list", + responses: { + 200: { + description: "Workspace adapters", + content: { + "application/json": { + schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await listAdapters(Instance.project.id)) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create workspace", + description: "Create a workspace for the current project.", + operationId: "experimental.workspace.create", + responses: { + 200: { + description: "Workspace created", + content: { + "application/json": { + schema: resolver(Workspace.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + Workspace.CreateInput.zodObject.omit({ + projectID: true, + }), + ), + async (c) => { + const body = c.req.valid("json") as Omit + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => + svc.create({ + projectID: Instance.project.id, + ...body, + }), + ), + ) + return c.json(workspace) + }, + ) + .get( + "/", + describeRoute({ + summary: "List workspaces", + description: "List all workspaces.", + operationId: "experimental.workspace.list", + responses: { + 200: { + description: "Workspaces", + content: { + "application/json": { + schema: resolver(z.array(Workspace.Info.zod)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) + }, + ) + .post( + "/sync-list", + describeRoute({ + summary: "Sync workspace list", + description: "Register missing workspaces returned by workspace adapters.", + operationId: "experimental.workspace.syncList", + responses: { + 204: { + description: "Workspace list synced", + }, + }, + }), + async (c) => { + await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project))) + return c.body(null, 204) + }, + ) + .get( + "/status", + describeRoute({ + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + operationId: "experimental.workspace.status", + responses: { + 200: { + description: "Workspace status", + content: { + "application/json": { + schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))), + }, + }, + }, + }, + }), + async (c) => { + const result = await AppRuntime.runPromise( + Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])), + ) + const ids = new Set(result[0].map((item) => item.id)) + return c.json(result[1].filter((item) => ids.has(item.workspaceID))) + }, + ) + .delete( + "/:id", + describeRoute({ + summary: "Remove workspace", + description: "Remove an existing workspace.", + operationId: "experimental.workspace.remove", + responses: { + 200: { + description: "Workspace removed", + content: { + "application/json": { + schema: resolver(Workspace.Info.zod.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: zodObject(Workspace.Info).shape.id, + }), + ), + async (c) => { + const { id } = c.req.valid("param") + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id)))) + }, + ) + .post( + "/warp", + describeRoute({ + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", + responses: { + 204: { + description: "Session warped", + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + copyChanges: z.boolean().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + return AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + copyChanges: body.copyChanges, + }), + ).pipe( + Effect.match({ + onFailure: (error) => { + if (error instanceof Vcs.PatchApplyError) { + return c.json( + { + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }, + 400, + ) + } + return c.json( + { + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }, + 400, + ) + }, + onSuccess: () => c.body(null, 204), + }), + ), + ) + }, + ), +) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts new file mode 100644 index 0000000000..da3614d228 --- /dev/null +++ b/packages/opencode/src/server/routes/global.ts @@ -0,0 +1,286 @@ +import { Hono, type Context } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import { Effect } from "effect" +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" +import { AppRuntime } from "@/effect/app-runtime" +import { AsyncQueue } from "@/util/queue" +import { Installation } from "@/installation" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import { lazy } from "../../util/lazy" +import { Config } from "@/config/config" +import { errors } from "../error" +import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" + +const log = Log.create({ service: "server" }) + +async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + payload: { + id: Bus.createID(), + type: "server.connected", + properties: {}, + }, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + payload: { + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, + }, + }), + ) + }, 10_000) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("global event disconnected") + } + + const unsub = subscribe(q) + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) +} + +export const GlobalRoutes = lazy(() => + new Hono() + .get( + "/health", + describeRoute({ + summary: "Get health", + description: "Get health information about the OpenCode server.", + operationId: "global.health", + responses: { + 200: { + description: "Health information", + content: { + "application/json": { + schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ healthy: true, version: InstallationVersion }) + }, + ) + .get( + "/event", + describeRoute({ + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + operationId: "global.event", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver( + z + .object({ + directory: z.string(), + project: z.string().optional(), + workspace: z.string().optional(), + payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]), + }) + .meta({ + ref: "GlobalEvent", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + log.info("global event connected") + c.header("Cache-Control", "no-cache, no-transform") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + + return streamEvents(c, (q) => { + async function handler(event: any) { + q.push(JSON.stringify(event)) + } + GlobalBus.on("event", handler) + return () => GlobalBus.off("event", handler) + }) + }, + ) + .get( + "/config", + describeRoute({ + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + operationId: "global.config.get", + responses: { + 200: { + description: "Get global config info", + content: { + "application/json": { + schema: resolver(Config.Info.zod), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) + }, + ) + .patch( + "/config", + describeRoute({ + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + operationId: "global.config.update", + responses: { + 200: { + description: "Successfully updated global config", + content: { + "application/json": { + schema: resolver(Config.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Config.Info.zod), + async (c) => { + const config = c.req.valid("json") + const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) + if (result.changed) { + void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( + () => undefined, + ) + } + return c.json(result.info) + }, + ) + .post( + "/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + operationId: "global.dispose", + responses: { + 200: { + description: "Global disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) + return c.json(true) + }, + ) + .post( + "/upgrade", + describeRoute({ + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + operationId: "global.upgrade", + responses: { + 200: { + description: "Upgrade result", + content: { + "application/json": { + schema: resolver( + z.union([ + z.object({ + success: z.literal(true), + version: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), + ]), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + target: z.string().optional(), + }), + ), + async (c) => { + const result = await AppRuntime.runPromise( + Installation.Service.use((svc) => + Effect.gen(function* () { + const method = yield* svc.method() + if (method === "unknown") { + return { success: false as const, status: 400 as const, error: "Unknown installation method" } + } + + const target = c.req.valid("json").target || (yield* svc.latest(method)) + const result = yield* Effect.catch( + svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), + (err) => + Effect.succeed({ + success: false as const, + status: 500 as const, + error: err instanceof Error ? err.message : String(err), + }), + ) + if (!result.success) return result + return { ...result, status: 200 as const } + }), + ), + ) + if (!result.success) { + return c.json({ success: false, error: result.error }, result.status) + } + const target = result.version + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return c.json({ success: true, version: target }) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md new file mode 100644 index 0000000000..c94fa64af7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/AGENTS.md @@ -0,0 +1,8 @@ +# Instance Route Parity + +This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. + +- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. +- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. +- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. +- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts new file mode 100644 index 0000000000..949734f81a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -0,0 +1,109 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" +import { Provider } from "@/provider/provider" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) + +export const ConfigRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + operationId: "config.get", + responses: { + 200: { + description: "Get config info", + content: { + "application/json": { + schema: resolver(Config.Info.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ConfigRoutes.get", c, function* () { + const cfg = yield* Config.Service + return yield* cfg.get() + }), + ) + .patch( + "/", + describeRoute({ + summary: "Update configuration", + description: "Update OpenCode configuration settings and preferences.", + operationId: "config.update", + responses: { + 200: { + description: "Successfully updated config", + content: { + "application/json": { + schema: resolver(Config.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Config.Info.zod), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, + ) + .get( + "/providers", + describeRoute({ + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + operationId: "config.providers", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver(Provider.ConfigProvidersResult.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ConfigRoutes.providers", c, function* () { + const svc = yield* Provider.Service + const providers = yield* svc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts new file mode 100644 index 0000000000..aeb1da5393 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -0,0 +1,90 @@ +import z from "zod" +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import * as Log from "@opencode-ai/core/util/log" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { AsyncQueue } from "@/util/queue" + +const log = Log.create({ service: "server" }) + +export const EventRoutes = () => + new Hono().get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver( + z.union(BusEvent.payloads()).meta({ + ref: "Event", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + c.header("Cache-Control", "no-cache, no-transform") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + id: Bus.createID(), + type: "server.connected", + properties: {}, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, + }), + ) + }, 10_000) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("event disconnected") + } + + const unsub = Bus.subscribeAll((event) => { + q.push(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stop() + } + }) + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) + }, + ) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts new file mode 100644 index 0000000000..7e09fb9ad3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -0,0 +1,419 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import * as EffectZod from "@/util/effect-zod" +import { ProviderID, ModelID } from "@/provider/schema" +import { ToolRegistry } from "@/tool/registry" +import { Worktree } from "@/worktree" +import { Instance } from "@/project/instance" +import { Project } from "@/project/project" +import { MCP } from "@/mcp" +import { Session } from "@/session/session" +import { Config } from "@/config/config" +import { ConsoleState } from "@/config/console-state" +import { Account } from "@/account/account" +import { AccountID, OrgID } from "@/account/schema" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Effect, Option } from "effect" +import { Agent } from "@/agent/agent" +import { jsonRequest, runRequest } from "./trace" + +const ConsoleOrgOption = z.object({ + accountID: z.string(), + accountEmail: z.string(), + accountUrl: z.string(), + orgID: z.string(), + orgName: z.string(), + active: z.boolean(), +}) + +const ConsoleOrgList = z.object({ + orgs: z.array(ConsoleOrgOption), +}) + +const ConsoleSwitchBody = z.object({ + accountID: z.string(), + orgID: z.string(), +}) + +const QueryBoolean = z.union([ + z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), + z.enum(["true", "false"]), +]) + +function queryBoolean(value: z.infer | undefined) { + if (value === undefined) return + return value === true || value === "true" +} + +export const ExperimentalRoutes = lazy(() => + new Hono() + .get( + "/console", + describeRoute({ + summary: "Get active Console provider metadata", + description: "Get the active Console org name and the set of provider IDs managed by that Console org.", + operationId: "experimental.console.get", + responses: { + 200: { + description: "Active Console provider metadata", + content: { + "application/json": { + schema: resolver(ConsoleState.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.console.get", c, function* () { + const config = yield* Config.Service + const account = yield* Account.Service + const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { + concurrency: "unbounded", + }) + return { + ...state, + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }), + ) + .get( + "/console/orgs", + describeRoute({ + summary: "List switchable Console orgs", + description: "Get the available Console orgs across logged-in accounts, including the current active org.", + operationId: "experimental.console.listOrgs", + responses: { + 200: { + description: "Switchable Console orgs", + content: { + "application/json": { + schema: resolver(ConsoleOrgList), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () { + const account = yield* Account.Service + const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { + concurrency: "unbounded", + }) + const info = Option.getOrUndefined(active) + const orgs = groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ) + return { orgs } + }), + ) + .post( + "/console/switch", + describeRoute({ + summary: "Switch active Console org", + description: "Persist a new active Console account/org selection for the current local OpenCode state.", + operationId: "experimental.console.switchOrg", + responses: { + 200: { + description: "Switch success", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", ConsoleSwitchBody), + async (c) => + jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () { + const body = c.req.valid("json") + const account = yield* Account.Service + yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) + return true + }), + ) + .get( + "/tool/ids", + describeRoute({ + summary: "List tool IDs", + description: + "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + operationId: "tool.ids", + responses: { + 200: { + description: "Tool IDs", + content: { + "application/json": { + schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.tool.ids", c, function* () { + const registry = yield* ToolRegistry.Service + return yield* registry.ids() + }), + ) + .get( + "/tool", + describeRoute({ + summary: "List tools", + description: + "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + operationId: "tool.list", + responses: { + 200: { + description: "Tools", + content: { + "application/json": { + schema: resolver( + z + .array( + z + .object({ + id: z.string(), + description: z.string(), + parameters: z.any(), + }) + .meta({ ref: "ToolListItem" }), + ) + .meta({ ref: "ToolList" }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "query", + z.object({ + provider: z.string(), + model: z.string(), + }), + ), + async (c) => { + const { provider, model } = c.req.valid("query") + const tools = await runRequest( + "ExperimentalRoutes.tool.list", + c, + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + return yield* registry.tools({ + providerID: ProviderID.make(provider), + modelID: ModelID.make(model), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + }), + ) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + parameters: EffectZod.toJsonSchema(t.parameters), + })), + ) + }, + ) + .post( + "/worktree", + describeRoute({ + summary: "Create worktree", + description: "Create a new git worktree for the current project and run any configured startup scripts.", + operationId: "worktree.create", + responses: { + 200: { + description: "Worktree created", + content: { + "application/json": { + schema: resolver(Worktree.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.CreateInput.zod.optional()), + async (c) => + jsonRequest("ExperimentalRoutes.worktree.create", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + return yield* svc.create(body) + }), + ) + .get( + "/worktree", + describeRoute({ + summary: "List worktrees", + description: "List all sandbox worktrees for the current project.", + operationId: "worktree.list", + responses: { + 200: { + description: "List of worktree directories", + content: { + "application/json": { + schema: resolver(z.array(z.string())), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.worktree.list", c, function* () { + const svc = yield* Project.Service + return yield* svc.sandboxes(Instance.project.id) + }), + ) + .delete( + "/worktree", + describeRoute({ + summary: "Remove worktree", + description: "Remove a git worktree and delete its branch.", + operationId: "worktree.remove", + responses: { + 200: { + description: "Worktree removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.RemoveInput.zod), + async (c) => + jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () { + const body = c.req.valid("json") + const worktree = yield* Worktree.Service + const project = yield* Project.Service + yield* worktree.remove(body) + yield* project.removeSandbox(Instance.project.id, body.directory) + return true + }), + ) + .post( + "/worktree/reset", + describeRoute({ + summary: "Reset worktree", + description: "Reset a worktree branch to the primary default branch.", + operationId: "worktree.reset", + responses: { + 200: { + description: "Worktree reset", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.ResetInput.zod), + async (c) => + jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + yield* svc.reset(body) + return true + }), + ) + .get( + "/session", + describeRoute({ + summary: "List sessions", + description: + "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + operationId: "experimental.session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.GlobalInfo.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), + start: z.coerce + .number() + .optional() + .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + cursor: z.coerce + .number() + .optional() + .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), + search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), + limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), + archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 100 + const sessions: Session.GlobalInfo[] = [] + for await (const session of Session.listGlobal({ + directory: query.directory, + roots: queryBoolean(query.roots), + start: query.start, + cursor: query.cursor, + search: query.search, + limit: limit + 1, + archived: queryBoolean(query.archived), + })) { + sessions.push(session) + } + const hasMore = sessions.length > limit + const list = hasMore ? sessions.slice(0, limit) : sessions + if (hasMore && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) + .get( + "/resource", + describeRoute({ + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + operationId: "experimental.resource.list", + responses: { + 200: { + description: "MCP resources", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Resource.zod)), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.resource.list", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts new file mode 100644 index 0000000000..d0e9ee6186 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import { LSP } from "@/lsp/lsp" +import { Instance } from "@/project/instance" +import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" + +export const FileRoutes = lazy(() => + new Hono() + .get( + "/find", + describeRoute({ + summary: "Find text", + description: "Search for text patterns across files in the project using ripgrep.", + operationId: "find.text", + responses: { + 200: { + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.SearchMatch.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => + jsonRequest("FileRoutes.findText", c, function* () { + const pattern = c.req.valid("query").pattern + const svc = yield* Ripgrep.Service + const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 }) + return result.items + }), + ) + .get( + "/find/file", + describeRoute({ + summary: "Find files", + description: "Search for files or directories by name or pattern in the project directory.", + operationId: "find.files", + responses: { + 200: { + description: "File paths", + content: { + "application/json": { + schema: resolver(z.string().array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + dirs: z.enum(["true", "false"]).optional(), + type: z.enum(["file", "directory"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + ), + async (c) => + jsonRequest("FileRoutes.findFile", c, function* () { + const query = c.req.valid("query") + const svc = yield* File.Service + return yield* svc.search({ + query: query.query, + limit: query.limit ?? 10, + dirs: query.dirs !== "false", + type: query.type, + }) + }), + ) + .get( + "/find/symbol", + describeRoute({ + summary: "Find symbols", + description: "Search for workspace symbols like functions, classes, and variables using LSP.", + operationId: "find.symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(LSP.Symbol.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + return c.json([]) + }, + ) + .get( + "/file", + describeRoute({ + summary: "List files", + description: "List files and directories in a specified path.", + operationId: "file.list", + responses: { + 200: { + description: "Files and directories", + content: { + "application/json": { + schema: resolver(File.Node.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => + jsonRequest("FileRoutes.list", c, function* () { + const svc = yield* File.Service + return yield* svc.list(c.req.valid("query").path) + }), + ) + .get( + "/file/content", + describeRoute({ + summary: "Read file", + description: "Read the content of a specified file.", + operationId: "file.read", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver(File.Content.zod), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => + jsonRequest("FileRoutes.read", c, function* () { + const svc = yield* File.Service + return yield* svc.read(c.req.valid("query").path) + }), + ) + .get( + "/file/status", + describeRoute({ + summary: "Get file status", + description: "Get the git status of all files in the project.", + operationId: "file.status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver(File.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("FileRoutes.status", c, function* () { + const svc = yield* File.Service + return yield* svc.status() + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index bdd917e4e8..9b7af481cf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -22,7 +22,8 @@ import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" -// SSE event schemas built from the BusEvent/SyncEvent registries. +// SSE event schemas built from the same BusEvent/SyncEvent registries that +// the Hono spec uses, so both specs emit identical Event/SyncEvent components. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const SyncEventSchemas = SyncEvent.effectPayloads() diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964..8ab43f6654 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,5 +1,4 @@ import { TuiEvent } from "@/cli/cmd/tui/event" -import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -10,6 +9,10 @@ import { described } from "./metadata" const root = "/tui" export const CommandPayload = Schema.Struct({ command: Schema.String }) +export const TuiRequestPayload = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, +}) const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4..eac579d7dc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -80,6 +80,7 @@ import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/ins import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import * as ServerBackend from "@/server/backend" import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" @@ -90,7 +91,8 @@ export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ "opencode.server.backend": "effect-httpapi" }) + const selected = ServerBackend.select() + yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi"))) return yield* effect }), ), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts new file mode 100644 index 0000000000..b6bf8baa74 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -0,0 +1,502 @@ +import { describeRoute, resolver, validator } from "hono-openapi" +import { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import z from "zod" +import { Format } from "@/format" +import { TuiRoutes } from "./tui" +import { Instance } from "@/project/instance" +import { InstanceRuntime } from "@/project/instance-runtime" +import { Vcs } from "@/project/vcs" +import { Agent } from "@/agent/agent" +import { Skill } from "@/skill" +import { Global } from "@opencode-ai/core/global" +import { LSP } from "@/lsp/lsp" +import { Command } from "@/command" +import { QuestionRoutes } from "./question" +import { PermissionRoutes } from "./permission" +import { ProjectRoutes } from "./project" +import { SessionRoutes } from "./session" +import { PtyRoutes } from "./pty" +import { McpRoutes } from "./mcp" +import { FileRoutes } from "./file" +import { ConfigRoutes } from "./config" +import { ExperimentalRoutes } from "./experimental" +import { ProviderRoutes } from "./provider" +import { EventRoutes } from "./event" +import { SyncRoutes } from "./sync" +import { InstanceMiddleware } from "./middleware" +import { jsonRequest, runRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" +import { errors } from "@/server/error" + +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { + const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler(opts).handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) + } + + return app + .route("/project", ProjectRoutes()) + .route("/pty", PtyRoutes(upgrade, opts)) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) + .route("/question", QuestionRoutes()) + .route("/provider", ProviderRoutes()) + .route("/sync", SyncRoutes()) + .route("/", FileRoutes()) + .route("/", EventRoutes()) + .route("/mcp", McpRoutes()) + .route("/tui", TuiRoutes()) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await InstanceRuntime.disposeInstance(Instance.current) + return c.json(true) + }, + ) + .get( + "/path", + describeRoute({ + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + operationId: "path.get", + responses: { + 200: { + description: "Path", + content: { + "application/json": { + schema: resolver( + z + .object({ + home: z.string(), + state: z.string(), + config: z.string(), + worktree: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Path", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: Instance.worktree, + directory: Instance.directory, + }) + }, + ) + .get( + "/vcs", + describeRoute({ + summary: "Get VCS info", + description: "Retrieve version control system (VCS) information for the current project, such as git branch.", + operationId: "vcs.get", + responses: { + 200: { + description: "VCS info", + content: { + "application/json": { + schema: resolver(Vcs.Info.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.vcs.get", c, function* () { + const vcs = yield* Vcs.Service + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { + concurrency: 2, + }) + return { branch, default_branch } + }), + ) + .get( + "/vcs/diff", + describeRoute({ + summary: "Get VCS diff", + description: "Retrieve the current git diff for the working tree or against the default branch.", + operationId: "vcs.diff", + responses: { + 200: { + description: "VCS diff", + content: { + "application/json": { + schema: resolver(Vcs.FileDiff.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + mode: Vcs.Mode.zod, + }), + ), + async (c) => + jsonRequest("InstanceRoutes.vcs.diff", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff(c.req.valid("query").mode) + }), + ) + .get( + "/vcs/status", + describeRoute({ + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + operationId: "vcs.status", + responses: { + 200: { + description: "VCS status", + content: { + "application/json": { + schema: resolver(Vcs.FileStatus.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.vcs.status", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.status() + }), + ) + .get( + "/vcs/diff/raw", + describeRoute({ + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + operationId: "vcs.diff.raw", + responses: { + 200: { + description: "Raw VCS diff", + content: { + "text/x-diff": { + schema: resolver(z.string()), + }, + }, + }, + }, + }), + async (c) => { + const patch = await runRequest( + "InstanceRoutes.vcs.diffRaw", + c, + Vcs.Service.use((vcs) => vcs.diffRaw()), + ) + return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) + }, + ) + .post( + "/vcs/apply", + describeRoute({ + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + operationId: "vcs.apply", + responses: { + 200: { + description: "VCS patch applied", + content: { + "application/json": { + schema: resolver(Vcs.ApplyResult.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Vcs.ApplyInput.zodObject), + async (c) => { + const result = await runRequest( + "InstanceRoutes.vcs.apply", + c, + Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ), + ) + if (result.ok) return c.json(result.value) + return c.json( + { + name: "VcsApplyError", + data: { + message: result.error.message, + reason: result.error.reason, + }, + }, + 400, + ) + }, + ) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.command.list", c, function* () { + const svc = yield* Command.Service + return yield* svc.list() + }), + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.agent.list", c, function* () { + const svc = yield* Agent.Service + return yield* svc.list() + }), + ) + .get( + "/skill", + describeRoute({ + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + operationId: "app.skills", + responses: { + 200: { + description: "List of skills", + content: { + "application/json": { + schema: resolver(Skill.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.skill.list", c, function* () { + const skill = yield* Skill.Service + return yield* skill.all() + }), + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.lsp.status", c, function* () { + const lsp = yield* LSP.Service + return yield* lsp.status() + }), + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.formatter.status", c, function* () { + const svc = yield* Format.Service + return yield* svc.status() + }), + ) +} diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts new file mode 100644 index 0000000000..d5542f042b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -0,0 +1,277 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { MCP } from "@/mcp" +import { ConfigMCP } from "@/config/mcp" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Effect } from "effect" +import { jsonRequest, runRequest } from "./trace" + +const UnsupportedOAuthError = z + .object({ + error: z.string(), + }) + .meta({ ref: "McpUnsupportedOAuthError" }) + +const unsupportedOAuthErrorResponse = { + description: "MCP server does not support OAuth", + content: { + "application/json": { + schema: resolver(UnsupportedOAuthError), + }, + }, +} + +export const McpRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "Get MCP status", + description: "Get the status of all Model Context Protocol (MCP) servers.", + operationId: "mcp.status", + responses: { + 200: { + description: "MCP server status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status.zod)), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("McpRoutes.status", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.status() + }), + ) + .post( + "/", + describeRoute({ + summary: "Add MCP server", + description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status.zod)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: ConfigMCP.Info.zod, + }), + ), + async (c) => + jsonRequest("McpRoutes.add", c, function* () { + const { name, config } = c.req.valid("json") + const mcp = yield* MCP.Service + const result = yield* mcp.add(name, config) + return result.status + }), + ) + .post( + "/:name/auth", + describeRoute({ + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + 400: unsupportedOAuthErrorResponse, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + const result = await runRequest( + "McpRoutes.auth.start", + c, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + auth: yield* mcp.startAuth(name), + } + }), + ) + if (!result.supports) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + return c.json(result.auth) + }, + ) + .post( + "/:name/auth/callback", + describeRoute({ + summary: "Complete MCP OAuth", + description: + "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => + jsonRequest("McpRoutes.auth.callback", c, function* () { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const mcp = yield* MCP.Service + return yield* mcp.finishAuth(name, code) + }), + ) + .post( + "/:name/auth/authenticate", + describeRoute({ + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status.zod), + }, + }, + }, + 400: unsupportedOAuthErrorResponse, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + const result = await runRequest( + "McpRoutes.auth.authenticate", + c, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + status: yield* mcp.authenticate(name), + } + }), + ) + if (!result.supports) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + return c.json(result.status) + }, + ) + .delete( + "/:name/auth", + describeRoute({ + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => + jsonRequest("McpRoutes.auth.remove", c, function* () { + const name = c.req.param("name") + const mcp = yield* MCP.Service + yield* mcp.removeAuth(name) + return { success: true as const } + }), + ) + .post( + "/:name/connect", + describeRoute({ + description: "Connect an MCP server", + operationId: "mcp.connect", + responses: { + 200: { + description: "MCP server connected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => + jsonRequest("McpRoutes.connect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.connect(name) + return true + }), + ) + .post( + "/:name/disconnect", + describeRoute({ + description: "Disconnect an MCP server", + operationId: "mcp.disconnect", + responses: { + 200: { + description: "MCP server disconnected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => + jsonRequest("McpRoutes.disconnect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.disconnect(name) + return true + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts new file mode 100644 index 0000000000..23707faf79 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -0,0 +1,32 @@ +import type { MiddlewareHandler } from "hono" +import { WithInstance } from "@/project/with-instance" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { WorkspaceID } from "@/control-plane/schema" + +export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { + return async (c, next) => { + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = AppFileSystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return WithInstance.provide({ + directory, + async fn() { + return next() + }, + }) + }, + }) + } +} diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts new file mode 100644 index 0000000000..c18f4734b4 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/permission.ts @@ -0,0 +1,73 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" + +export const PermissionRoutes = lazy(() => + new Hono() + .post( + "/:requestID/reply", + describeRoute({ + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.reply", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: PermissionID.zod, + }), + ), + validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), + async (c) => + jsonRequest("PermissionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return true + }), + ) + .get( + "/", + describeRoute({ + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + operationId: "permission.list", + responses: { + 200: { + description: "List of pending permissions", + content: { + "application/json": { + schema: resolver(Permission.Request.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("PermissionRoutes.list", c, function* () { + const svc = yield* Permission.Service + return yield* svc.list() + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts new file mode 100644 index 0000000000..3d8bb605bd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -0,0 +1,116 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { Instance } from "@/project/instance" +import { InstanceRuntime } from "@/project/instance-runtime" +import { Project } from "@/project/project" +import z from "zod" +import { ProjectID } from "@/project/schema" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { jsonRequest, runRequest } from "./trace" + +export const ProjectRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List all projects", + description: "Get a list of projects that have been opened with OpenCode.", + operationId: "project.list", + responses: { + 200: { + description: "List of projects", + content: { + "application/json": { + schema: resolver(Project.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => { + const projects = Project.list() + return c.json(projects) + }, + ) + .get( + "/current", + describeRoute({ + summary: "Get current project", + description: "Retrieve the currently active project that OpenCode is working with.", + operationId: "project.current", + responses: { + 200: { + description: "Current project information", + content: { + "application/json": { + schema: resolver(Project.Info.zod), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Instance.project) + }, + ) + .post( + "/git/init", + describeRoute({ + summary: "Initialize git repository", + description: "Create a git repository for the current project and return the refreshed project info.", + operationId: "project.initGit", + responses: { + 200: { + description: "Project information after git initialization", + content: { + "application/json": { + schema: resolver(Project.Info.zod), + }, + }, + }, + }, + }), + async (c) => { + const dir = Instance.directory + const prev = Instance.project + const next = await runRequest( + "ProjectRoutes.initGit", + c, + Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), + ) + if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) + await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) + return c.json(next) + }, + ) + .patch( + "/:projectID", + describeRoute({ + summary: "Update project", + description: "Update project properties such as name, icon, and commands.", + operationId: "project.update", + responses: { + 200: { + description: "Updated project information", + content: { + "application/json": { + schema: resolver(Project.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("param", z.object({ projectID: ProjectID.zod })), + validator("json", Project.UpdateInput.omit({ projectID: true })), + async (c) => + jsonRequest("ProjectRoutes.update", c, function* () { + const projectID = c.req.valid("param").projectID + const body = c.req.valid("json") + const svc = yield* Project.Service + return yield* svc.update({ ...body, projectID }) + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts new file mode 100644 index 0000000000..8ff7bc3103 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -0,0 +1,158 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Config } from "@/config/config" +import { Provider } from "@/provider/provider" +import { ModelsDev } from "@/provider/models" +import { ProviderAuth } from "@/provider/auth" +import { ProviderID } from "@/provider/schema" +import { mapValues } from "remeda" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Effect } from "effect" +import { jsonRequest } from "./trace" + +export const ProviderRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver(Provider.ListResult.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ProviderRoutes.list", c, function* () { + const svc = yield* Provider.Service + const cfg = yield* Config.Service + const config = yield* cfg.get() + const all = yield* ModelsDev.Service.use((s) => s.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }), + ) + .get( + "/auth", + describeRoute({ + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + operationId: "provider.auth", + responses: { + 200: { + description: "Provider auth methods", + content: { + "application/json": { + schema: resolver(ProviderAuth.Methods.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ProviderRoutes.auth", c, function* () { + const svc = yield* ProviderAuth.Service + return yield* svc.methods() + }), + ) + .post( + "/:providerID/oauth/authorize", + describeRoute({ + summary: "OAuth authorize", + description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.Authorization.zod.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod.meta({ description: "Provider ID" }), + }), + ), + validator("json", ProviderAuth.AuthorizeInput.zod), + async (c) => + jsonRequest("ProviderRoutes.oauth.authorize", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, inputs } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + return yield* svc.authorize({ + providerID, + method, + inputs, + }) + }), + ) + .post( + "/:providerID/oauth/callback", + describeRoute({ + summary: "OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + operationId: "provider.oauth.callback", + responses: { + 200: { + description: "OAuth callback processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod.meta({ description: "Provider ID" }), + }), + ), + validator("json", ProviderAuth.CallbackInput.zod), + async (c) => + jsonRequest("ProviderRoutes.oauth.callback", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + yield* svc.callback({ + providerID, + method, + code, + }) + return true + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts new file mode 100644 index 0000000000..fb8d5e356d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -0,0 +1,340 @@ +import { Hono } from "hono" +import type { Context } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import type { UpgradeWebSocket } from "hono/ws" +import { Effect, Schema } from "effect" +import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" +import { Shell } from "@/shell/shell" +import { NotFoundError } from "@/storage/storage" +import { errors } from "../../error" +import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" +import { zod as effectZod } from "@/util/effect-zod" + +const ShellItem = z.object({ + path: z.string(), + name: z.string(), + acceptable: z.boolean(), +}) +const decodePtyID = Schema.decodeUnknownSync(PtyID) + +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { + return new Hono() + .get( + "/shells", + describeRoute({ + summary: "List available shells", + description: "Get a list of available shells on the system.", + operationId: "pty.shells", + responses: { + 200: { + description: "List of shells", + content: { + "application/json": { + schema: resolver(z.array(ShellItem)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Shell.list()) + }, + ) + .get( + "/", + describeRoute({ + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + operationId: "pty.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Pty.Info.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("PtyRoutes.list", c, function* () { + const pty = yield* Pty.Service + return yield* pty.list() + }), + ) + .post( + "/", + describeRoute({ + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + operationId: "pty.create", + responses: { + 200: { + description: "Created session", + content: { + "application/json": { + schema: resolver(Pty.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Pty.CreateInput.zod), + async (c) => + jsonRequest("PtyRoutes.create", c, function* () { + const pty = yield* Pty.Service + return yield* pty.create(c.req.valid("json") as Pty.CreateInput) + }), + ) + .get( + "/:ptyID", + describeRoute({ + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + operationId: "pty.get", + responses: { + 200: { + description: "Session info", + content: { + "application/json": { + schema: resolver(Pty.Info.zod), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + const info = await runRequest( + "PtyRoutes.get", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.get(c.req.valid("param").ptyID) + }), + ) + if (!info) { + throw new NotFoundError({ message: "Session not found" }) + } + return c.json(info) + }, + ) + .put( + "/:ptyID", + describeRoute({ + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + operationId: "pty.update", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Pty.Info.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + validator("json", Pty.UpdateInput.zod), + async (c) => + jsonRequest("PtyRoutes.update", c, function* () { + const pty = yield* Pty.Service + return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput) + }), + ) + .delete( + "/:ptyID", + describeRoute({ + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + operationId: "pty.remove", + responses: { + 200: { + description: "Session removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => + jsonRequest("PtyRoutes.remove", c, function* () { + const pty = yield* Pty.Service + yield* pty.remove(c.req.valid("param").ptyID) + return true + }), + ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) + .get( + "/:ptyID/connect", + describeRoute({ + summary: "Connect to PTY session", + description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + operationId: "pty.connect", + responses: { + 200: { + description: "Connected session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + upgradeWebSocket(async (c) => { + type Handler = { + onMessage: (message: string | ArrayBuffer) => void + onClose: () => void + } + + const id = decodePtyID(c.req.param("ptyID")) + if ( + !(await runRequest( + "PtyRoutes.connect", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.get(id) + }), + )) + ) { + throw new NotFoundError({ message: "Session not found" }) + } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) + } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined + + type Socket = { + readyState: number + send: (data: string | Uint8Array | ArrayBuffer) => void + close: (code?: number, reason?: string) => void + } + + const isSocket = (value: unknown): value is Socket => { + if (!value || typeof value !== "object") return false + if (!("readyState" in value)) return false + if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false + if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false + return typeof (value as { readyState?: unknown }).readyState === "number" + } + + const pending: string[] = [] + let ready = false + + return { + async onOpen(_event, ws) { + const socket = ws.raw + if (!isSocket(socket)) { + ws.close() + return + } + handler = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.connect(id, socket, cursor) + }).pipe(Effect.withSpan("PtyRoutes.connect.open")), + ) + ready = true + for (const msg of pending) handler?.onMessage(msg) + pending.length = 0 + }, + onMessage(event) { + if (typeof event.data !== "string") return + if (!ready) { + pending.push(event.data) + return + } + handler?.onMessage(event.data) + }, + onClose() { + handler?.onClose() + }, + onError() { + handler?.onClose() + }, + } + }), + ) +} diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts new file mode 100644 index 0000000000..51ecb48ccd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/question.ts @@ -0,0 +1,111 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { QuestionID } from "@/question/schema" +import { Question } from "@/question" +import z from "zod" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" + +const Reply = z.object({ + answers: Question.Answer.zod + .array() + .describe("User answers in order of questions (each answer is an array of selected labels)"), +}) + +export const QuestionRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + operationId: "question.list", + responses: { + 200: { + description: "List of pending questions", + content: { + "application/json": { + schema: resolver(Question.Request.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("QuestionRoutes.list", c, function* () { + const svc = yield* Question.Service + return yield* svc.list() + }), + ) + .post( + "/:requestID/reply", + describeRoute({ + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + operationId: "question.reply", + responses: { + 200: { + description: "Question answered successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: QuestionID.zod, + }), + ), + validator("json", Reply), + async (c) => + jsonRequest("QuestionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Question.Service + yield* svc.reply({ + requestID: params.requestID, + answers: json.answers, + }) + return true + }), + ) + .post( + "/:requestID/reject", + describeRoute({ + summary: "Reject question request", + description: "Reject a question request from the AI assistant.", + operationId: "question.reject", + responses: { + 200: { + description: "Question rejected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: QuestionID.zod, + }), + ), + async (c) => + jsonRequest("QuestionRoutes.reject", c, function* () { + const params = c.req.valid("param") + const svc = yield* Question.Service + yield* svc.reject(params.requestID) + return true + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts new file mode 100644 index 0000000000..a16a92f927 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -0,0 +1,1124 @@ +import { Hono } from "hono" +import { stream } from "hono/streaming" +import { describeRoute, validator, resolver } from "hono-openapi" +import { SessionID, MessageID, PartID } from "@/session/schema" +import z from "zod" +import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionPrompt } from "@/session/prompt" +import { SessionRunState } from "@/session/run-state" +import { SessionCompaction } from "@/session/compaction" +import { SessionRevert } from "@/session/revert" +import { SessionShare } from "@/share/session" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { Effect } from "effect" +import { Agent } from "@/agent/agent" +import { Snapshot } from "@/snapshot" +import { Command } from "@/command" +import * as Log from "@opencode-ai/core/util/log" +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { zodObject } from "@/util/effect-zod" +import { Bus } from "@/bus" +import { NamedError } from "@opencode-ai/core/util/error" +import { jsonRequest, runRequest } from "./trace" + +const log = Log.create({ service: "server" }) + +const QueryBoolean = z.union([ + z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), + z.enum(["true", "false"]), +]) + +function queryBoolean(value: z.infer | undefined) { + if (value === undefined) return + return value === true || value === "true" +} + +export const SessionRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List sessions", + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", + operationId: "session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.Info.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string().optional().meta({ description: "Filter sessions by directory" }), + // TODO: in 2.0 remove `scope` and `directory` and default + // to list all sessions for a project + scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), + path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), + roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), + start: z.coerce + .number() + .optional() + .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), + limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + return c.json( + await runRequest( + "SessionRoutes.list", + c, + Session.Service.use((svc) => + svc.list({ + directory: query.scope === "project" ? undefined : query.directory, + path: query.path, + roots: queryBoolean(query.roots), + start: query.start, + search: query.search, + limit: query.limit, + }), + ), + ), + ) + }, + ) + .get( + "/status", + describeRoute({ + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + operationId: "session.status", + responses: { + 200: { + description: "Get session status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), SessionStatus.Info.zod)), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => + jsonRequest("SessionRoutes.status", c, function* () { + const svc = yield* SessionStatus.Service + return Object.fromEntries(yield* svc.list()) + }), + ) + .get( + "/:sessionID", + describeRoute({ + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + tags: ["Session"], + operationId: "session.get", + responses: { + 200: { + description: "Get session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.GetInput.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + return jsonRequest("SessionRoutes.get", c, function* () { + const session = yield* Session.Service + return yield* session.get(sessionID) + }) + }, + ) + .get( + "/:sessionID/children", + describeRoute({ + summary: "Get session children", + tags: ["Session"], + description: "Retrieve all child sessions that were forked from the specified parent session.", + operationId: "session.children", + responses: { + 200: { + description: "List of children", + content: { + "application/json": { + schema: resolver(Session.Info.zod.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.ChildrenInput.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + return jsonRequest("SessionRoutes.children", c, function* () { + const session = yield* Session.Service + return yield* session.children(sessionID) + }) + }, + ) + .get( + "/:sessionID/todo", + describeRoute({ + summary: "Get session todos", + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", + operationId: "session.todo", + responses: { + 200: { + description: "Todo list", + content: { + "application/json": { + schema: resolver(Todo.Info.zod.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + return jsonRequest("SessionRoutes.todo", c, function* () { + const todo = yield* Todo.Service + return yield* todo.get(sessionID) + }) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + operationId: "session.create", + responses: { + ...errors(400), + 200: { + description: "Successfully created session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + }, + }), + validator("json", Session.CreateInput.zod), + async (c) => + jsonRequest("SessionRoutes.create", c, function* () { + const body = c.req.valid("json") ?? {} + const svc = yield* SessionShare.Service + return yield* svc.create(body) + }), + ) + .delete( + "/:sessionID", + describeRoute({ + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + operationId: "session.delete", + responses: { + 200: { + description: "Successfully deleted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.RemoveInput.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.delete", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* Session.Service + yield* svc.remove(sessionID) + return true + }), + ) + .patch( + "/:sessionID", + describeRoute({ + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + operationId: "session.update", + responses: { + 200: { + description: "Successfully updated session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + title: z.string().optional(), + permission: Permission.Ruleset.zod.optional(), + time: z + .object({ + archived: z.number().optional(), + }) + .optional(), + }), + ), + async (c) => + jsonRequest("SessionRoutes.update", c, function* () { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + const session = yield* Session.Service + const current = yield* session.get(sessionID) + + if (updates.title !== undefined) { + yield* session.setTitle({ sessionID, title: updates.title }) + } + if (updates.permission !== undefined) { + yield* session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } + if (updates.time?.archived !== undefined) { + yield* session.setArchived({ sessionID, time: updates.time.archived }) + } + + return yield* session.get(sessionID) + }), + ) + // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. + .post( + "/:sessionID/init", + describeRoute({ + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + operationId: "session.init", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.init", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + yield* svc.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) + return true + }), + ) + .post( + "/:sessionID/fork", + describeRoute({ + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + operationId: "session.fork", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })), + async (c) => + jsonRequest("SessionRoutes.fork", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") as { messageID?: MessageID } + const svc = yield* Session.Service + return yield* svc.fork({ ...body, sessionID }) + }), + ) + .post( + "/:sessionID/abort", + describeRoute({ + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + operationId: "session.abort", + responses: { + 200: { + description: "Aborted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.abort", c, function* () { + const svc = yield* SessionPrompt.Service + yield* svc.cancel(c.req.valid("param").sessionID) + return true + }), + ) + .post( + "/:sessionID/share", + describeRoute({ + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + operationId: "session.share", + responses: { + 200: { + description: "Successfully shared session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.share", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.share(sessionID) + return yield* session.get(sessionID) + }), + ) + .get( + "/:sessionID/diff", + describeRoute({ + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + operationId: "session.diff", + responses: { + 200: { + description: "Successfully retrieved diff", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.zod.array()), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })), + async (c) => + jsonRequest("SessionRoutes.diff", c, function* () { + const query = c.req.valid("query") as Omit + const params = c.req.valid("param") + const summary = yield* SessionSummary.Service + return yield* summary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + }), + ) + .delete( + "/:sessionID/share", + describeRoute({ + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + operationId: "session.unshare", + responses: { + 200: { + description: "Successfully unshared session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.unshare", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.unshare(sessionID) + return yield* session.get(sessionID) + }), + ) + .post( + "/:sessionID/summarize", + describeRoute({ + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + operationId: "session.summarize", + responses: { + 200: { + description: "Summarized session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, + auto: z.boolean().optional().default(false), + }), + ), + async (c) => + jsonRequest("SessionRoutes.summarize", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service + + yield* revert.cleanup(yield* session.get(sessionID)) + const msgs = yield* session.messages({ sessionID }) + const defaultAgent = yield* agent.defaultAgent() + let currentAgent = defaultAgent + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || defaultAgent + break + } + } + + yield* compact.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + yield* prompt.loop({ sessionID }) + return true + }), + ) + .get( + "/:sessionID/message", + describeRoute({ + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + operationId: "session.messages", + responses: { + 200: { + description: "List of messages", + content: { + "application/json": { + schema: resolver(MessageV2.WithParts.zod.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "query", + z + .object({ + limit: z.coerce + .number() + .int() + .min(0) + .optional() + .meta({ description: "Maximum number of messages to return" }), + before: z + .string() + .optional() + .meta({ description: "Opaque cursor for loading older messages" }) + .refine( + (value) => { + if (!value) return true + try { + MessageV2.cursor.decode(value) + return true + } catch { + return false + } + }, + { message: "Invalid cursor" }, + ), + }) + .refine((value) => !value.before || value.limit !== undefined, { + message: "before requires limit", + path: ["before"], + }), + ), + async (c) => { + const query = c.req.valid("query") + const sessionID = c.req.valid("param").sessionID + if (query.limit === undefined || query.limit === 0) { + const messages = await runRequest( + "SessionRoutes.messages", + c, + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.get(sessionID) + return yield* session.messages({ sessionID }) + }), + ) + return c.json(messages) + } + + const page = await MessageV2.page({ + sessionID, + limit: query.limit, + before: query.before, + }) + if (page.cursor) { + const url = new URL(c.req.url) + url.searchParams.set("limit", query.limit.toString()) + url.searchParams.set("before", page.cursor) + c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") + c.header("Link", `<${url.toString()}>; rel="next"`) + c.header("X-Next-Cursor", page.cursor) + } + return c.json(page.items) + }, + ) + .get( + "/:sessionID/message/:messageID", + describeRoute({ + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + operationId: "session.message", + responses: { + 200: { + description: "Message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Info.zod, + parts: MessageV2.Part.zod.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + const message = await MessageV2.get({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(message) + }, + ) + .delete( + "/:sessionID/message/:messageID", + describeRoute({ + summary: "Delete message", + description: + "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", + operationId: "session.deleteMessage", + responses: { + 200: { + description: "Successfully deleted message", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.deleteMessage", c, function* () { + const params = c.req.valid("param") + const state = yield* SessionRunState.Service + const session = yield* Session.Service + yield* state.assertNotBusy(params.sessionID) + yield* session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return true + }), + ) + .delete( + "/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Delete a part from a message", + operationId: "part.delete", + responses: { + 200: { + description: "Successfully deleted part", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + partID: PartID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.deletePart", c, function* () { + const params = c.req.valid("param") + const svc = yield* Session.Service + yield* svc.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return true + }), + ) + .patch( + "/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Update a part in a message", + operationId: "part.update", + responses: { + 200: { + description: "Successfully updated part", + content: { + "application/json": { + schema: resolver(MessageV2.Part.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + partID: PartID.zod, + }), + ), + validator("json", MessageV2.Part.zod), + async (c) => { + const params = c.req.valid("param") + const body = c.req.valid("json") + if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { + throw new Error( + `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, + ) + } + return jsonRequest("SessionRoutes.updatePart", c, function* () { + const svc = yield* Session.Service + return yield* svc.updatePart(body) + }) + }, + ) + .post( + "/:sessionID/message", + describeRoute({ + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + operationId: "session.prompt", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), + async (c) => { + c.status(200) + c.header("Content-Type", "application/json") + return stream(c, async (stream) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await runRequest( + "SessionRoutes.prompt", + c, + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), + ) + void stream.write(JSON.stringify(msg)) + }) + }, + ) + .post( + "/:sessionID/prompt_async", + describeRoute({ + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + operationId: "session.prompt_async", + responses: { + 204: { + description: "Prompt accepted", + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + void runRequest( + "SessionRoutes.prompt_async", + c, + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), + ).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + void Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) + + return c.body(null, 204) + }, + ) + .post( + "/:sessionID/command", + describeRoute({ + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + operationId: "session.command", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })), + async (c) => + jsonRequest("SessionRoutes.command", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") as Omit + const svc = yield* SessionPrompt.Service + return yield* svc.command({ ...body, sessionID }) + }), + ) + .post( + "/:sessionID/shell", + describeRoute({ + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + operationId: "session.shell", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver(MessageV2.WithParts.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })), + async (c) => + jsonRequest("SessionRoutes.shell", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") as Omit + const svc = yield* SessionPrompt.Service + return yield* svc.shell({ ...body, sessionID }) + }), + ) + .post( + "/:sessionID/revert", + describeRoute({ + summary: "Revert message", + description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", + operationId: "session.revert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") as Omit + log.info("revert", body) + return jsonRequest("SessionRoutes.revert", c, function* () { + const svc = yield* SessionRevert.Service + return yield* svc.revert({ sessionID, ...body }) + }) + }, + ) + .post( + "/:sessionID/unrevert", + describeRoute({ + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + operationId: "session.unrevert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => + jsonRequest("SessionRoutes.unrevert", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* SessionRevert.Service + return yield* svc.unrevert({ sessionID }) + }), + ) + .post( + "/:sessionID/permissions/:permissionID", + describeRoute({ + summary: "Respond to permission", + deprecated: true, + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.respond", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + permissionID: PermissionID.zod, + }), + ), + validator("json", z.object({ response: Permission.Reply.zod })), + async (c) => + jsonRequest("SessionRoutes.permissionRespond", c, function* () { + const params = c.req.valid("param") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return true + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts new file mode 100644 index 0000000000..9894d8c8ee --- /dev/null +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -0,0 +1,199 @@ +import z from "zod" +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { SyncEvent } from "@/sync" +import { Database } from "@/storage/db" +import { asc } from "drizzle-orm" +import { and } from "drizzle-orm" +import { not } from "drizzle-orm" +import { or } from "drizzle-orm" +import { lte } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { EventTable } from "@/sync/event.sql" +import { lazy } from "@/util/lazy" +import * as Log from "@opencode-ai/core/util/log" +import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" +import { Instance } from "@/project/instance" +import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" + +const ReplayEvent = z.object({ + id: z.string(), + aggregateID: z.string(), + seq: z.number().int().min(0), + type: z.string(), + data: z.record(z.string(), z.unknown()), +}) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) + +const log = Log.create({ service: "server.sync" }) + +export const SyncRoutes = lazy(() => + new Hono() + .post( + "/start", + describeRoute({ + summary: "Start workspace sync", + description: "Start sync loops for workspaces in the current project that have active sessions.", + operationId: "sync.start", + responses: { + 200: { + description: "Workspace sync started", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + void AppRuntime.runPromise( + Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)), + ) + return c.json(true) + }, + ) + .post( + "/replay", + describeRoute({ + summary: "Replay sync events", + description: "Validate and replay a complete sync event history.", + operationId: "sync.replay", + responses: { + 200: { + description: "Replayed sync events", + content: { + "application/json": { + schema: resolver( + z.object({ + sessionID: z.string(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + directory: z.string(), + events: z.array(ReplayEvent).min(1), + }), + ), + async (c) => { + const body = c.req.valid("json") + const events = body.events + const source = events[0].aggregateID + + log.info("sync replay requested", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + directory: body.directory, + }) + await AppRuntime.runPromise(SyncEvent.use.replayAll(events)) + + log.info("sync replay complete", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + }) + + return c.json({ + sessionID: source, + }) + }, + ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) + .post( + "/history", + describeRoute({ + summary: "List sync events", + description: + "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + operationId: "sync.history.list", + responses: { + 200: { + description: "Sync events", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + aggregate_id: z.string(), + seq: z.number(), + type: z.string(), + data: z.record(z.string(), z.unknown()), + }), + ), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.record(z.string(), z.number().int().min(0))), + async (c) => { + const body = c.req.valid("json") + const exclude = Object.entries(body) + const where = + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined + const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) + return c.json(rows) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts new file mode 100644 index 0000000000..4c7119ef3a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -0,0 +1,59 @@ +import type { Context } from "hono" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" + +type AppEnv = Parameters[0] extends Effect.Effect ? R : never + +// Build the base span attributes for an HTTP handler: method, path, and every +// matched route param. Names follow OTel attribute-naming guidance: +// domain-first (`session.id`, `message.id`, …) so they match the existing +// OTel `session.id` semantic convention and the bare `message.id` we +// already emit from Tool.execute. Non-standard route params fall back to +// `opencode.` since those are internal implementation details +// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/). +export interface RequestLike { + readonly req: { + readonly method: string + readonly url: string + param(): Record + } +} + +// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`) +// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any +// other param is namespaced under `opencode.` to avoid colliding with +// standard conventions. +export function paramToAttributeKey(key: string): string { + const m = key.match(/^(.+)ID$/) + if (m) return `${m[1].toLowerCase()}.id` + return `opencode.${key}` +} + +export function requestAttributes(c: RequestLike): Record { + const attributes: Record = { + "http.method": c.req.method, + "http.path": new URL(c.req.url).pathname, + } + for (const [key, value] of Object.entries(c.req.param())) { + attributes[paramToAttributeKey(key)] = value + } + return attributes +} + +export function runRequest(name: string, c: Context, effect: Effect.Effect) { + return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) +} + +export async function jsonRequest( + name: string, + c: C, + effect: (c: C) => Effect.gen.Return, +) { + return c.json( + await runRequest( + name, + c, + Effect.gen(() => effect(c)), + ), + ) +} diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts new file mode 100644 index 0000000000..a7a0c9cbdc --- /dev/null +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -0,0 +1,387 @@ +import { Hono, type Context } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { Schema } from "effect" +import z from "zod" +import { Bus } from "@/bus" +import { Session } from "@/session/session" +import type { SessionID } from "@/session/schema" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { zodObject } from "@/util/effect-zod" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { runRequest } from "./trace" +import { + TuiRequest, + nextTuiRequest, + nextTuiResponse, + submitTuiRequest, + submitTuiResponse, +} from "@/server/shared/tui-control" + +export async function callTui(ctx: Context) { + const body = await ctx.req.json() + submitTuiRequest({ + path: ctx.req.path, + body, + }) + return nextTuiResponse() +} + +const TuiControlRoutes = new Hono() + .get( + "/next", + describeRoute({ + summary: "Get next TUI request", + description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", + operationId: "tui.control.next", + responses: { + 200: { + description: "Next TUI request", + content: { + "application/json": { + schema: resolver(TuiRequest), + }, + }, + }, + }, + }), + async (c) => { + const req = await nextTuiRequest() + return c.json(req) + }, + ) + .post( + "/response", + describeRoute({ + summary: "Submit TUI response", + description: "Submit a response to the TUI request queue to complete a pending request.", + operationId: "tui.control.response", + responses: { + 200: { + description: "Response submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", z.any()), + async (c) => { + const body = c.req.valid("json") + submitTuiResponse(body) + return c.json(true) + }, + ) + +export const TuiRoutes = lazy(() => + new Hono() + .post( + "/append-prompt", + describeRoute({ + summary: "Append TUI prompt", + description: "Append prompt to the TUI", + operationId: "tui.appendPrompt", + responses: { + 200: { + description: "Prompt processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", zodObject(TuiEvent.PromptAppend.properties)), + async (c) => { + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string }) + return c.json(true) + }, + ) + .post( + "/open-help", + describeRoute({ + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + operationId: "tui.openHelp", + responses: { + 200: { + description: "Help dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "help.show", + }) + return c.json(true) + }, + ) + .post( + "/open-sessions", + describeRoute({ + summary: "Open sessions dialog", + description: "Open the session dialog", + operationId: "tui.openSessions", + responses: { + 200: { + description: "Session dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/open-themes", + describeRoute({ + summary: "Open themes dialog", + description: "Open the theme dialog", + operationId: "tui.openThemes", + responses: { + 200: { + description: "Theme dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/open-models", + describeRoute({ + summary: "Open models dialog", + description: "Open the model dialog", + operationId: "tui.openModels", + responses: { + 200: { + description: "Model dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "model.list", + }) + return c.json(true) + }, + ) + .post( + "/submit-prompt", + describeRoute({ + summary: "Submit TUI prompt", + description: "Submit the prompt", + operationId: "tui.submitPrompt", + responses: { + 200: { + description: "Prompt submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.submit", + }) + return c.json(true) + }, + ) + .post( + "/clear-prompt", + describeRoute({ + summary: "Clear TUI prompt", + description: "Clear the prompt", + operationId: "tui.clearPrompt", + responses: { + 200: { + description: "Prompt cleared successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.clear", + }) + return c.json(true) + }, + ) + .post( + "/execute-command", + describeRoute({ + summary: "Execute TUI command", + description: "Execute a TUI command (e.g. agent_cycle)", + operationId: "tui.executeCommand", + responses: { + 200: { + description: "Command executed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.object({ command: z.string() })), + async (c) => { + const command = c.req.valid("json").command + await Bus.publish(TuiEvent.CommandExecute, { + // @ts-expect-error + command: { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", + }[command], + }) + return c.json(true) + }, + ) + .post( + "/show-toast", + describeRoute({ + summary: "Show TUI toast", + description: "Show a toast notification in the TUI", + operationId: "tui.showToast", + responses: { + 200: { + description: "Toast notification shown successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", zodObject(TuiEvent.ToastShow.properties)), + async (c) => { + await Bus.publish( + TuiEvent.ToastShow, + c.req.valid("json") as Schema.Schema.Type, + ) + return c.json(true) + }, + ) + .post( + "/publish", + describeRoute({ + summary: "Publish TUI event", + description: "Publish a TUI event", + operationId: "tui.publish", + responses: { + 200: { + description: "Event published successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.union( + Object.values(TuiEvent).map((def) => { + return z + .object({ + type: z.literal(def.type), + properties: zodObject(def.properties), + }) + .meta({ + ref: `Event.${def.type}`, + }) + }), + ), + ), + async (c) => { + const evt = c.req.valid("json") as { type: string; properties: Record } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any) + return c.json(true) + }, + ) + .post( + "/select-session", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "tui.selectSession", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", zodObject(TuiEvent.SessionSelect.properties)), + async (c) => { + const { sessionID } = c.req.valid("json") as { sessionID: SessionID } + await runRequest( + "TuiRoutes.sessionSelect", + c, + Session.Service.use((svc) => svc.get(sessionID)), + ) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) + .route("/control", TuiControlRoutes), +) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts new file mode 100644 index 0000000000..608525b63a --- /dev/null +++ b/packages/opencode/src/server/routes/ui.ts @@ -0,0 +1,40 @@ +import fs from "node:fs/promises" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Hono } from "hono" +import { proxy } from "hono/proxy" +import { ProxyUtil } from "../proxy-util" +import { UI_UPSTREAM, csp, cspForHtml, embeddedUI, upstreamURL } from "../shared/ui" + +export async function serveUI(request: Request) { + const embeddedWebUI = await embeddedUI() + const path = new URL(request.url).pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) + + if (await fs.exists(match)) { + const mime = AppFileSystem.mimeType(match) + const headers = new Headers({ "content-type": mime }) + const body = new Uint8Array(await fs.readFile(match)) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + } + return new Response(body, { headers }) + } + + return Response.json({ error: "Not Found" }, { status: 404 }) + } + + const response = await proxy(upstreamURL(path), { + raw: request, + headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), + }) + response.headers.set( + "Content-Security-Policy", + response.headers.get("content-type")?.includes("text/html") ? cspForHtml(await response.clone().text()) : csp(), + ) + return response +} + +export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 67a728b801..bc09667c29 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,14 +1,30 @@ +import { generateSpecs } from "hono-openapi" +import { Hono } from "hono" +import { adapter } from "#hono" +import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WorkspaceID } from "@/control-plane/schema" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" +import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" +import { FenceMiddleware } from "./fence" import { initProjectors } from "./projectors" +import { InstanceRoutes } from "./routes/instance" +import { ControlPlaneRoutes } from "./routes/control" +import { UIRoutes } from "./routes/ui" +import { GlobalRoutes } from "./routes/global" +import { WorkspaceRouterMiddleware } from "./workspace" +import { InstanceMiddleware } from "./routes/instance/middleware" +import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" +import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -37,27 +53,203 @@ type ListenOptions = CorsOptions & { mdnsDomain?: string } -const defaultHttpApi = (() => { - const handler = ExperimentalHttpApiServer.webHandler().handler +const DefaultHono = lazy(() => + withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), +) +const DefaultHttpApi = lazy(() => createDefaultHttpApi()) + +function select() { + return ServerBackend.select() +} + +export const backend = select + +export const Default = () => { + const selected = select() + return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() +} + +function create(opts: ListenOptions) { + const selected = select() + return selected.backend === "effect-httpapi" + ? withBackend(selected, createHttpApi(opts)) + : withBackend(selected, createHono(opts, selected)) +} + +export function Legacy(opts: CorsOptions = {}) { + return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) +} + +function createDefaultHttpApi() { + return withBackend(select(), createHttpApi()) +} + +function withBackend(selection: ServerBackend.Selection, built: T) { + log.info("server backend selected", ServerBackend.attributes(selection)) + return built +} + +function createHttpApi(corsOptions?: CorsOptions) { + const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) }, } - return { app } -})() + return { + app, + runtime: adapter.createFetch(app), + } +} -export const Default = () => defaultHttpApi +function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { + const backendAttributes = ServerBackend.attributes(selection) + const app = new Hono() + .onError(ErrorMiddleware) + .use(CorsMiddleware(opts)) + .use(LoggerMiddleware(backendAttributes)) + .use(AuthMiddleware) + .use(CompressionMiddleware) + .route("/global", GlobalRoutes()) + const runtime = adapter.create(app) + + if (Flag.OPENCODE_WORKSPACE_ID) { + return { + app: app + .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) + .use(FenceMiddleware) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), + runtime, + } + } + + const workspaceApp = new Hono() + const workspaceLegacyApp = new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + workspaceApp.route("/", workspaceLegacyApp) + + return { + app: app + .route("/", ControlPlaneRoutes()) + .route("/", workspaceApp) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) + .route("/", UIRoutes()), + runtime, + } +} + +/** + * Generate the OpenAPI document used by the SDK build. + * + * Since the Effect HttpApi backend now covers every Hono route (plus the new + * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity + * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. + * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` + * transform that injects instance query parameters, strips Effect's optional + * null arms, normalizes component names, and patches SSE response schemas so + * the generated SDK keeps the legacy Hono shape. + * + * The Hono-derived spec is still reachable via `openapiHono()` so reviewers + * can diff the two outputs while the Hono backend lingers; once the Hono + * backend is deleted that helper goes with it. + */ export async function openapi() { return OpenApi.fromApi(PublicApi) } +/** + * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once + * the Hono backend is removed. + */ +export async function openapiHono() { + // Build a fresh app with all routes registered directly so + // hono-openapi can see describeRoute metadata (`.route()` wraps + // handlers when the sub-app has a custom errorHandler, which + // strips the metadata symbol). + const { app } = createHono({}) + const result = await generateSpecs(app, { + documentation: { + info: { + title: "opencode", + version: "1.0.0", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }) + return result +} + export let url: URL export async function listen(opts: ListenOptions): Promise { - log.info("server backend", { "opencode.server.runtime": HttpApiServer.name }) + const selected = select() + const inner: Listener = + selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) + + const next = new URL(inner.url) + url = next + + const mdns = + opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" + if (mdns) { + MDNS.publish(inner.port, opts.mdnsDomain) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + + let closing: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } + return { + hostname: inner.hostname, + port: inner.port, + url: next, + stop(close?: boolean) { + unpublish() + // Always forward stop(true), even if a graceful stop was requested + // first, so native listeners can escalate shutdown in-place. + const next = inner.stop(close) + closing ??= next + return close ? next.then(() => closing!) : closing + }, + } +} + +async function listenLegacy(opts: ListenOptions): Promise { + const built = create(opts) + const server = await built.runtime.listen(opts) + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(server.port) + return { + hostname: opts.hostname, + port: server.port, + url: innerUrl, + stop: (close?: boolean) => server.stop(close), + } +} + +/** + * Run the effect-httpapi backend on a native Effect HTTP server. This + * lets HttpApi routes that call `request.upgrade` (PTY connect, the + * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono + * adapter path can't surface `request.upgrade` because its fetch handler has + * no reference to the platform server instance for websocket upgrades. + */ +async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { + log.info("server backend selected", { + ...ServerBackend.attributes(selection), + "opencode.server.runtime": HttpApiServer.name, + }) const buildLayer = (port: number) => HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { @@ -78,6 +270,10 @@ export async function listen(opts: ListenOptions): Promise { const start = async (port: number) => { const scope = Scope.makeUnsafe() try { + // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by + // design, which leaks `R = any` through `HttpRouter.serve`. The actual + // requirements at this point are fully satisfied by `createRoutes` and the + // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. const layer = buildLayer(port) as Layer.Layer< HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, unknown, @@ -112,24 +308,8 @@ export async function listen(opts: ListenOptions): Promise { const innerUrl = new URL("http://localhost") innerUrl.hostname = opts.hostname innerUrl.port = String(port) - url = innerUrl - - const mdns = - opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" - if (mdns) { - MDNS.publish(port, opts.mdnsDomain) - } else if (opts.mdns) { - log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") - } - let forceStopPromise: Promise | undefined let stopPromise: Promise | undefined - let mdnsUnpublished = false - const unpublish = () => { - if (!mdns || mdnsUnpublished) return - mdnsUnpublished = true - MDNS.unpublish() - } const forceStop = () => { forceStopPromise ??= Effect.runPromiseExit( Effect.gen(function* () { @@ -145,8 +325,9 @@ export async function listen(opts: ListenOptions): Promise { port, url: innerUrl, stop: (close?: boolean) => { - unpublish() const requested = close ? forceStop() : Promise.resolve() + // The first call starts scope shutdown. A later stop(true) cannot undo + // that, but it still runs forceStop() before awaiting the original close. stopPromise ??= requested .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) .then(() => undefined) diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts index 03b62bbab0..40aaf04a96 100644 --- a/packages/opencode/src/server/shared/tui-control.ts +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -1,12 +1,12 @@ +import z from "zod" import { AsyncQueue } from "@/util/queue" -import { Schema } from "effect" -export const TuiRequest = Schema.Struct({ - path: Schema.String, - body: Schema.Unknown, +export const TuiRequest = z.object({ + path: z.string(), + body: z.any(), }) -export type TuiRequest = Schema.Schema.Type +export type TuiRequest = z.infer const request = new AsyncQueue() const response = new AsyncQueue() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts new file mode 100644 index 0000000000..0972875305 --- /dev/null +++ b/packages/opencode/src/server/workspace.ts @@ -0,0 +1,93 @@ +import type { MiddlewareHandler } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { getAdapter } from "@/control-plane/adapters" +import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { Workspace } from "@/control-plane/workspace" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" +import { Session } from "@/session/session" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { ServerProxy } from "./proxy" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" + +async function getSessionWorkspace(url: URL) { + const id = getWorkspaceRouteSessionID(url) + if (!id) return null + + const session = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), + ).catch(() => undefined) + return session?.workspaceID +} + +export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { + const log = Log.create({ service: "workspace-router" }) + + return async (c, next) => { + const url = new URL(c.req.url) + + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") + + if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { + return next() + } + + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))), + ) + + if (!workspace) { + return new Response(`Workspace not found: ${workspaceID}`, { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { + // No instance provided because we are serving cached data; there + // is no instance to work with + return next() + } + + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = await adapter.target(workspace) + + if (target.type === "local") { + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(workspaceID), + fn: () => + WithInstance.provide({ + directory: target.directory, + async fn() { + return next() + }, + }), + }) + } + + const proxyURL = workspaceProxyURL(target.url, url) + + log.info("workspace proxy forwarding", { + workspaceID, + request: url.toString(), + target: String(target.url), + proxy: proxyURL.toString(), + }) + + if (c.req.header("upgrade")?.toLowerCase() === "websocket") { + return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) + } + + const headers = new Headers(c.req.raw.headers) + headers.delete("x-opencode-workspace") + + const req = new Request(c.req.raw, { headers }) + return ServerProxy.http(proxyURL, target.headers, req, workspace.id) + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 85f6124505..2930dbaeb3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -446,6 +446,19 @@ export type Part = | RetryPart | CompactionPart +// Zod discriminated union kept for the legacy Hono OpenAPI path. +const AssistantErrorZod = z.discriminatedUnion("name", [ + AuthError.Schema, + NamedError.Unknown.Schema, + OutputLengthError.Schema, + AbortedError.Schema, + StructuredOutputError.Schema, + ContextOverflowError.Schema, + APIError.Schema, +]) +type AssistantError = z.infer + +// Effect Schema for the same union — used by HttpApi OpenAPI generation. const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ @@ -457,7 +470,6 @@ const AssistantErrorSchema = Schema.Union([ ContextOverflowError.EffectSchema, APIError.EffectSchema, ]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type // ── Prompt input schemas ───────────────────────────────────────────────────── // diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 05d9727891..62b30ccf9a 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,3 +1,4 @@ +import z from "zod" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" @@ -9,6 +10,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" +import { zodObject } from "@/util/effect-zod" import type { DeepMutable } from "@/util/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" @@ -362,6 +364,26 @@ export function claim(aggregateID: string, ownerID: string) { ) } +export function payloads() { + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal("sync"), + name: z.literal(type), + id: z.string(), + seq: z.number(), + aggregateID: z.literal(def.aggregate), + data: zodObject(def.schema), + }) + .meta({ + ref: `SyncEvent.${def.type}`, + }) + }) + .toArray() +} + export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a6599debdf..27d2e41cd6 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -21,6 +21,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" +import { zod as effectZod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "worktree" }) @@ -44,7 +46,9 @@ export const Info = Schema.Struct({ name: Schema.String, branch: Schema.String, directory: Schema.String, -}).annotate({ identifier: "Worktree" }) +}) + .annotate({ identifier: "Worktree" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) export type Info = Schema.Schema.Type export const CreateInput = Schema.Struct({ @@ -52,17 +56,23 @@ export const CreateInput = Schema.Struct({ startCommand: Schema.optional( Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }), ), -}).annotate({ identifier: "WorktreeCreateInput" }) +}) + .annotate({ identifier: "WorktreeCreateInput" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) export type CreateInput = Schema.Schema.Type export const RemoveInput = Schema.Struct({ directory: Schema.String, -}).annotate({ identifier: "WorktreeRemoveInput" }) +}) + .annotate({ identifier: "WorktreeRemoveInput" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) export type RemoveInput = Schema.Schema.Type export const ResetInput = Schema.Struct({ directory: Schema.String, -}).annotate({ identifier: "WorktreeResetInput" }) +}) + .annotate({ identifier: "WorktreeResetInput" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) export type ResetInput = Schema.Schema.Type export const NotGitError = NamedError.create( diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 3c4837e318..8333d9573f 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,7 +5,7 @@ import Http from "node:http" import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" @@ -376,10 +376,9 @@ describe("workspace schemas and exports", () => { extra: { nested: true }, } - const decode = Schema.decodeUnknownSync(Workspace.CreateInput) - expect(decode(input)).toEqual(input) - expect(() => decode({ ...input, id: 1 })).toThrow() - expect(() => decode({ ...input, branch: 1 })).toThrow() + expect(Workspace.CreateInput.zod.parse(input)).toEqual(input) + expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() + expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) }) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 0000000000..c01450549b --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, test } from "bun:test" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// These regressions cover the legacy instance-loading paths fixed by PRs +// #25389 and #25449. The plugin config hook writes a marker file, and the test +// bodies deliberately avoid touching Plugin or config directly. The marker only +// exists if InstanceBootstrap ran at the instance boundary. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { + await using tmp = await bootstrapFixture() + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts new file mode 100644 index 0000000000..8476ece0e7 --- /dev/null +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -0,0 +1,501 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Instance } from "../../src/project/instance" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +const methods = ["get", "post", "put", "delete", "patch"] as const +let effectSpec: ReturnType | undefined + +function effectOpenApi() { + return (effectSpec ??= OpenApi.fromApi(PublicApi)) +} + +function app(input?: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } +} + +function openApiRouteKeys(spec: { paths: Record>> }) { + return Object.entries(spec.paths) + .flatMap(([path, item]) => + methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() +} + +function openApiParameters(spec: { paths: Record>> }) { + return Object.fromEntries( + Object.entries(spec.paths).flatMap(([path, item]) => + methods + .filter((method) => item[method]) + .map((method) => [ + `${method.toUpperCase()} ${path}`, + (item[method]?.parameters ?? []) + .map(parameterKey) + .filter((param) => param !== undefined) + .sort(), + ]), + ), + ) +} + +function openApiRequestBodies(spec: OpenApiSpec) { + return Object.fromEntries( + Object.entries(spec.paths).flatMap(([path, item]) => + methods + .filter((method) => item[method]) + .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]), + ), + ) +} + +type OpenApiSpec = { + components?: { + schemas?: Record + } + paths: Record>> +} + +type OpenApiSchema = { + $ref?: string + allOf?: unknown[] + anyOf?: unknown[] + oneOf?: unknown[] + properties?: Record + type?: string | string[] +} + +type Operation = { + parameters?: unknown[] + responses?: unknown + requestBody?: unknown +} + +type RequestBody = { + content?: Record + required?: boolean +} + +function parameterKey(param: unknown): string | undefined { + if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined + if (typeof param.in !== "string" || typeof param.name !== "string") return undefined + return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( + "schema" in param ? param.schema : undefined, + )}` +} + +function stableSchema(input: unknown): string { + return JSON.stringify(sortSchema(input)) +} + +function sortSchema(input: unknown): unknown { + if (Array.isArray(input)) return input.map(sortSchema) + if (!input || typeof input !== "object") return input + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortSchema(value)]), + ) +} + +function parameterSchema(input: { + spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + name: string +}): unknown { + const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( + (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, + ) + if (!param || typeof param !== "object" || !("schema" in param)) return undefined + return param.schema +} + +function requestBodyKey(spec: OpenApiSpec, body: unknown) { + if (!body || typeof body !== "object" || !("content" in body)) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. + const requestBody = body as RequestBody + return JSON.stringify({ + required: requestBody.required === true, + content: Object.entries(requestBody.content ?? {}) + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) + .sort(([left], [right]) => left.localeCompare(right)), + }) +} + +function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { + if (!schema) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. + const resolved = ( + schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema + ) as OpenApiSchema | undefined + if (resolved?.properties) return "object" + if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" + return resolved?.type ?? schema.type ?? "inline" +} + +function responseContentTypes(input: { + spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + status: string +}) { + const responses = input.spec.paths[input.path]?.[input.method]?.responses + if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. + const response = (responses as Record)[input.status] + if (!response || typeof response !== "object" || !("content" in response)) return [] + const content = (response as { content?: unknown }).content + if (!content || typeof content !== "object") { + return [] + } + return Object.keys(content).sort() +} + +function authorization(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +function fileUrl(input?: { directory?: string; token?: string }) { + const url = new URL(`http://localhost${FilePaths.content}`) + url.searchParams.set("path", "hello.txt") + if (input?.directory) url.searchParams.set("directory", input.directory) + if (input?.token) url.searchParams.set("auth_token", input.token) + return url +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi server", () => { + test("keeps Effect HttpApi behind the feature flag", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" }) + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" }) + }) + + test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { + const honoRoutes = openApiRouteKeys(await Server.openapiHono()) + const effectRoutes = openApiRouteKeys(effectOpenApi()) + + expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) + }) + + test("matches generated OpenAPI route parameters", async () => { + const hono = openApiParameters(await Server.openapiHono()) + const effect = openApiParameters(effectOpenApi()) + + expect( + Object.keys(hono) + .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route])) + .map((route) => ({ route, hono: hono[route], effect: effect[route] })), + ).toEqual([]) + }) + + test("matches generated OpenAPI request body shape", async () => { + const hono = openApiRequestBodies(await Server.openapiHono()) + const effect = openApiRequestBodies(effectOpenApi()) + + expect( + Object.keys(hono) + .filter((route) => hono[route] !== effect[route]) + .map((route) => ({ route, hono: hono[route], effect: effect[route] })), + ).toEqual([]) + }) + + test("matches SDK-affecting query parameter schemas", async () => { + const effect = effectOpenApi() + + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({ + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + }) + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({ + type: "number", + }) + expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({ + type: "integer", + minimum: 1, + maximum: 200, + }) + expect( + parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }), + ).toEqual({ + type: "integer", + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }) + }) + + test("matches SDK-affecting request schema details", () => { + const effect = effectOpenApi() + const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody + const sessionUpdateSchema = + typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate + ? sessionUpdate.content?.["application/json"]?.schema + : undefined + const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined + const time = sessionUpdateProperties?.time + expect(time?.properties?.archived).toEqual({ type: "number" }) + }) + + test("documents event routes as server-sent events", () => { + const effect = effectOpenApi() + + expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + }) + + test("allows requests when auth is disabled", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(`${tmp.path}/hello.txt`, "hello") + + const response = await app().request(fileUrl(), { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ content: "hello" }) + }) + + test("provides instance context to bridged handlers", async () => { + await using tmp = await tmpdir({ git: true }) + + const response = await app().request("/project/current", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ worktree: tmp.path }) + }) + + test("requires credentials when auth is enabled", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(`${tmp.path}/hello.txt`, "hello") + + const [missing, bad, good] = await Promise.all([ + app({ password: "secret" }).request(fileUrl(), { + headers: { "x-opencode-directory": tmp.path }, + }), + app({ password: "secret" }).request(fileUrl(), { + headers: { + authorization: authorization("opencode", "wrong"), + "x-opencode-directory": tmp.path, + }, + }), + app({ password: "secret" }).request(fileUrl(), { + headers: { + authorization: authorization("opencode", "secret"), + "x-opencode-directory": tmp.path, + }, + }), + ]) + + expect(missing.status).toBe(401) + expect(bad.status).toBe(401) + expect(good.status).toBe(200) + }) + + test("requires credentials for root routes when auth is enabled", async () => { + const server = app({ password: "secret" }) + const auth = { authorization: authorization("opencode", "secret") } + const wrongAuth = { authorization: authorization("opencode", "wrong") } + + const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([ + server.request(GlobalPaths.health), + server.request(GlobalPaths.health, { headers: auth }), + server.request(GlobalPaths.config), + server.request(GlobalPaths.config, { headers: wrongAuth }), + server.request(GlobalPaths.config, { headers: auth }), + ]) + + expect(missingHealth.status).toBe(401) + expect(goodHealth.status).toBe(200) + expect(missingConfig.status).toBe(401) + expect(wrongConfig.status).toBe(401) + expect(goodConfig.status).toBe(200) + + const missingDispose = await server.request(GlobalPaths.dispose, { method: "POST" }) + expect(missingDispose.status).toBe(401) + + const missingUpgrade = await server.request(GlobalPaths.upgrade, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }) + expect(missingUpgrade.status).toBe(401) + + const invalidUpgrade = await server.request(GlobalPaths.upgrade, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: "not-json", + }) + expect(invalidUpgrade.status).toBe(400) + + const missingLog = await server.request(ControlPaths.log, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "httpapi-auth-test", level: "info", message: "hello" }), + }) + expect(missingLog.status).toBe(401) + + const missingAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api", key: "secret" }), + }) + expect(missingAuth.status).toBe(401) + + const invalidAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ type: "api" }), + }) + expect(invalidAuth.status).toBe(400) + }) + + test("accepts auth_token query credentials", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(`${tmp.path}/hello.txt`, "hello") + + const response = await app({ password: "secret" }).request( + fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }), + { + headers: { + "x-opencode-directory": tmp.path, + }, + }, + ) + + expect(response.status).toBe(200) + }) + + test("selects instance from query before directory header", async () => { + await using header = await tmpdir({ git: true }) + await using query = await tmpdir({ git: true }) + await Bun.write(`${header.path}/hello.txt`, "header") + await Bun.write(`${query.path}/hello.txt`, "query") + + const response = await app().request(fileUrl({ directory: query.path }), { + headers: { + "x-opencode-directory": header.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ content: "query" }) + }) + + test("serves global health from Effect HttpApi", async () => { + const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ healthy: true }) + }) + + test("serves global event stream from Effect HttpApi", async () => { + const response = await app().request(GlobalPaths.event) + if (!response.body) throw new Error("missing event stream body") + const reader = response.body.getReader() + const chunk = await reader.read() + await reader.cancel() + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/event-stream") + expect(new TextDecoder().decode(chunk.value)).toContain("server.connected") + }) + + test("serves control log from Effect HttpApi", async () => { + const response = await app().request(ControlPaths.log, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + }) + + test("validates control auth without falling through to 404", async () => { + const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api" }), + }) + + expect(response.status).toBe(400) + }) + + test("validates global upgrade without invoking installers", async () => { + const response = await app().request(GlobalPaths.upgrade, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ success: false }) + }) +}) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts index 4fcf8864fe..a7e119bd87 100644 --- a/packages/opencode/test/server/httpapi-compression.test.ts +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { gunzipSync, inflateSync } from "node:zlib" +import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -7,12 +8,16 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 3be04b57a3..16e8975ea1 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" +import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -8,7 +9,10 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -20,6 +24,7 @@ async function waitDisposed(directory: string) { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts index 74a09cb253..edec8e9f76 100644 --- a/packages/opencode/test/server/httpapi-cors-vary.test.ts +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -6,13 +7,17 @@ import { disposeAllInstances } from "../fixture/fixture" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -function app() { - return Server.Default().app +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } const PREFLIGHT_HEADERS = { @@ -28,8 +33,19 @@ const PREFLIGHT_HEADERS = { // cached for one origin against a different origin. corsVaryFixLayer // restores the merged form. describe("CORS preflight Vary header", () => { + test("Hono backend preflight Vary contains Origin", async () => { + const response = await app(false).request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + expect([200, 204]).toContain(response.status) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") + }) + test("HTTP API backend preflight Vary contains Origin", async () => { - const response = await app().request("/global/config", { + const response = await app(true).request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -40,7 +56,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { - const response = await app().request("/global/config", { + const response = await app(true).request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -51,7 +67,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend does not duplicate Origin in Vary", async () => { - const response = await app().request("/global/config", { + const response = await app(true).request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -59,8 +75,8 @@ describe("CORS preflight Vary header", () => { const vary = response.headers.get("vary") ?? "" const originCount = vary .split(",") - .map((s: string) => s.trim().toLowerCase()) - .filter((s: string) => s === "origin").length + .map((s) => s.trim().toLowerCase()) + .filter((s) => s === "origin").length expect(originCount).toBe(1) }) }) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 6c83b00d53..8d7e95dfbf 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Config, ConfigProvider, Effect, Layer } from "effect" +import { Config, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Server } from "../../src/server/server" @@ -13,12 +13,15 @@ import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, } + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = "secret" yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD await resetDatabase() }), @@ -60,21 +63,12 @@ describe("HttpApi CORS", () => { }), ) - it.live("adds CORS headers to unauthorized responses", () => + it.live("adds CORS headers to legacy unauthorized responses", () => Effect.gen(function* () { - const handler = HttpRouter.toWebHandler( - ExperimentalHttpApiServer.createRoutes().pipe( - Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))), - ), - { disableLogger: true }, - ).handler - const response = yield* Effect.promise(() => - handler( - new Request(new URL("/global/config", "http://localhost"), { - headers: { origin: "https://app.opencode.ai" }, - }), - ExperimentalHttpApiServer.context, - ), + const response = yield* Effect.promise(async () => + Server.Legacy().app.request("/global/config", { + headers: { origin: "https://app.opencode.ai" }, + }), ) expect(response.status).toBe(401) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index df716ed096..940efed9c3 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" @@ -8,8 +9,11 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -function app() { - return Server.Default().app +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } async function readFirstChunk(response: Response) { @@ -32,12 +36,13 @@ async function readFirstEvent(response: Response) { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -describe("event HttpApi", () => { - test("serves event stream", async () => { +describe("event HttpApi bridge", () => { + test("serves event stream through experimental Effect route", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } }) @@ -49,11 +54,15 @@ describe("event HttpApi", () => { expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) - test("serves the initial server connected event", async () => { + test("matches legacy first event frame", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path } - const response = await app().request(EventPaths.event, { headers }) + const legacy = await app(false).request(EventPaths.event, { headers }) + const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index fac5f699c3..f66d15a3ee 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -1,3 +1,4 @@ +import { Flag } from "@opencode-ai/core/flag/flag" import { ConfigProvider, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { parse } from "./assertions" @@ -55,7 +56,16 @@ function app(modules: Runtime, backend: Backend, options: CallOptions) { const username = options.auth?.username const password = options.auth?.password const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = password + Flag.OPENCODE_SERVER_USERNAME = username if (appCache[cacheKey]) return appCache[cacheKey] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache[cacheKey] = { + request: (input, init) => legacy.request(input, init), + }) + } const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 9d3eaa0e53..7962f7df94 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -22,6 +22,7 @@ process.env.OPENCODE_DB = exerciseDatabasePath Flag.OPENCODE_DB = exerciseDatabasePath export const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 4560973abe..bc876d9f07 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1,10 +1,10 @@ /** - * End-to-end exerciser for the Effect HttpApi routes. + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. * - * The goal is not to be a normal unit test file. This is a route-coverage harness: - * every public route should have a small scenario that proves the route decodes - * requests, uses the right instance context, mutates storage when expected, and - * returns the expected response shape. + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. * * The script intentionally isolates `OPENCODE_DB` before importing modules that touch * storage. Scenarios may create/delete sessions and reset the database after each run, @@ -15,7 +15,8 @@ * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. * - `.at(...)` builds the request from that typed state. * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. - * - `.mutating()` tells the runner to reset isolated state after destructive routes. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. */ import { Effect } from "effect" import { OpenApi } from "effect/unstable/httpapi" @@ -1262,6 +1263,7 @@ const main = Effect.gen(function* () { const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) const selected = selectedScenarios(options, scenarios) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) @@ -1272,7 +1274,7 @@ const main = Effect.gen(function* () { } } - printHeader(options, effectRoutes, selected, missing, extra, { + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, { database: exerciseDatabasePath, global: exerciseGlobalRoot, }) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts index 7e79e972cb..cf674f1db9 100644 --- a/packages/opencode/test/server/httpapi-exercise/report.ts +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -14,6 +14,7 @@ export const color = { export function printHeader( options: Options, effectRoutes: string[], + honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[], @@ -23,7 +24,7 @@ export function printHeader( console.log(`${color.dim}db=${paths.database}${color.reset}`) console.log(`${color.dim}global=${paths.global}${color.reset}`) console.log( - `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`, + `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, ) console.log("") } diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index 9e432af2e3..b2ca3eb5af 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -41,7 +41,8 @@ export function coverageResult(scenario: Scenario): Result { export function parseOptions(args: string[]): Options { const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`) + if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth") + throw new Error(`invalid --mode ${mode}`) return { mode, include: option(args, "--include"), diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 2b3f720c84..c735cc125c 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -5,10 +5,20 @@ import type { Config } from "../../../src/config/config" import { ModelID, ProviderID } from "../../../src/provider/schema" import type { MessageV2 } from "../../../src/session/message-v2" import { MessageID, PartID } from "../../../src/session/schema" +import { stable } from "./assertions" import { call, callAuthProbe } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" -import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types" +import type { + ActiveScenario, + CallResult, + Options, + ProjectOptions, + Result, + Scenario, + ScenarioContext, + SeededContext, +} from "./types" export function runScenario(options: Options) { return (scenario: Scenario) => { @@ -28,6 +38,16 @@ export function runScenario(options: Options) { function runActive(options: Options, scenario: ActiveScenario) { if (options.mode === "auth") return runAuth(scenario) + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend(options, "effect", scenario) + const legacy = yield* runBackend(options, "legacy", scenario) + yield* trace(options, scenario, "compare start") + yield* compare(scenario, effect, legacy) + yield* trace(options, scenario, "compare done") + }) + } + return withContext(options, scenario, "shared", (ctx) => Effect.gen(function* () { yield* trace(options, scenario, "effect request start") @@ -36,6 +56,17 @@ function runActive(options: Options, scenario: ActiveScenario) { yield* trace(options, scenario, "effect expect start") yield* scenario.expect(ctx, ctx.state, effect) yield* trace(options, scenario, "effect expect done") + if (options.mode === "parity" && scenario.compare !== "none") { + yield* trace(options, scenario, "legacy request start") + const legacy = yield* call("legacy", scenario, ctx) + yield* trace(options, scenario, `legacy response ${legacy.status}`) + yield* trace(options, scenario, "legacy expect start") + yield* scenario.expect(ctx, ctx.state, legacy) + yield* trace(options, scenario, "legacy expect done") + yield* trace(options, scenario, "compare start") + yield* compare(scenario, effect, legacy) + yield* trace(options, scenario, "compare done") + } }), ) } @@ -43,18 +74,38 @@ function runActive(options: Options, scenario: ActiveScenario) { function runAuth(scenario: ActiveScenario) { return Effect.gen(function* () { const effect = yield* callAuthProbe("effect", scenario, "missing") + const legacy = yield* callAuthProbe("legacy", scenario, "missing") if (scenario.auth === "protected") { if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) + if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`) const effectAuthed = yield* callAuthProbe("effect", scenario, "valid") + const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid") if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials") + if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials") return } if (effect.status === 401) throw new Error("effect auth expected public access, got 401") + if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401") if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out") + if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out") }) } +function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(options, scenario, backend, (ctx) => + Effect.gen(function* () { + yield* trace(options, scenario, `${backend} request start`) + const result = yield* call(backend, scenario, ctx) + yield* trace(options, scenario, `${backend} response ${result.status}`) + yield* trace(options, scenario, `${backend} expect start`) + yield* scenario.expect(ctx, ctx.state, result) + yield* trace(options, scenario, `${backend} expect done`) + return result + }), + ) +} + function withContext( options: Options, scenario: ActiveScenario, @@ -249,8 +300,19 @@ function fakeLlmConfig(url: string): Partial { } } +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + const resetState = Effect.promise(async () => { const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME await modules.disposeAllInstances() diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 7163cf0c5a..ef1c868208 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -1,6 +1,7 @@ export type Runtime = { PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../../../src/server/server"))["Server"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] Instance: (typeof import("../../../src/project/instance"))["Instance"] @@ -21,6 +22,7 @@ export function runtime() { return (runtimePromise ??= (async () => { const publicApi = await import("../../../src/server/routes/instance/httpapi/public") const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") + const server = await import("../../../src/server/server") const appRuntime = await import("../../../src/effect/app-runtime") const instanceRef = await import("../../../src/effect/instance-ref") const instance = await import("../../../src/project/instance") @@ -35,6 +37,7 @@ export function runtime() { return { PublicApi: publicApi.PublicApi, ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, AppLayer: appRuntime.AppLayer, InstanceRef: instanceRef.InstanceRef, Instance: instance.Instance, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index a0466d7b70..2cdc822541 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -10,8 +10,8 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const export type Method = (typeof Methods)[number] export type OpenApiMethod = (typeof OpenApiMethods)[number] -export type Mode = "effect" | "coverage" | "auth" -export type Backend = "effect" +export type Mode = "effect" | "parity" | "coverage" | "auth" +export type Backend = "effect" | "legacy" export type Comparison = "none" | "status" | "json" export type CaptureMode = "full" | "stream" export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 0b8d8051bc..8684edf134 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" @@ -14,9 +15,11 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testWorktreeMutations = process.platform === "win32" ? test.skip : test function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -36,6 +39,7 @@ async function waitReady(directory: string) { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts new file mode 100644 index 0000000000..b5f0805e4c --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +async function waitDisposed(directory: string) { + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +describe("instance HttpApi", () => { + test("serves catalog read endpoints through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const [commands, agents, skills, lsp, formatter] = await Promise.all([ + app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), + ]) + + expect(commands.status).toBe(200) + expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) + + expect(agents.status).toBe(200) + expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) + + expect(skills.status).toBe(200) + expect(await skills.json()).toBeArray() + + expect(lsp.status).toBe(200) + expect(await lsp.json()).toEqual([]) + + expect(formatter.status).toBe(200) + expect(await formatter.json()).toEqual([]) + }) + + test("serves project git init through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const disposed = waitDisposed(tmp.path) + + const response = await app().request("/project/git/init", { + method: "POST", + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + await disposed + + const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + }) + + test("serves project update through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) + expect(current.status).toBe(200) + const project = (await current.json()) as { id: string } + + const response = await app().request(`/project/${project.id}`, { + method: "PATCH", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + id: project.id, + name: "patched-project", + commands: { start: "bun dev" }, + }) + + const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) + expect(list.status).toBe(200) + expect(await list.json()).toContainEqual( + expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), + ) + }) + + test("serves instance dispose through Hono bridge", async () => { + await using tmp = await tmpdir() + + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", + }) + + const response = await app().request(InstancePaths.dispose, { + method: "POST", + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + expect((await disposed).directory).toBe(tmp.path) + }) +}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 946de2835c..365fa1220f 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -14,18 +14,22 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -// Flip the experimental workspaces flag so SyncEvent.run actually writes to -// EventSequenceTable (the source of truth the fence middleware reads). Reset -// the database around the test so per-instance state does not leak between -// runs. resetDatabase() already calls disposeAllInstances(), so we don't -// repeat it. +// Flip the experimental HttpApi flag so backend selection telemetry on the +// production routes reports the right backend, and the experimental +// workspaces flag so SyncEvent.run actually writes to EventSequenceTable +// (the source of truth the fence middleware reads). Reset the database +// around the test so per-instance state does not leak between runs. +// resetDatabase() already calls disposeAllInstances(), so we don't repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { + const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await resetDatabase() }), diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts new file mode 100644 index 0000000000..656541be71 --- /dev/null +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { MessageID, PartID } from "../../src/session/schema" +import { Session } from "@/session/session" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} +type TestApp = ReturnType + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +const seedSessions = Effect.gen(function* () { + const svc = yield* Session.Service + const parent = yield* svc.create({ title: "parent" }) + yield* svc.create({ title: "child", parentID: parent.id }) + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: parent.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: parent.id, + messageID: message.id, + type: "text", + text: "hello", + }) + return { parent, message } +}) + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path)))) +} + +function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) { + return Effect.promise(async () => { + const response = await serverApp.request(path, { headers }) + if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`) + return await response.json() + }) +} + +function expectJsonParity(input: { + label: string + legacy: TestApp + httpapi: TestApp + path: string + headers: HeadersInit +}) { + return Effect.gen(function* () { + const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers) + const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers) + expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) + return httpapi + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi JSON parity", () => { + it.live( + "matches legacy JSON shape for safe GET endpoints", + withTmp( + { + git: true, + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) + + const headers = { "x-opencode-directory": tmp.path } + const legacy = app(false) + const httpapi = app(true) + + yield* Effect.forEach( + [ + { label: "global.health", path: GlobalPaths.health, headers: {} }, + { label: "global.config", path: GlobalPaths.config, headers: {} }, + { label: "instance.path", path: InstancePaths.path, headers }, + { label: "instance.vcs", path: InstancePaths.vcs, headers }, + { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, + { label: "instance.command", path: InstancePaths.command, headers }, + { label: "instance.agent", path: InstancePaths.agent, headers }, + { label: "instance.skill", path: InstancePaths.skill, headers }, + { label: "instance.lsp", path: InstancePaths.lsp, headers }, + { label: "instance.formatter", path: InstancePaths.formatter, headers }, + { label: "config.get", path: "/config", headers }, + { label: "config.providers", path: "/config/providers", headers }, + { label: "project.list", path: "/project", headers }, + { label: "project.current", path: "/project/current", headers }, + { label: "provider.list", path: "/provider", headers }, + { label: "provider.auth", path: "/provider/auth", headers }, + { label: "permission.list", path: "/permission", headers }, + { label: "question.list", path: "/question", headers }, + { label: "mcp.status", path: McpPaths.status, headers }, + { label: "pty.shells", path: PtyPaths.shells, headers }, + { label: "pty.list", path: PtyPaths.list, headers }, + { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, + { + label: "file.content", + path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, + headers, + }, + { label: "file.status", path: FilePaths.status, headers }, + { + label: "find.file", + path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers, + }, + { + label: "find.text", + path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, + headers, + }, + { + label: "find.symbol", + path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, + headers, + }, + { label: "experimental.console", path: ExperimentalPaths.console, headers }, + { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, + { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, + { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, + { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) + + it.live( + "matches legacy JSON shape for session read endpoints", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer)) + const legacy = app(false) + const httpapi = app(true) + + const rootsFalse = yield* expectJsonParity({ + label: "session.list roots false", + legacy, + httpapi, + path: `${SessionPaths.list}?roots=false`, + headers, + }) + expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id) + expect((rootsFalse as Session.Info[]).length).toBe(2) + + const experimentalRootsFalse = yield* expectJsonParity({ + label: "experimental.session roots false", + legacy, + httpapi, + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`, + headers, + }) + expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2) + + const experimentalArchivedFalse = yield* expectJsonParity({ + label: "experimental.session archived false", + legacy, + httpapi, + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`, + headers, + }) + expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2) + + yield* Effect.forEach( + [ + { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, + { label: "session.list all", path: SessionPaths.list, headers }, + { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, + { + label: "session.children", + path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), + headers, + }, + { + label: "session.messages", + path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), + headers, + }, + { + label: "session.messages empty before", + path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`, + headers, + }, + { + label: "session.message", + path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), + headers, + }, + { + label: "experimental.session", + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, + headers, + }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index b2ff28ec67..b49fbe98b5 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -10,6 +10,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, envPassword: process.env.OPENCODE_SERVER_PASSWORD, @@ -19,6 +20,7 @@ const auth = { username: "opencode", password: "listen-secret" } const testPty = process.platform === "win32" ? test.skip : test afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD @@ -29,7 +31,8 @@ afterEach(async () => { await resetDatabase() }) -async function startListener() { +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -37,7 +40,8 @@ async function startListener() { return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener() { +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -208,6 +212,22 @@ describe("HttpApi Server.listen", () => { } }) + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startListener() @@ -280,18 +300,20 @@ describe("HttpApi Server.listen", () => { } }) - testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener() - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) - ws.send("ping-no-auth\n") - expect(await message).toContain("ping-no-auth") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index b6c7aebcd2..f442df5770 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" @@ -14,11 +15,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const context = Context.empty() as Context.Context const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) -function app() { - return Server.Default().app +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } type TestApp = ReturnType @@ -76,6 +79,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s }) afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -161,19 +165,23 @@ describe("mcp HttpApi", () => { }) it.live( - "returns unsupported OAuth error responses", + "matches legacy unsupported OAuth error responses", withMcpProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir } + const legacy = app(false) + const httpapi = app(true) yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => Effect.gen(function* () { - const response = yield* readResponse({ app: app(), path, headers }) + const legacyResponse = yield* readResponse({ app: legacy, path, headers }) + const httpapiResponse = yield* readResponse({ app: httpapi, path, headers }) - expect(response).toEqual({ + expect(legacyResponse).toEqual({ status: 400, body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), }) + expect(httpapiResponse).toEqual(legacyResponse) }), ) }), diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 0000000000..9d7eff4964 --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's public NamedError +// envelope `{ name, data: { message } }`. SDK consumers read +// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a +// compatibility break. +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test("HttpApi 404 body matches Hono shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + + const hono = await app(false).request("/session/ses_does_not_exist", { headers }) + const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) + + expect(httpapi.status).toBe(hono.status) + const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } + expect(body).toEqual(await hono.json()) + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 12262e30ed..fa32608fc8 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" @@ -12,13 +13,15 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app() { - return Server.Default().app +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function requestAuthorize(input: { @@ -98,37 +101,54 @@ function withProviderProject(self: (dir: string) => Effect.Effect { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) describe("provider HttpApi", () => { it.live( - "serves OAuth authorize response shapes", + "matches legacy OAuth authorize response shapes", withProviderProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir, "content-type": "application/json" } - const server = app() + const legacy = app(false) + const httpapi = app(true) - const api = yield* requestAuthorize({ - app: server, + const apiLegacy = yield* requestAuthorize({ + app: legacy, providerID, method: 0, headers, }) - // method 0 (api-key style) — authorize() resolves with no further - // redirect; #26474 changed the wire format to JSON `null` so clients - // can `.json()` parse uniformly instead of getting an empty body - // that throws. - expect(api).toEqual({ status: 200, body: "null" }) + const apiHttpApi = yield* requestAuthorize({ + app: httpapi, + providerID, + method: 0, + headers, + }) + expect(apiLegacy).toEqual({ status: 200, body: "" }) + // #26474 changed the HTTP API authorize handler to serialize an + // undefined service result as JSON `null` instead of an empty body + // so clients can `.json()` parse the response uniformly. The legacy + // Hono path still emits an empty body (`c.json(undefined)`); the new + // backend's body diverges intentionally. + expect(apiHttpApi).toEqual({ status: 200, body: "null" }) - const oauth = yield* requestAuthorize({ - app: server, + const oauthLegacy = yield* requestAuthorize({ + app: legacy, providerID, method: 1, headers, }) - expect(JSON.parse(oauth.body)).toEqual({ + const oauthHttpApi = yield* requestAuthorize({ + app: httpapi, + providerID, + method: 1, + headers, + }) + expect(oauthHttpApi).toEqual(oauthLegacy) + expect(JSON.parse(oauthHttpApi.body)).toEqual({ url: oauthURL, method: "code", instructions: oauthInstructions, diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 987eba6b38..5e63eae61c 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -16,13 +17,16 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await resetDatabase() }), ) @@ -46,8 +50,9 @@ const effectIt = testEffect( ), ) -function app() { - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function serverUrl() { @@ -57,6 +62,7 @@ function serverUrl() { const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -115,6 +121,18 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) + test("matches Hono missing PTY error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) + + const hono = await app(false).request(path, { headers }) + const httpapi = await app().request(path, { headers }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index b1d4af76b8..fd82e78639 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -12,7 +13,10 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) +const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + function app(input: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( Layer.provide( @@ -44,6 +48,7 @@ async function cancelBody(response: Response) { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8a179a4dcc..493f890838 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,21 +22,23 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } -type ServerPath = "default" | "raw" +type Backend = "legacy" | "httpapi" type Sdk = ReturnType type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { +function app(backend: Backend, input?: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - if (serverPath === "default") return Server.Default().app + if (backend === "legacy") return Server.Legacy().app const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( @@ -60,7 +62,7 @@ function app(serverPath: ServerPath, input?: { password?: string; username?: str } function client( - serverPath: ServerPath, + backend: Backend, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { @@ -68,12 +70,12 @@ function client( baseUrl: "http://localhost", directory, headers: input?.headers, - fetch: serverFetch(serverPath, input), + fetch: serverFetch(backend, input), }) } -function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { - const serverApp = app(serverPath, input) +function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { + const serverApp = app(backend, input) return Object.assign( async (request: RequestInfo | URL, init?: RequestInit) => await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), @@ -192,20 +194,20 @@ function httpapi(name: string, effect: Effect.Effect) { it.live(name, effect) } -function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { +function parity(name: string, scenario: (backend: Backend) => Effect.Effect) { it.live( name, Effect.gen(function* () { - const standard = yield* scenario("default") + const legacy = yield* scenario("legacy") yield* resetState() - const raw = yield* scenario("raw") - expect(raw).toEqual(standard) + const httpapi = yield* scenario("httpapi") + expect(httpapi).toEqual(legacy) }), ) } function withProject( - serverPath: ServerPath, + backend: Backend, options: { git?: boolean; config?: Partial; setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { @@ -214,30 +216,30 @@ function withProject( (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore), ).pipe( Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void), - Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })), + Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })), ) } -function withStandardProject(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect) { - return withProject(serverPath, { setup: writeStandardFiles }, run) +function withStandardProject(backend: Backend, run: (input: ProjectFixture) => Effect.Effect) { + return withProject(backend, { setup: writeStandardFiles }, run) } -function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { +function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect) { return Effect.gen(function* () { const llm = yield* TestLLMServer - return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) }).pipe(Effect.provide(TestLLMServer.layer)) } function withFakeLlmProject( - serverPath: ServerPath, + backend: Backend, options: { setup?: (dir: string) => Effect.Effect }, run: (input: LlmProjectFixture) => Effect.Effect, ) { return Effect.gen(function* () { const llm = yield* TestLLMServer return yield* withProject( - serverPath, + backend, { config: providerConfig(llm.url), setup: options.setup, @@ -304,6 +306,7 @@ function seedMessage(directory: string, sessionID: string) { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME await disposeAllInstances() @@ -314,7 +317,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("raw") + const sdk = client("httpapi") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -331,7 +334,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) @@ -354,9 +357,9 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK global and control behavior", (serverPath) => + parity("matches generated SDK global and control behavior across backends", (backend) => Effect.gen(function* () { - const sdk = client(serverPath) + const sdk = client(backend) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -369,22 +372,22 @@ describe("HttpApi SDK", () => { }), ) - serverPathParity("matches generated SDK global event stream", (serverPath) => - firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( + parity("matches generated SDK global event stream across backends", (backend) => + firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ) - serverPathParity("matches generated SDK instance event stream", (serverPath) => - withStandardProject(serverPath, ({ sdk }) => + parity("matches generated SDK instance event stream across backends", (backend) => + withStandardProject(backend, ({ sdk }) => firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ), ) - serverPathParity("matches generated SDK missing session errors", (serverPath) => - withStandardProject(serverPath, ({ sdk }) => + parity("matches generated SDK missing session errors across backends", (backend) => + withStandardProject(backend, ({ sdk }) => Effect.gen(function* () { const sessionID = "ses_missing" const expected = { @@ -405,8 +408,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("formats missing session validation errors for -s", (serverPath) => - withStandardProject(serverPath, ({ directory }) => + parity("formats missing session validation errors for -s", (backend) => + withStandardProject(backend, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" const thrown = yield* captureThrown(() => @@ -414,7 +417,7 @@ describe("HttpApi SDK", () => { url: "http://localhost", directory, sessionID, - fetch: serverFetch(serverPath), + fetch: serverFetch(backend), }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -423,21 +426,20 @@ describe("HttpApi SDK", () => { ), ) - httpapi( - "uses generated SDK basic auth behavior", - withStandardProject("raw", ({ directory }) => + parity("matches generated SDK basic auth behavior across backends", (backend) => + withStandardProject(backend, ({ directory }) => Effect.gen(function* () { const missing = yield* capture(() => - client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), + client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), ) const bad = yield* capture(() => - client("raw", directory, { + client(backend, directory, { password: "secret", headers: { authorization: authorization("opencode", "wrong") }, }).file.read({ path: "hello.txt" }), ) const good = yield* capture(() => - client("raw", directory, { + client(backend, directory, { password: "secret", headers: { authorization: authorization("opencode", "secret") }, }).file.read({ path: "hello.txt" }), @@ -451,8 +453,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK instance read routes", (serverPath) => - withStandardProject(serverPath, ({ sdk, directory }) => + parity("matches generated SDK instance read routes across backends", (backend) => + withStandardProject(backend, ({ sdk, directory }) => Effect.gen(function* () { const project = yield* capture(() => sdk.project.current()) const projects = yield* capture(() => sdk.project.list()) @@ -502,8 +504,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK session lifecycle routes", (serverPath) => - withStandardProject(serverPath, ({ sdk }) => + parity("matches generated SDK session lifecycle routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => Effect.gen(function* () { const parent = yield* capture(() => sdk.session.create({ title: "parent" })) const parentID = String(record(parent.data).id) @@ -555,8 +557,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK session message and part routes", (serverPath) => - withStandardProject(serverPath, ({ sdk, directory }) => + parity("matches generated SDK session message and part routes across backends", (backend) => + withStandardProject(backend, ({ sdk, directory }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "messages" })) const sessionID = String(record(session.data).id) @@ -607,8 +609,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) => - withStandardProject(serverPath, ({ sdk }) => + parity("matches generated SDK prompt no-reply routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "prompt" })) const sessionID = String(record(session.data).id) @@ -644,8 +646,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) => - withFakeLlm(serverPath, ({ sdk, llm }) => + parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) => + withFakeLlm(backend, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -680,7 +682,7 @@ describe("HttpApi SDK", () => { httpapi( "includes project skills in REST API async prompt context", - withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => + withFakeLlmProject("httpapi", { setup: writeProjectSkill }, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -708,8 +710,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) => - withStandardProject(serverPath, ({ sdk }) => + parity("matches generated SDK TUI validation and command routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "tui" })) const sessionID = String(record(session.data).id) @@ -759,8 +761,8 @@ describe("HttpApi SDK", () => { ), ) - serverPathParity("matches generated SDK project git initialization", (serverPath) => - withProject(serverPath, { git: false }, ({ sdk, directory }) => + parity("matches generated SDK project git initialization across backends", (backend) => + withProject(backend, { git: false }, ({ sdk, directory }) => Effect.gen(function* () { const before = yield* capture(() => sdk.project.current()) const init = yield* capture(() => sdk.project.initGit()) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 96ddf8fcce..c1d82446b9 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -30,14 +30,16 @@ import { it } from "../lib/effect" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -function app() { - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { @@ -117,6 +119,10 @@ function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } +function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { + return Effect.promise(async () => app(experimental).request(path, init)) +} + function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -143,6 +149,7 @@ function withTmp( } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() @@ -191,7 +198,7 @@ describe("session HttpApi", () => { ) it.live( - "serves read routes", + "serves read routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -298,7 +305,7 @@ describe("session HttpApi", () => { ) it.live( - "serves lifecycle mutation routes", + "serves lifecycle mutation routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -382,26 +389,39 @@ describe("session HttpApi", () => { ) it.live( - "validates archived timestamp values", + "matches legacy archived timestamp validation", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const session = yield* createSession(tmp.path, { title: "archived" }) + const legacy = yield* createSession(tmp.path, { title: "legacy" }) + const effect = yield* createSession(tmp.path, { title: "effect" }) const body = JSON.stringify({ time: { archived: -1 } }) - const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), { + const legacyResponse = yield* requestWithBackend( + false, + pathFor(SessionPaths.update, { sessionID: legacy.id }), + { + method: "PATCH", + headers, + body, + }, + ) + expect(legacyResponse.status).toBe(200) + expect((yield* json(legacyResponse)).time.archived).toBe(-1) + + const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { method: "PATCH", headers, body, }) - expect(response.status).toBe(200) - expect((yield* json(response)).time.archived).toBe(-1) + expect(effectResponse.status).toBe(legacyResponse.status) + expect((yield* json(effectResponse)).time.archived).toBe(-1) }), ), ) it.live( - "uses project-scoped path and directory precedence", + "matches legacy project-scoped path and directory precedence", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const currentDir = path.join(tmp.path, "packages", "opencode", "src") @@ -421,18 +441,22 @@ describe("session HttpApi", () => { directory: currentDir, }) const headers = { "x-opencode-directory": tmp.path } - const sessions = (yield* json( - yield* request(`${SessionPaths.list}?${query}`, { headers }), + const legacy = (yield* json( + yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + const effect = (yield* json( + yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), )).map((item) => item.id) - expect(sessions).toContain(pathSession.id) - expect(sessions).not.toContain(pathlessSession.id) + expect(legacy).toContain(pathSession.id) + expect(legacy).not.toContain(pathlessSession.id) + expect(effect).toEqual(legacy) }), ), ) it.live( - "serves paginated message link headers", + "matches legacy paginated message link headers", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -441,17 +465,20 @@ describe("session HttpApi", () => { yield* createTextMessage(tmp.path, session.id, "second") const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` - const response = yield* request(route, { headers }) + const legacy = yield* requestWithBackend(false, route, { headers }) + const effect = yield* requestWithBackend(true, route, { headers }) - expect(response.headers.get("x-next-cursor")).toBeTruthy() - expect(response.headers.get("link")).toContain("limit=1") - expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") + expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) + expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) + expect(effect.headers.get("access-control-expose-headers")).toBe( + legacy.headers.get("access-control-expose-headers"), + ) }), ), ) it.live( - "serves message mutation routes", + "serves message mutation routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -495,7 +522,7 @@ describe("session HttpApi", () => { ) it.live( - "serves remaining non-LLM session mutation routes", + "serves remaining non-LLM session mutation routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index c5ee637842..c4d2397afd 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -13,11 +13,13 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) +const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -function app() { - return Server.Default().app +function app(httpapi = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi + return httpapi ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { @@ -26,13 +28,14 @@ function runSession(fx: Effect.Effect) { afterEach(async () => { mock.restore() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() }) describe("sync HttpApi", () => { - test("serves sync routes", async () => { + test("serves sync routes through Hono bridge", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -84,7 +87,7 @@ describe("sync HttpApi", () => { expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) - test("validates seq values", async () => { + test("matches legacy seq validation", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const cases = [ @@ -113,12 +116,18 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const response = await app().request(item.path, { + const legacy = await app(false).request(item.path, { method: "POST", headers, body: JSON.stringify(item.body), }) - expect(response.status).toBe(400) + const httpapi = await app(true).request(item.path, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) + expect(httpapi.status).toBe(legacy.status) + expect(httpapi.status).toBe(400) } }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts new file mode 100644 index 0000000000..91cad362a9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { Context } from "hono" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TuiEvent } from "../../src/cli/cmd/tui/event" +import { SessionID } from "../../src/session/schema" +import { Instance } from "../../src/project/instance" +import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" +import { callTui } from "../../src/server/routes/instance/tui" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { OpenApi } from "effect/unstable/httpapi" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function nextCommandExecute() { + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) +} + +async function expectTrue(path: string, headers: Record, body?: unknown) { + const response = await app().request(path, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }) + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +describe("tui HttpApi bridge", () => { + test("documents legacy bad request responses", async () => { + const legacy = await Server.openapiHono() + const effect = OpenApi.fromApi(TuiApi) + for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { + expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() + expect(effect.paths[path].post?.responses?.[400]).toBeDefined() + } + }) + + test("serves TUI command and event routes through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + + await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" }) + await expectTrue(TuiPaths.openHelp, headers) + await expectTrue(TuiPaths.openSessions, headers) + await expectTrue(TuiPaths.openThemes, headers) + await expectTrue(TuiPaths.openModels, headers) + await expectTrue(TuiPaths.submitPrompt, headers) + await expectTrue(TuiPaths.clearPrompt, headers) + await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" }) + await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" }) + await expectTrue(TuiPaths.publish, headers, { + type: "tui.prompt.append", + properties: { text: "from publish" }, + }) + + const missingSessionID = SessionID.descending() + const missing = await app().request(TuiPaths.selectSession, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ sessionID: missingSessionID }), + }) + expect(missing.status).toBe(404) + }) + + test("matches Hono missing selected session error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ sessionID: SessionID.descending() }) + + const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) + const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + + test("matches legacy unknown execute command behavior", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ command: "unknown_command" }) + + const legacyCommand = nextCommandExecute() + const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(legacy.status).toBe(200) + expect(await legacy.json()).toBe(true) + + const effectCommand = nextCommandExecute() + const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(effect.status).toBe(200) + expect(await effect.json()).toBe(true) + + const legacyPublished = await legacyCommand + const effectPublished = await effectCommand + expect(effectPublished).toBe(legacyPublished) + expect(legacyPublished).toBeUndefined() + }) + + test("serves TUI control queue through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) + const headers = { "x-opencode-directory": tmp.path } + + const next = await app().request(TuiPaths.controlNext, { headers }) + expect(next.status).toBe(200) + expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } }) + + await expectTrue(TuiPaths.controlResponse, headers, { ok: true }) + expect(await pending).toEqual({ ok: true }) + }) +}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 68ed2a3e25..440aeaecb5 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -22,6 +22,7 @@ import { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, @@ -30,6 +31,7 @@ const original = { } afterEach(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME @@ -115,6 +117,7 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest. describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -134,6 +137,7 @@ describe("HttpApi UI fallback", () => { }) test("strips upstream transfer encoding headers from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -185,6 +189,7 @@ describe("HttpApi UI fallback", () => { // forwarded through the proxy while the proxy itself re-frames the body, // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await Effect.runPromise( @@ -227,6 +232,7 @@ describe("HttpApi UI fallback", () => { }) test("serves embedded UI assets when Bun can read them but access reports missing", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined const response = await Effect.runPromise( @@ -256,6 +262,7 @@ describe("HttpApi UI fallback", () => { }) test("allows embedded UI terminal wasm and theme preload CSP", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const script = 'document.documentElement.dataset.theme = "dark"' const response = await Effect.runPromise( @@ -287,12 +294,15 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const response = await Server.Default().app.request("/session/nope") expect(response.status).toBe(404) }) test("requires server password for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/") @@ -302,6 +312,7 @@ describe("HttpApi UI fallback", () => { }) test("accepts auth token for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ @@ -315,6 +326,7 @@ describe("HttpApi UI fallback", () => { }) test("accepts basic auth for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { @@ -330,6 +342,7 @@ describe("HttpApi UI fallback", () => { // server returning 401 breaks PWA install. These specific public assets // should bypass auth. test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { @@ -343,6 +356,8 @@ describe("HttpApi UI fallback", () => { }) test("allows web UI preflight without auth", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const response = await app({ password: "secret", username: "opencode" }).request("/", { method: "OPTIONS", headers: { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a2de1362fb..2e64081b8f 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -24,14 +24,16 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) -function request(path: string, directory: string, init: RequestInit = {}) { +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -159,6 +161,7 @@ function eventStreamResponse() { afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) @@ -286,6 +289,32 @@ describe("workspace HttpApi", () => { }), ) + it.live("documents legacy Hono accepting the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request( + WorkspacePaths.list, + dir, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }, + false, + ) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + }) + }), + ) + it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts new file mode 100644 index 0000000000..c6e8005a20 --- /dev/null +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" + +function fakeContext(method: string, url: string, params: Record) { + return { + req: { + method, + url, + param: () => params, + }, + } +} + +describe("paramToAttributeKey", () => { + test("converts fooID to foo.id", () => { + expect(paramToAttributeKey("sessionID")).toBe("session.id") + expect(paramToAttributeKey("messageID")).toBe("message.id") + expect(paramToAttributeKey("partID")).toBe("part.id") + expect(paramToAttributeKey("projectID")).toBe("project.id") + expect(paramToAttributeKey("providerID")).toBe("provider.id") + expect(paramToAttributeKey("ptyID")).toBe("pty.id") + expect(paramToAttributeKey("permissionID")).toBe("permission.id") + expect(paramToAttributeKey("requestID")).toBe("request.id") + expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") + }) + + test("namespaces non-ID params under opencode.", () => { + expect(paramToAttributeKey("name")).toBe("opencode.name") + expect(paramToAttributeKey("slug")).toBe("opencode.slug") + }) +}) + +describe("requestAttributes", () => { + test("includes http method and path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) + expect(attrs["http.method"]).toBe("GET") + expect(attrs["http.path"]).toBe("/session") + }) + + test("strips query string from path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) + expect(attrs["http.path"]).toBe("/file/search") + }) + + test("emits OTel-style .id for ID-shaped route params", () => { + const attrs = requestAttributes( + fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { + sessionID: "ses_abc", + messageID: "msg_def", + partID: "prt_ghi", + }), + ) + expect(attrs["session.id"]).toBe("ses_abc") + expect(attrs["message.id"]).toBe("msg_def") + expect(attrs["part.id"]).toBe("prt_ghi") + // No camelCase leftovers: + expect(attrs["opencode.sessionID"]).toBeUndefined() + expect(attrs["opencode.messageID"]).toBeUndefined() + expect(attrs["opencode.partID"]).toBeUndefined() + }) + + test("produces no param attributes when no params are matched", () => { + const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) + expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) + }) + + test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { + const attrs = requestAttributes( + fakeContext("POST", "http://localhost/mcp/exa/connect", { + name: "exa", + }), + ) + expect(attrs["opencode.name"]).toBe("exa") + expect(attrs["name"]).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index e95d706d54..768a261a00 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -13,13 +13,16 @@ import { testEffect } from "../lib/effect" const stateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, } + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.addFinalizer(() => Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES await resetDatabase() }), diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index b3f74a1bf6..946ad1402b 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,9 +9,16 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") -await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +// `bun dev generate` now derives the spec from the Effect HttpApi contract by +// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. +if (openapiSource === "httpapi") { + await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) +} await createClient({ input: "./openapi.json",