Compare commits

..

9 Commits

Author SHA1 Message Date
Kit Langton 2b028287e2 docs(effect): add generated http route inventory 2026-04-24 18:14:53 -04:00
opencode-agent[bot] 5cd178ba70 chore: generate 2026-04-24 22:05:23 +00:00
Kit Langton 97eb9fdee8 test(httpapi): cover hono bridge middleware (#24216) 2026-04-24 18:03:51 -04:00
Kit Langton 5a04de231e refactor(ripgrep): migrate result schemas to effect (#24213) 2026-04-24 17:42:52 -04:00
Kyle Altendorf bb3509b5ff fix(opencode): clarify git amend condition to require verifying commit landed (#19937)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-04-24 17:42:10 -04:00
Aiden Cline 4a67905266 fix: ensure gpt-5.5 compacts at correct context size when using openai oauth (#24212) 2026-04-24 17:39:06 -04:00
opencode-agent[bot] c4e33d3168 chore: generate 2026-04-24 21:38:31 +00:00
Aiden Cline 872cdff2ab ignore: denounce ai spammer 2026-04-24 17:37:28 -04:00
Kit Langton 361d7001d0 Clarify HttpApi migration plan (#24211) 2026-04-24 17:36:49 -04:00
11 changed files with 614 additions and 499 deletions
+1
View File
@@ -33,3 +33,4 @@ simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
@@ -0,0 +1,138 @@
#!/usr/bin/env bun
import type { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { WorkspacePaths } from "../src/server/routes/instance/httpapi/workspace"
import { FilePaths } from "../src/server/routes/instance/httpapi/file"
import { McpPaths } from "../src/server/routes/instance/httpapi/mcp"
import { Flag } from "../src/flag/flag"
import { ControlPlaneRoutes } from "../src/server/routes/control"
import { WorkspaceRoutes } from "../src/server/routes/control/workspace"
import { InstanceRoutes } from "../src/server/routes/instance"
import { UIRoutes } from "../src/server/routes/ui"
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "ALL"
interface Route {
surface: string
method: Method
path: string
status: string
}
const methodOrder = new Map<Method, number>([
["GET", 0],
["POST", 1],
["PUT", 2],
["PATCH", 3],
["DELETE", 4],
["ALL", 5],
])
const bridged = new Set([
key("GET", "/question"),
key("POST", "/question/:requestID/reply"),
key("POST", "/question/:requestID/reject"),
key("GET", "/permission"),
key("POST", "/permission/:requestID/reply"),
key("GET", "/config"),
key("GET", "/config/providers"),
key("GET", "/provider"),
key("GET", "/provider/auth"),
key("POST", "/provider/:providerID/oauth/authorize"),
key("POST", "/provider/:providerID/oauth/callback"),
key("GET", "/project"),
key("GET", "/project/current"),
key("GET", FilePaths.list),
key("GET", FilePaths.content),
key("GET", FilePaths.status),
key("GET", McpPaths.status),
...Object.values(WorkspacePaths).map((path) => key("GET", path)),
])
const topLevelNext = new Set([
key("GET", "/path"),
key("GET", "/vcs"),
key("GET", "/vcs/diff"),
key("GET", "/command"),
key("GET", "/agent"),
key("GET", "/skill"),
key("GET", "/lsp"),
key("GET", "/formatter"),
])
function key(method: string, path: string) {
return `${method} ${path}`
}
function normalize(prefix: string, route: string) {
if (!prefix) return route
if (route === "/") return prefix
return `${prefix}${route}`.replaceAll(/\/+/g, "/")
}
function routes(surface: string, app: Hono, prefix = "") {
const seen = new Map<string, Route>()
for (const route of app.routes as Array<{ method: Method; path: string }>) {
if (surface !== "ui" && route.method === "ALL" && route.path === "/*") continue
const path = normalize(prefix, route.path)
seen.set(key(route.method, path), {
surface,
method: route.method,
path,
status: classify(route.method, path, surface),
})
}
return [...seen.values()].toSorted(compare)
}
function compare(a: Route, b: Route) {
return (
a.surface.localeCompare(b.surface) ||
a.path.localeCompare(b.path) ||
(methodOrder.get(a.method) ?? 99) - (methodOrder.get(b.method) ?? 99)
)
}
function classify(method: Method, path: string, surface: string) {
if (bridged.has(key(method, path))) return "bridged"
if (topLevelNext.has(key(method, path))) return "next"
if (surface === "ui") return "special"
if (path === "/event") return "special"
if (path.startsWith("/pty") || path.startsWith("/tui")) return "special"
if (path.startsWith("/session") || path.startsWith("/sync")) return "later"
if (path.startsWith("/experimental")) return method === "GET" ? "next" : "later"
if (path.startsWith("/mcp")) return "later"
if (path === "/instance/dispose") return "next"
return "later"
}
function table(items: Route[]) {
return [
"| Surface | Method | Path | Status |",
"| --- | --- | --- | --- |",
...items.map((item) => `| ${item.surface} | \`${item.method}\` | \`${item.path}\` | \`${item.status}\` |`),
].join("\n")
}
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const inventory = [
...routes("control", ControlPlaneRoutes()),
...routes("workspace", WorkspaceRoutes(), "/experimental/workspace"),
...routes("instance", InstanceRoutes(websocket)),
...routes("ui", UIRoutes()),
].toSorted(compare)
await Bun.write(
new URL("../specs/effect/http-route-inventory.md", import.meta.url),
`# Http Route Inventory
Generated from Hono route registrations by \`packages/opencode/script/http-route-inventory.ts\`.
Status meanings are defined in \`specs/effect/http-api.md\`.
${table(inventory)}
`,
)
+128 -410
View File
@@ -1,462 +1,180 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
## Goal
## End State
Use Effect `HttpApi` where it gives us a better typed contract for:
- 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.
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
## Current State
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
- `HttpApi` auth is independent of Hono auth.
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
- 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`.
## Core model
## Migration Rules
`HttpApi` is definition-first.
- 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.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
## Schema Notes
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
- 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.
## Why it is relevant here
## Phases
The current route-effectification work is already pushing handlers toward:
### 1. Stabilize The Bridge
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
Before porting more routes, cover the bridge behavior that every route depends on.
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
- 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.
## What HttpApi gives us
### 2. Complete The Inventory
### Contracts
Create a route inventory from the actual Hono registrations and classify each route.
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
The generated inventory lives in `specs/effect/http-route-inventory.md` and is produced by:
### Validation and decoding
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
### OpenAPI
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
### Typed errors
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
## Likely fit for opencode
Best fit first:
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Harder / later fit:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
## Current blockers and gaps
### Schema split
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
### Mixed handler styles
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### Existing Hono integration
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
## Recommended strategy
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
### 2. Start with one parallel group
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
Recommended first slice:
- start with `question`
- start with `GET /question`
- start with `POST /question/:requestID/reply`
Why `question` first:
- already JSON-only
- already delegates into an Effect service
- proves list + mutation + params + payload + OpenAPI in one small slice
- avoids the harder streaming and middleware cases
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Bridge into Hono behind a feature flag
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
- one process, one port — no separate server
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
- Effect middleware handles auth and instance lookup independently from Hono middleware
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
```ts
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
```bash
bun run script/http-route-inventory.ts
```
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
Statuses:
### 5. Observability
- `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`.
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
### 3. Finish JSON Route Parity
This gives:
Port remaining JSON routes in small batches.
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
Good near-term candidates:
### 6. Migrate JSON route groups gradually
- 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: `PATCH /config`, project git init, workspace/worktree create/remove/reset, file search, MCP auth flows
As each route group is ported to `HttpApi`:
Keep large or stateful groups for later:
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
- `session`
- `sync`
- process-level experimental routes
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
### 4. Move OpenAPI And SDK Generation
## Schema rule for HttpApi work
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
Required before route deletion:
Default rule:
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- 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.
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
### 5. Make HttpApi Default For JSON Routes
Practical implication for `HttpApi` migration:
After JSON parity and SDK generation are covered:
- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
- avoid maintaining parallel Zod and Effect definitions for the same request or response type
- 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.
Ordering for a route-group migration:
### 6. Delete Hono Route Implementations
1. move implicated shared `schema.ts` leaf types to Effect Schema first
2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
Delete Hono routes group-by-group after each group meets the deletion criteria.
SDK shape rule:
Deletion criteria:
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- `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.
### Schema.Class vs Schema.Struct
After deleting a group:
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
- 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.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
### 7. Replace Special Routes
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
Special routes need explicit designs before Hono can disappear completely.
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
- `event`: SSE
- `pty`: websocket
- `tui`: UI/control bridge behavior
- streaming `session` endpoints
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
When to use each:
## Current Route Status
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
| Area | Status | Notes |
| ------------------------ | ----------------- | --------------------------------------------------------------------- |
| `question` | `bridged` | `GET /question`, reply, reject |
| `permission` | `bridged` | list and reply |
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
| `config` | `bridged` partial | reads only; `PATCH /config` remains Hono |
| `project` | `bridged` partial | reads only; update and git-init remain Hono |
| `file` | `bridged` partial | `GET /file`, `GET /file/content`, `GET /file/status` |
| `mcp` | `bridged` partial | `GET /mcp` only |
| `workspace` | `bridged` partial | reads only; create/remove/session-restore remain Hono |
| top-level instance reads | `next` | `GET /path`, `/vcs`, `/vcs/diff`, `/command`, `/agent`, `/skill`, etc. |
| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
| `event` | `special` | SSE |
| `pty` | `special` | websocket |
| `tui` | `special` | UI bridge |
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
See `specs/effect/http-route-inventory.md` for exact paths and per-route status.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
## Next PRs
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception:
- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
- if that happens, leave a short note so the type does not become a permanent second source of truth
## First vertical slice
The first `HttpApi` spike should be intentionally small and repeatable.
Chosen slice:
- group: `question`
- endpoints: `GET /question` and `POST /question/:requestID/reply`
Non-goals:
- no `session` routes
- no SSE or websocket routes
- no auth redesign
- no broad service refactor
Behavior rule:
- preserve current runtime behavior first
- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
## Repeatable slice template
Use the same sequence for each route group.
1. Pick one JSON-only route group that already mostly delegates into services.
2. Identify the shared DTOs, IDs, and errors implicated by that slice.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
Rule of thumb:
- migrate one route group at a time
- migrate one or two endpoints first, not the whole file
- keep business logic in the existing service
- keep the first spike easy to delete if the experiment is not worth continuing
## Example structure
Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
Each route-group spike should follow the same shape.
### 1. Contract
- define an experimental `HttpApi`
- define one `HttpApiGroup`
- define endpoint params, payload, success, and error schemas from canonical Effect schemas
- annotate summary, description, and operation ids explicitly so generated docs are stable
### 2. Handler layer
- implement with `HttpApiBuilder.group(api, groupName, ...)`
- yield the existing Effect service from context
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Bridged server
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
### Error mapping
- keep domain and service errors typed in the service layer
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
## Exit criteria for the spike
The first slice is successful if:
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings
### Schema
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
## Route inventory
Status legend:
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
- `done` - Effect HttpApi slice exists but not yet bridged
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `bridged`
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoints: `GET /config`, `GET /config/providers`
defer `PATCH /config` for now
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `bridged` (partial)
bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status`
defer search endpoints first
- `mcp` - `bridged` (partial)
bridged endpoints: `GET /mcp`
defer interactive OAuth/auth flows first
- `session` - `defer`
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
- `event` - `defer`
SSE only
- `global` - `defer`
mixed bag with SSE and process-level side effects
- `pty` - `defer`
websocket-heavy route surface
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence:
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `file` JSON read endpoints
3. `mcp` JSON read endpoints
1. Add bridge-level auth and instance-context tests for the current `HttpApi` bridge.
2. Port the top-level JSON reads.
3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
## Checklist
- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [x] use Effect Schema request / response types for that slice
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [x] port `workspace` read endpoints
- [x] port `file` JSON read endpoints
- [x] port `mcp` status read endpoint
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Attach auth middleware in route modules.
- [x] Support `auth_token` as a query security scheme.
- [ ] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [ ] Port remaining JSON routes.
- [ ] Generate SDK/OpenAPI from Effect routes.
- [ ] Flip ported JSON routes to default-on with fallback.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.
@@ -0,0 +1,119 @@
# Http Route Inventory
Generated from Hono route registrations by `packages/opencode/script/http-route-inventory.ts`.
Status meanings are defined in `specs/effect/http-api.md`.
| Surface | Method | Path | Status |
| --- | --- | --- | --- |
| control | `PUT` | `/auth/:providerID` | `later` |
| control | `DELETE` | `/auth/:providerID` | `later` |
| control | `GET` | `/doc` | `later` |
| control | `POST` | `/log` | `later` |
| instance | `GET` | `/agent` | `next` |
| instance | `GET` | `/command` | `next` |
| instance | `GET` | `/config` | `bridged` |
| instance | `PATCH` | `/config` | `later` |
| instance | `GET` | `/config/providers` | `bridged` |
| instance | `GET` | `/event` | `special` |
| instance | `GET` | `/experimental/console` | `next` |
| instance | `GET` | `/experimental/console/orgs` | `next` |
| instance | `POST` | `/experimental/console/switch` | `later` |
| instance | `GET` | `/experimental/resource` | `next` |
| instance | `GET` | `/experimental/session` | `next` |
| instance | `GET` | `/experimental/tool` | `next` |
| instance | `GET` | `/experimental/tool/ids` | `next` |
| instance | `GET` | `/experimental/worktree` | `next` |
| instance | `POST` | `/experimental/worktree` | `later` |
| instance | `DELETE` | `/experimental/worktree` | `later` |
| instance | `POST` | `/experimental/worktree/reset` | `later` |
| instance | `GET` | `/file` | `bridged` |
| instance | `GET` | `/file/content` | `bridged` |
| instance | `GET` | `/file/status` | `bridged` |
| instance | `GET` | `/find` | `later` |
| instance | `GET` | `/find/file` | `later` |
| instance | `GET` | `/find/symbol` | `later` |
| instance | `GET` | `/formatter` | `next` |
| instance | `POST` | `/instance/dispose` | `next` |
| instance | `GET` | `/lsp` | `next` |
| instance | `GET` | `/mcp` | `bridged` |
| instance | `POST` | `/mcp` | `later` |
| instance | `POST` | `/mcp/:name/auth` | `later` |
| instance | `DELETE` | `/mcp/:name/auth` | `later` |
| instance | `POST` | `/mcp/:name/auth/authenticate` | `later` |
| instance | `POST` | `/mcp/:name/auth/callback` | `later` |
| instance | `POST` | `/mcp/:name/connect` | `later` |
| instance | `POST` | `/mcp/:name/disconnect` | `later` |
| instance | `GET` | `/path` | `next` |
| instance | `GET` | `/permission` | `bridged` |
| instance | `POST` | `/permission/:requestID/reply` | `bridged` |
| instance | `GET` | `/project` | `bridged` |
| instance | `PATCH` | `/project/:projectID` | `later` |
| instance | `GET` | `/project/current` | `bridged` |
| instance | `POST` | `/project/git/init` | `later` |
| instance | `GET` | `/provider` | `bridged` |
| instance | `POST` | `/provider/:providerID/oauth/authorize` | `bridged` |
| instance | `POST` | `/provider/:providerID/oauth/callback` | `bridged` |
| instance | `GET` | `/provider/auth` | `bridged` |
| instance | `GET` | `/pty` | `special` |
| instance | `POST` | `/pty` | `special` |
| instance | `GET` | `/pty/:ptyID` | `special` |
| instance | `PUT` | `/pty/:ptyID` | `special` |
| instance | `DELETE` | `/pty/:ptyID` | `special` |
| instance | `GET` | `/pty/:ptyID/connect` | `special` |
| instance | `GET` | `/question` | `bridged` |
| instance | `POST` | `/question/:requestID/reject` | `bridged` |
| instance | `POST` | `/question/:requestID/reply` | `bridged` |
| instance | `GET` | `/session` | `later` |
| instance | `POST` | `/session` | `later` |
| instance | `GET` | `/session/:sessionID` | `later` |
| instance | `PATCH` | `/session/:sessionID` | `later` |
| instance | `DELETE` | `/session/:sessionID` | `later` |
| instance | `POST` | `/session/:sessionID/abort` | `later` |
| instance | `GET` | `/session/:sessionID/children` | `later` |
| instance | `POST` | `/session/:sessionID/command` | `later` |
| instance | `GET` | `/session/:sessionID/diff` | `later` |
| instance | `POST` | `/session/:sessionID/fork` | `later` |
| instance | `POST` | `/session/:sessionID/init` | `later` |
| instance | `GET` | `/session/:sessionID/message` | `later` |
| instance | `POST` | `/session/:sessionID/message` | `later` |
| instance | `GET` | `/session/:sessionID/message/:messageID` | `later` |
| instance | `DELETE` | `/session/:sessionID/message/:messageID` | `later` |
| instance | `PATCH` | `/session/:sessionID/message/:messageID/part/:partID` | `later` |
| instance | `DELETE` | `/session/:sessionID/message/:messageID/part/:partID` | `later` |
| instance | `POST` | `/session/:sessionID/permissions/:permissionID` | `later` |
| instance | `POST` | `/session/:sessionID/prompt_async` | `later` |
| instance | `POST` | `/session/:sessionID/revert` | `later` |
| instance | `POST` | `/session/:sessionID/share` | `later` |
| instance | `DELETE` | `/session/:sessionID/share` | `later` |
| instance | `POST` | `/session/:sessionID/shell` | `later` |
| instance | `POST` | `/session/:sessionID/summarize` | `later` |
| instance | `GET` | `/session/:sessionID/todo` | `later` |
| instance | `POST` | `/session/:sessionID/unrevert` | `later` |
| instance | `GET` | `/session/status` | `later` |
| instance | `GET` | `/skill` | `next` |
| instance | `POST` | `/sync/history` | `later` |
| instance | `POST` | `/sync/replay` | `later` |
| instance | `POST` | `/sync/start` | `later` |
| instance | `POST` | `/tui/append-prompt` | `special` |
| instance | `POST` | `/tui/clear-prompt` | `special` |
| instance | `GET` | `/tui/control/next` | `special` |
| instance | `POST` | `/tui/control/response` | `special` |
| instance | `POST` | `/tui/execute-command` | `special` |
| instance | `POST` | `/tui/open-help` | `special` |
| instance | `POST` | `/tui/open-models` | `special` |
| instance | `POST` | `/tui/open-sessions` | `special` |
| instance | `POST` | `/tui/open-themes` | `special` |
| instance | `POST` | `/tui/publish` | `special` |
| instance | `POST` | `/tui/select-session` | `special` |
| instance | `POST` | `/tui/show-toast` | `special` |
| instance | `POST` | `/tui/submit-prompt` | `special` |
| instance | `GET` | `/vcs` | `next` |
| instance | `GET` | `/vcs/diff` | `next` |
| ui | `ALL` | `/*` | `special` |
| workspace | `GET` | `/experimental/workspace` | `bridged` |
| workspace | `POST` | `/experimental/workspace` | `later` |
| workspace | `DELETE` | `/experimental/workspace/:id` | `later` |
| workspace | `POST` | `/experimental/workspace/:id/session-restore` | `later` |
| workspace | `GET` | `/experimental/workspace/adaptor` | `bridged` |
| workspace | `GET` | `/experimental/workspace/status` | `bridged` |
+62 -65
View File
@@ -1,7 +1,6 @@
import path from "path"
import z from "zod"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
@@ -12,6 +11,8 @@ import { Global } from "@/global"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
import { which } from "@/util/which"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
@@ -25,83 +26,82 @@ const PLATFORM = {
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
const TimeStats = Schema.Struct({
secs: Schema.Number,
nanos: Schema.Number,
human: Schema.String,
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: Schema.Number,
searches_with_match: Schema.Number,
bytes_searched: Schema.Number,
bytes_printed: Schema.Number,
matched_lines: Schema.Number,
matches: Schema.Number,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
),
}),
start: Schema.Number,
end: Schema.Number,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(Schema.Number),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
const Result = Schema.Union([Begin, Match, End, Summary])
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
@@ -187,10 +187,7 @@ function row(data: Row): Row {
}
function parse(line: string) {
return Effect.try({
try: () => Result.parse(JSON.parse(line)),
catch: (cause) => new Error("invalid ripgrep output", { cause }),
})
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
}
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
+10
View File
@@ -390,6 +390,16 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
output: 0,
cache: { read: 0, write: 0 },
}
// gpt-5.5 models temporarily have restricted context window size for codex plans
if (model.id.includes("gpt-5.5")) {
model.limit = {
context: 400_000,
//@ts-expect-error incorrect type for v1 sdk but works
input: 272_000,
output: 128_000,
}
}
}
return {
@@ -21,7 +21,7 @@ export const FileRoutes = lazy(() =>
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
schema: resolver(Ripgrep.SearchMatch.zod.array()),
},
},
},
@@ -1,4 +1,4 @@
import { Instance } from "@/project/instance"
import * as InstanceState from "@/effect/instance-state"
import { Project } from "@/project"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -54,7 +54,7 @@ export const projectHandlers = Layer.unwrap(
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return Instance.project
return (yield* InstanceState.context).project
})
return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
+1 -1
View File
@@ -58,7 +58,7 @@ Git Safety Protocol:
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
(1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including
(1) User explicitly requested amend, OR the commit succeeded and pre-commit hooks auto-modified files that need including — verify by checking `git log` that HEAD is the new commit before amending
(2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
(3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "../../src/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { 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 websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return InstanceRoutes(websocket)
}
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 Instance.disposeAll()
await resetDatabase()
})
describe("HttpApi Hono bridge", () => {
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("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" })
})
})
+18 -20
View File
@@ -1055,28 +1055,26 @@ unix("shell captures stdout and stderr in completed tool output", () =>
)
unix("shell completes a fast command on the preferred shell", () =>
withSh(() =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "pwd",
})
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "pwd",
})
expect(result.info.role).toBe("assistant")
const tool = completedTool(result.parts)
if (!tool) return
expect(result.info.role).toBe("assistant")
const tool = completedTool(result.parts)
if (!tool) return
expect(tool.state.input.command).toBe("pwd")
expect(tool.state.output).toContain(dir)
expect(tool.state.metadata.output).toContain(dir)
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
),
expect(tool.state.input.command).toBe("pwd")
expect(tool.state.output).toContain(dir)
expect(tool.state.metadata.output).toContain(dir)
yield* run.assertNotBusy(chat.id)
}),
{ git: true, config: cfg },
),
)