Revert "research: delete Hono backend (do not merge) (#25667)" and cleanup

This reverts:
- 28b03595b research: delete Hono backend (do not merge) (#25667)
- b24a4e897 chore(server): clean up post-Hono-deletion scar tissue (#26542)

v1.14.42 broke startup for users with plugins that depend on the
Hono wire format (most visibly opencode-gemini-auth, see #26546).
Restoring Hono as the default backend on stable channels while we
investigate the actual plugin compatibility story.

OPENCODE_EXPERIMENTAL_HTTPAPI flag and dual-backend selection come
back. Stable installs default to Hono; dev/beta default to HTTP API.

Conflict resolution: took the pre-deletion side for control-plane
schemas and the seven test files where post-deletion follow-up PRs
had also touched the conflicting lines. The HTTP API code added
since the deletion (compression, cors-vary, fence, lifecycle log,
account error mapping, etc.) is preserved as-is — those still apply
on the HTTP API path for users on dev/beta channels.
This commit is contained in:
Kit Langton
2026-05-09 13:37:36 -04:00
parent b1cd25de3d
commit b4836589f4
84 changed files with 8039 additions and 226 deletions

View File

@@ -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=="],

View File

@@ -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"),

View File

@@ -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:"

View File

@@ -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

View File

@@ -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).

View File

@@ -1,4 +1,6 @@
import z from "zod"
import { Schema } from "effect"
import { zodObject } from "@/util/effect-zod"
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
type: Type
@@ -16,6 +18,23 @@ export function define<Type extends string, Properties extends Schema.Top>(
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()

View File

@@ -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<string, Record<string, any>> }
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]

View File

@@ -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({

View File

@@ -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<Schema.Schema.Type<typeof WorkspaceInfo>>
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<Schema.Schema.Type<typeof WorkspaceListedInfo>>
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<typeof WorkspaceAdapterEntry>
export type Target =

View File

@@ -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<typeof CreateInput>
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<typeof SessionWarpInput>
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {

View File

@@ -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<typeof createBunWebSocket>["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)),
}
},
}

View File

@@ -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<ServerType>((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<void> | undefined
return {
port: addr.port,
stop(close?: boolean) {
closing ??= new Promise<void>((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),
}
},
}

View File

@@ -0,0 +1,26 @@
import type { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
export type FetchApp = {
fetch(request: Request): Response | Promise<Response>
}
export type Opts = {
port: number
hostname: string
}
export type Listener = {
port: number
stop: (close?: boolean) => Promise<void>
}
export interface Runtime {
upgradeWebSocket: UpgradeWebSocket
listen(opts: Opts): Promise<Listener>
}
export interface Adapter {
create(app: Hono): Runtime
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
}

View File

@@ -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<typeof attributes>
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<string, string> {
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",
}
}

View File

@@ -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]]))
}

View File

@@ -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))
}
}

View File

@@ -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 }

View File

@@ -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)
}

View File

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

View File

@@ -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)
},
)
}

View File

@@ -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<Workspace.CreateInput, "projectID">
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),
}),
),
)
},
),
)

View File

@@ -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<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
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 })
},
),
)

View File

@@ -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.

View File

@@ -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),
}
}),
),
)

View File

@@ -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<string | null>()
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()
}
})
},
)

View File

@@ -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<typeof QueryBoolean> | 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()
}),
),
)

View File

@@ -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()
}),
),
)

View File

@@ -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()

View File

@@ -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,

View File

@@ -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<unknown>(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
}),
),

View File

@@ -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<unknown>
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()
}),
)
}

View File

@@ -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
}),
),
)

View File

@@ -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()
},
})
},
})
}
}

View File

@@ -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()
}),
),
)

View File

@@ -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 })
}),
),
)

View File

@@ -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<string, (typeof all)[string]> = {}
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
}),
),
)

View File

@@ -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()
},
}
}),
)
}

View File

@@ -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
}),
),
)

File diff suppressed because it is too large Load Diff

View File

@@ -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)
},
),
)

View File

@@ -0,0 +1,59 @@
import type { Context } from "hono"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? 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.<name>` 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<string, string>
}
}
// 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<string, string> {
const attributes: Record<string, string> = {
"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<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
}
export async function jsonRequest<C extends Context, A, E>(
name: string,
c: C,
effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
) {
return c.json(
await runRequest(
name,
c,
Effect.gen(() => effect(c)),
),
)
}

View File

@@ -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<typeof TuiEvent.ToastShow.properties>,
)
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<string, unknown> }
// 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),
)

View File

@@ -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))

View File

@@ -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<T extends { app: ServerApp; runtime: unknown }>(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<Listener> {
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<void> | 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<Listener> {
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<Listener> {
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<Listener> {
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<Listener> {
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<void> | undefined
let stopPromise: Promise<void> | 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<Listener> {
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)

View File

@@ -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<typeof TuiRequest>
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()

View File

@@ -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)
}
}

View File

@@ -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<typeof AssistantErrorZod>
// 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<typeof AssistantErrorSchema>
// ── Prompt input schemas ─────────────────────────────────────────────────────
//

View File

@@ -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()

View File

@@ -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<typeof Info>
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<typeof CreateInput>
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<typeof RemoveInput>
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<typeof ResetInput>
export const NotGitError = NamedError.create(

View File

@@ -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()
})
})

View File

@@ -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)
})

View File

@@ -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<typeof OpenApi.fromApi> | 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<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
return Object.entries(spec.paths)
.flatMap(([path, item]) =>
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
)
.sort()
}
function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
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<string, unknown>
}
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
}
type OpenApiSchema = {
$ref?: string
allOf?: unknown[]
anyOf?: unknown[]
oneOf?: unknown[]
properties?: Record<string, unknown>
type?: string | string[]
}
type Operation = {
parameters?: unknown[]
responses?: unknown
requestBody?: unknown
}
type RequestBody = {
content?: Record<string, { schema?: OpenApiSchema }>
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<string, Partial<Record<(typeof methods)[number], Operation>>> }
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<string, Partial<Record<(typeof methods)[number], Operation>>> }
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<string, unknown>)[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<string, OpenApiSchema> | 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 })
})
})

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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)
})
})

View File

@@ -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)

View File

@@ -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)
})
})

View File

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

View File

@@ -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,
}

View File

@@ -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,
})

View File

@@ -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("")
}

View File

@@ -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"),

View File

@@ -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<A, E>(
options: Options,
scenario: ActiveScenario,
@@ -249,8 +300,19 @@ function fakeLlmConfig(url: string): Partial<Config.Info> {
}
}
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()

View File

@@ -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,

View File

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

View File

@@ -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()
})

View File

@@ -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)
})
})

View File

@@ -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()
}),

View File

@@ -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<typeof app>
function pathFor(path: string, params: Record<string, string>) {
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<A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) {
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 },
)
}),
),
)
})

View File

@@ -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)
}
})
}
})

View File

@@ -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<unknown>
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<typeof app>
@@ -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)
}),
)
}),

View File

@@ -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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
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")
})
})

View File

@@ -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<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
}
afterEach(async () => {
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,

View File

@@ -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()), {

View File

@@ -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()
})

View File

@@ -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<typeof createOpencodeClient>
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<string, string> },
) {
@@ -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<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
it.live(name, effect)
}
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, Scope.Scope>) {
function parity<A, E>(name: string, scenario: (backend: Backend) => Effect.Effect<A, E, Scope.Scope>) {
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<A, E, R>(
serverPath: ServerPath,
backend: Backend,
options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
) {
@@ -214,30 +216,30 @@ function withProject<A, E, R>(
(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<A, E, R>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
return withProject(serverPath, { setup: writeStandardFiles }, run)
function withStandardProject<A, E, R>(backend: Backend, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
return withProject(backend, { setup: writeStandardFiles }, run)
}
function withFakeLlm<A, E, R>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
function withFakeLlm<A, E, R>(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
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<A, E, R>(
serverPath: ServerPath,
backend: Backend,
options: { setup?: (dir: string) => Effect.Effect<void> },
run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
) {
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())

View File

@@ -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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -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<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
@@ -143,6 +149,7 @@ function withTmp<A, E, R>(
}
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<Session.Info>(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<Session.Info>(response)).time.archived).toBe(-1)
expect(effectResponse.status).toBe(legacyResponse.status)
expect((yield* json<Session.Info>(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<Session.Info[]>(
yield* request(`${SessionPaths.list}?${query}`, { headers }),
const legacy = (yield* json<Session.Info[]>(
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const effect = (yield* json<Session.Info[]>(
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" }

View File

@@ -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<unknown>
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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -26,13 +28,14 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
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)
}
})

View File

@@ -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<string, string>, 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 })
})
})

View File

@@ -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: {

View File

@@ -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

View File

@@ -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<string, string>) {
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 <domain>.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()
})
})

View File

@@ -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()
}),

View File

@@ -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",