From bc63d7a9db319d58092660827ffc870ef0756ae4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 15:51:31 -0400 Subject: [PATCH 1/3] fix(httpapi): enforce instance route parity --- .../src/server/routes/instance/httpapi/pty.ts | 21 +++++++++++++++++++ .../src/server/routes/instance/index.ts | 8 +------ .../test/server/httpapi-bridge.test.ts | 18 ++++++++++++++++ .../opencode/test/server/httpapi-pty.test.ts | 16 ++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index 930322ff38..4e46f30df7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -1,6 +1,7 @@ import { EffectBridge } from "@/effect/bridge" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { Shell } from "@/shell/shell" import { Effect, Layer, Schema } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -14,8 +15,14 @@ const Params = Schema.Struct({ const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String), }) +const ShellItem = Schema.Struct({ + path: Schema.String, + name: Schema.String, + acceptable: Schema.Boolean, +}) export const PtyPaths = { + shells: `${root}/shells`, list: root, create: root, get: `${root}/:ptyID`, @@ -28,6 +35,15 @@ export const PtyApi = HttpApi.make("pty") .add( HttpApiGroup.make("pty") .add( + HttpApiEndpoint.get("shells", PtyPaths.shells, { + success: Schema.Array(ShellItem), + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.shells", + summary: "List available shells", + description: "Get a list of available shells on the system.", + }), + ), HttpApiEndpoint.get("list", PtyPaths.list, { success: Schema.Array(Pty.Info), }).annotateMerge( @@ -101,6 +117,10 @@ export const ptyHandlers = Layer.unwrap( Effect.gen(function* () { const pty = yield* Pty.Service + const shells = Effect.fn("PtyHttpApi.shells")(function* () { + return yield* Effect.promise(() => Shell.list()) + }) + const list = Effect.fn("PtyHttpApi.list")(function* () { return yield* pty.list() }) @@ -143,6 +163,7 @@ export const ptyHandlers = Layer.unwrap( return HttpApiBuilder.group(PtyApi, "pty", (handlers) => handlers + .handle("shells", shells) .handle("list", list) .handle("create", create) .handle("get", get) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 86a1db12b6..c80515b9c3 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -25,7 +25,6 @@ import { McpPaths } from "./httpapi/mcp" import { SessionPaths } from "./httpapi/session" import { SyncPaths } from "./httpapi/sync" import { TuiPaths } from "./httpapi/tui" -import { WorkspacePaths } from "./httpapi/workspace" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -99,6 +98,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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.shells, (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)) @@ -145,12 +145,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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.adaptors, (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.sessionRestore, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 37f0a5ec11..0908f40ce8 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -25,6 +25,10 @@ function app(input?: { password?: string; username?: string }) { return InstanceRoutes(websocket) } +function routeKey(route: ReturnType["routes"][number]) { + return `${route.method} ${route.path}` +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -46,6 +50,20 @@ afterEach(async () => { }) describe("HttpApi Hono bridge", () => { + test("mounts experimental handlers for every legacy instance route", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + const legacy = InstanceRoutes(websocket) + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const experimental = InstanceRoutes(websocket) + + const bridge = experimental.routes.slice(0, experimental.routes.length - legacy.routes.length) + const legacyRoutes = [...new Set(legacy.routes.map(routeKey))] + const bridgeRoutes = new Set(bridge.map(routeKey)) + + expect(legacyRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) + expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello") diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 65a115a411..ffaea3b751 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -27,6 +27,22 @@ afterEach(async () => { }) describe("pty HttpApi bridge", () => { + test("serves available shell list through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + }) + testPty("serves PTY JSON 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 } From 70169e66bd73a44121c4cef8e40e5bfc82e70dbd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 16:00:46 -0400 Subject: [PATCH 2/3] fix(httpapi): include workspace routes in parity check --- packages/opencode/src/server/routes/instance/index.ts | 7 +++++++ packages/opencode/test/server/httpapi-bridge.test.ts | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index c80515b9c3..68b508a9a7 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -25,6 +25,7 @@ import { McpPaths } from "./httpapi/mcp" import { SessionPaths } from "./httpapi/session" import { SyncPaths } from "./httpapi/sync" import { TuiPaths } from "./httpapi/tui" +import { WorkspacePaths } from "./httpapi/workspace" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -145,6 +146,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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.adaptors, (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.sessionRestore, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 0908f40ce8..dac23a654d 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -3,6 +3,7 @@ import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { InstanceRoutes } from "../../src/server/routes/instance" +import { WorkspaceRoutes } from "../../src/server/routes/control/workspace" import { FilePaths } from "../../src/server/routes/instance/httpapi/file" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -57,7 +58,11 @@ describe("HttpApi Hono bridge", () => { const experimental = InstanceRoutes(websocket) const bridge = experimental.routes.slice(0, experimental.routes.length - legacy.routes.length) - const legacyRoutes = [...new Set(legacy.routes.map(routeKey))] + const workspaceRoutes = WorkspaceRoutes().routes.map((route) => ({ + ...route, + path: `/experimental/workspace${route.path === "/" ? "" : route.path}`, + })) + const legacyRoutes = [...new Set([...legacy.routes, ...workspaceRoutes].map(routeKey))] const bridgeRoutes = new Set(bridge.map(routeKey)) expect(legacyRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) From 38b26abfbb2d7fe60ae7a5781bc7d9c2692b2f03 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 16:25:08 -0400 Subject: [PATCH 3/3] test(httpapi): verify reflected route mounts --- .../test/server/httpapi-bridge.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index dac23a654d..4417cc4464 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -4,8 +4,23 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { InstanceRoutes } from "../../src/server/routes/instance" import { WorkspaceRoutes } from "../../src/server/routes/control/workspace" -import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { ConfigApi } from "../../src/server/routes/instance/httpapi/config" +import { EventPaths } from "../../src/server/routes/instance/httpapi/event" +import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/experimental" +import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { InstanceApi } from "../../src/server/routes/instance/httpapi/instance" +import { McpApi } from "../../src/server/routes/instance/httpapi/mcp" +import { PermissionApi } from "../../src/server/routes/instance/httpapi/permission" +import { ProjectApi } from "../../src/server/routes/instance/httpapi/project" +import { ProviderApi } from "../../src/server/routes/instance/httpapi/provider" +import { PtyApi, PtyPaths } from "../../src/server/routes/instance/httpapi/pty" +import { QuestionApi } from "../../src/server/routes/instance/httpapi/question" +import { SessionApi } from "../../src/server/routes/instance/httpapi/session" +import { SyncApi } from "../../src/server/routes/instance/httpapi/sync" +import { TuiApi } from "../../src/server/routes/instance/httpapi/tui" +import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace" import * as Log from "@opencode-ai/core/util/log" +import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -30,6 +45,39 @@ function routeKey(route: ReturnType["routes"][number]) { return `${route.method} ${route.path}` } +function reflectedHttpApiRoutes() { + const routes = [ + `GET ${EventPaths.event}`, + `GET ${PtyPaths.connect}`, + ] + + function addRoutes(api: HttpApi.HttpApi) { + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint }) { + routes.push(`${endpoint.method} ${endpoint.path}`) + }, + }) + } + + addRoutes(ConfigApi) + addRoutes(ExperimentalApi) + addRoutes(FileApi) + addRoutes(InstanceApi) + addRoutes(McpApi) + addRoutes(PermissionApi) + addRoutes(ProjectApi) + addRoutes(ProviderApi) + addRoutes(PtyApi) + addRoutes(QuestionApi) + addRoutes(SessionApi) + addRoutes(SyncApi) + addRoutes(TuiApi) + addRoutes(WorkspaceApi) + + return [...new Set(routes)] +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -69,6 +117,21 @@ describe("HttpApi Hono bridge", () => { expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([]) }) + test("mounts every Effect HttpApi route through the Hono bridge", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + const legacy = InstanceRoutes(websocket) + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const experimental = InstanceRoutes(websocket) + + const bridgeRoutes = new Set( + experimental.routes.slice(0, experimental.routes.length - legacy.routes.length).map(routeKey), + ) + const httpApiRoutes = reflectedHttpApiRoutes() + + expect(httpApiRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) + expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello")