summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-30 11:07:00 -0400
committerGitHub <[email protected]>2026-04-30 11:07:00 -0400
commitdddfcbf0d8aa00e6b2744c8a9c111d489b8a4ca2 (patch)
tree8de8324c22cb32a4ce4740c56b81b4b53b5f201b
parent62e1335388fdbadaa95d258b43f1c84740e6db1d (diff)
downloadopencode-dddfcbf0d8aa00e6b2744c8a9c111d489b8a4ca2.tar.gz
opencode-dddfcbf0d8aa00e6b2744c8a9c111d489b8a4ca2.zip
test: port instance HttpApi path/vcs read coverage to Effect
-rw-r--r--packages/opencode/src/pty/input.ts24
-rw-r--r--packages/opencode/src/server/cors.ts12
-rw-r--r--packages/opencode/src/server/middleware.ts12
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts1
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/session.ts3
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts5
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts7
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts61
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts27
-rw-r--r--packages/opencode/src/server/workspace.ts1
-rw-r--r--packages/opencode/src/session/session.ts10
-rw-r--r--packages/opencode/test/server/httpapi-cors.test.ts64
-rw-r--r--packages/opencode/test/server/httpapi-event.test.ts15
-rw-r--r--packages/opencode/test/server/httpapi-instance-context.test.ts59
-rw-r--r--packages/opencode/test/server/httpapi-instance.legacy.test.ts138
-rw-r--r--packages/opencode/test/server/httpapi-instance.test.ts227
-rw-r--r--packages/opencode/test/server/httpapi-mcp-oauth.test.ts81
-rw-r--r--packages/opencode/test/server/httpapi-pty-websocket.test.ts16
-rw-r--r--packages/opencode/test/server/httpapi-sdk.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-session.test.ts100
-rw-r--r--packages/opencode/test/server/httpapi-tui.test.ts40
-rw-r--r--packages/opencode/test/server/httpapi-workspace-routing.test.ts31
23 files changed, 717 insertions, 221 deletions
diff --git a/packages/opencode/src/pty/input.ts b/packages/opencode/src/pty/input.ts
new file mode 100644
index 000000000..0e4ea9a61
--- /dev/null
+++ b/packages/opencode/src/pty/input.ts
@@ -0,0 +1,24 @@
+import { Effect } from "effect"
+
+const inputDecoder = new TextDecoder("utf-8", { fatal: true })
+
+export function handlePtyInput(
+ handler: { onMessage: (message: string | ArrayBuffer) => void },
+ message: string | Uint8Array,
+) {
+ if (typeof message === "string") {
+ handler.onMessage(message)
+ return Effect.void
+ }
+ return Effect.try({
+ try: () => inputDecoder.decode(message),
+ catch: () => new Error("invalid PTY websocket input"),
+ }).pipe(
+ Effect.catch(() => Effect.succeed(undefined)),
+ Effect.flatMap((decoded) => {
+ if (decoded === undefined) return Effect.void
+ handler.onMessage(decoded)
+ return Effect.void
+ }),
+ )
+}
diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts
new file mode 100644
index 000000000..8ae945b75
--- /dev/null
+++ b/packages/opencode/src/server/cors.ts
@@ -0,0 +1,12 @@
+const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
+
+export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
+ if (!input) return true
+ if (input.startsWith("http://localhost:")) return true
+ if (input.startsWith("http://127.0.0.1:")) return true
+ if (input.startsWith("oc://renderer")) return true
+ if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
+ return true
+ if (opencodeOrigin.test(input)) return true
+ return opts?.cors?.includes(input) ?? false
+}
diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts
index 95f140570..433f301ae 100644
--- a/packages/opencode/src/server/middleware.ts
+++ b/packages/opencode/src/server/middleware.ts
@@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
+import { isAllowedCorsOrigin } from "./cors"
const log = Log.create({ service: "server" })
@@ -70,16 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
- if (!input) return
-
- if (input.startsWith("http://localhost:")) return input
- if (input.startsWith("http://127.0.0.1:")) return input
- if (input.startsWith("oc://renderer")) return input
- if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
- return input
-
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
- if (opts?.cors?.includes(input)) return input
+ if (isAllowedCorsOrigin(input, opts)) return input
},
})
}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
index e9caf0cd9..b30714c19 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
@@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
+ oauthState: Schema.String,
})
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
index bc26a9e59..77d064ff5 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
@@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
-import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
- archived: Schema.optional(NonNegativeInt),
+ archived: Schema.optional(Session.ArchivedTimestamp),
}),
),
})
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
index 8558ee793..aa151cece 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
@@ -1,6 +1,7 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
+import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -102,9 +103,7 @@ export const ptyConnectRoute = HttpRouter.add(
if (!handler) return HttpServerResponse.empty()
yield* socket
- .runRaw((message) => {
- handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
- })
+ .runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
index 65c90b952..3d88db60d 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
@@ -62,7 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return Instance.restore(instance, () =>
Array.from(
Session.list({
- directory: ctx.query.directory,
+ directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
index cb12ccb7a..c7c447ce8 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
@@ -28,8 +28,8 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
- const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
- bus.publish(TuiEvent.CommandExecute, { command })
+ const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
+ bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
@@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
- yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
+ // Legacy only publishes known aliases; unknown commands become undefined.
+ yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases])
return true
})
diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts
index c93261a0b..7b263980c 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts
@@ -1,32 +1,63 @@
+import type { WorkspaceID } from "@/control-plane/schema"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
+import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
-const disposeAfterResponse = new WeakMap<object, InstanceContext>()
+type MarkedInstance = {
+ ctx: InstanceContext
+ workspaceID?: WorkspaceID
+}
-export const markInstanceForDisposal = (ctx: InstanceContext) =>
- HttpEffect.appendPreResponseHandler((request, response) =>
- Effect.sync(() => {
- disposeAfterResponse.set(request.source, ctx)
- return response
+// Disposal is requested by an endpoint handler, but must run from the outer
+// server middleware after the response has been produced. The original Request
+// object is the stable handoff key between those two phases.
+const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
+
+const mark = (ctx: InstanceContext) =>
+ Effect.gen(function* () {
+ return { ctx, workspaceID: yield* WorkspaceRef }
+ })
+
+// Instance.dispose/reload still publish events through legacy ALS helpers.
+// Effect request handlers carry these values in services, so bridge them back
+// into the legacy contexts only around the lifecycle operation.
+const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
+ Effect.promise(() =>
+ WorkspaceContext.provide({
+ workspaceID: marked.workspaceID,
+ fn: () => Instance.restore(marked.ctx, fn),
}),
)
+export const markInstanceForDisposal = (ctx: InstanceContext) =>
+ Effect.gen(function* () {
+ const marked = yield* mark(ctx)
+ return yield* HttpEffect.appendPreResponseHandler((request, response) =>
+ Effect.sync(() => {
+ // The response is sent before disposeMiddleware performs the teardown.
+ disposeAfterResponse.set(request.source, marked)
+ return response
+ }),
+ )
+ })
+
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
- HttpEffect.appendPreResponseHandler((_request, response) =>
- Effect.as(
- Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))),
- response,
- ),
- )
+ Effect.gen(function* () {
+ const marked = yield* mark(ctx)
+ return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
+ Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
+ )
+ })
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
Effect.gen(function* () {
const response = yield* effect
const request = yield* HttpServerRequest.HttpServerRequest
- const ctx = disposeAfterResponse.get(request.source)
- if (!ctx) return response
+ const marked = disposeAfterResponse.get(request.source)
+ if (!marked) return response
disposeAfterResponse.delete(request.source)
- yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
+ yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
return response
})
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 370696ddb..e0ce52485 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
-import { HttpRouter, HttpServer } from "effect/unstable/http"
+import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
@@ -31,6 +31,7 @@ import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
+import { isAllowedCorsOrigin } from "@/server/cors"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
import { eventRoute } from "./event"
@@ -55,7 +56,6 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
-import type { Predicate } from "effect/Predicate"
export const context = Context.makeUnsafe<unknown>(new Map())
@@ -69,6 +69,11 @@ const runtime = HttpRouter.middleware()(
),
).layer
+const cors = HttpRouter.middleware(HttpMiddleware.cors({
+ allowedOrigins: isAllowedCorsOrigin,
+ maxAge: 86_400,
+}), { global: true })
+
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -105,24 +110,8 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
- Layer.provide(
- HttpRouter.cors({
- maxAge: 86_400,
- allowedOrigins: ((input) => {
- return (
- !input ||
- input.startsWith("http://localhost:") ||
- input.startsWith("http://127.0.0.1:") ||
- input.startsWith("oc://renderer") ||
- input === "tauri://localhost" ||
- input === "http://tauri.localhost" ||
- input === "https://tauri.localhost" ||
- /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)
- )
- }) as Predicate<string> as any,
- }),
- ),
Layer.provide([
+ cors,
runtime,
Account.defaultLayer,
Agent.defaultLayer,
diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts
index 29b1ab986..c22a09bda 100644
--- a/packages/opencode/src/server/workspace.ts
+++ b/packages/opencode/src/server/workspace.ts
@@ -17,6 +17,7 @@ import { ServerProxy } from "./proxy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
+ { path: "/experimental/workspace", action: "local" },
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index 1be5dfffd..9a50a9a98 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -142,11 +142,15 @@ const Share = Schema.Struct({
url: Schema.String,
})
+// Legacy HTTP accepted any number here, and persisted data may already contain
+// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
+export const ArchivedTimestamp = Schema.Number
+
const Time = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
- archived: optionalOmitUndefined(NonNegativeInt),
+ archived: optionalOmitUndefined(ArchivedTimestamp),
})
const Revert = Schema.Struct({
@@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
)
export const SetArchivedInput = Schema.Struct({
sessionID: SessionID,
- time: Schema.optional(NonNegativeInt),
+ time: Schema.optional(ArchivedTimestamp),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
@@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
- archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
+ archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)),
})
const UpdatedInfo = Schema.Struct({
diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts
new file mode 100644
index 000000000..3330cfdd1
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-cors.test.ts
@@ -0,0 +1,64 @@
+import { NodeHttpServer, NodeServices } from "@effect/platform-node"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { describe, expect } from "bun:test"
+import { Config, Effect, Layer } from "effect"
+import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
+import * as Socket from "effect/unstable/socket/Socket"
+import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
+import { resetDatabase } from "../fixture/db"
+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()
+ }),
+ )
+ }),
+)
+
+const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
+ ExperimentalHttpApiServer.routes,
+ { disableListenLog: true, disableLogger: true },
+)
+
+const it = testEffect(
+ Layer.mergeAll(
+ testStateLayer,
+ servedRoutes.pipe(
+ Layer.provide(Socket.layerWebSocketConstructorGlobal),
+ Layer.provideMerge(NodeHttpServer.layerTest),
+ Layer.provideMerge(NodeServices.layer),
+ ),
+ ),
+)
+
+describe("HttpApi CORS", () => {
+ it.live("allows browser preflight requests without credentials", () =>
+ Effect.gen(function* () {
+ const response = yield* HttpClientRequest.options(InstancePaths.path).pipe(
+ HttpClientRequest.setHeaders({
+ origin: "http://localhost:3000",
+ "access-control-request-method": "GET",
+ "access-control-request-headers": "authorization",
+ }),
+ HttpClient.execute,
+ )
+
+ expect(response.status).toBe(204)
+ expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000")
+ expect(response.headers["access-control-allow-headers"]).toBe("authorization")
+ }),
+ )
+})
diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts
index 6fe92a234..915d79784 100644
--- a/packages/opencode/test/server/httpapi-event.test.ts
+++ b/packages/opencode/test/server/httpapi-event.test.ts
@@ -11,9 +11,9 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
-function app() {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
- return Server.Default().app
+function app(experimental = true) {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
+ return experimental ? Server.Default().app : Server.Legacy().app
}
async function readFirstChunk(response: Response) {
@@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
})
+
+ 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 legacy = await app(false).request(EventPaths.event, { headers })
+ const effect = await app(true).request(EventPaths.event, { headers })
+
+ expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
+ })
})
diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts
index 74b1ecdeb..aec3743e6 100644
--- a/packages/opencode/test/server/httpapi-instance-context.test.ts
+++ b/packages/opencode/test/server/httpapi-instance-context.test.ts
@@ -1,7 +1,8 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
@@ -12,6 +13,7 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
+import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
@@ -84,6 +86,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build,
)
+const waitDisposedEvent = Effect.promise(
+ () =>
+ new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
+ const timer = setTimeout(() => {
+ GlobalBus.off("event", onEvent)
+ reject(new Error("timed out waiting for instance disposal"))
+ }, 10_000)
+
+ function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
+ if (event.payload.type !== "server.instance.disposed") return
+ clearTimeout(timer)
+ GlobalBus.off("event", onEvent)
+ resolve({ directory: event.directory, workspace: event.workspace })
+ }
+
+ GlobalBus.on("event", onEvent)
+ }),
+)
+
+const serveDisposeProbe = () =>
+ HttpRouter.serve(
+ HttpRouter.add(
+ "POST",
+ "/dispose-probe",
+ Effect.gen(function* () {
+ const instance = yield* InstanceRef
+ if (!instance) return HttpServerResponse.empty({ status: 500 })
+ yield* markInstanceForDisposal(instance)
+ return yield* HttpServerResponse.json(true)
+ }),
+ ).pipe(Layer.provide(instanceContextTestLayer)),
+ { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
+ ).pipe(Layer.build)
+
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
Effect.gen(function* () {
@@ -164,4 +200,25 @@ describe("HttpApi instance context middleware", () => {
})
}),
)
+
+ it.live("preserves selected workspace id on instance disposal events", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const project = yield* Project.use.fromDirectory(dir)
+ const workspaceDir = path.join(dir, ".workspace-local")
+ const workspace = yield* createLocalWorkspace({
+ projectID: project.project.id,
+ type: "instance-context-dispose-event",
+ directory: workspaceDir,
+ })
+ yield* serveDisposeProbe()
+ const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped)
+
+ const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute)
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toBe(true)
+ expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
+ }),
+ )
})
diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts
new file mode 100644
index 000000000..4f9ccc512
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "@/bus/global"
+import { Instance } from "../../src/project/instance"
+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 { tmpdir } from "../fixture/fixture"
+
+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) {
+ return await new Promise<void>((resolve, reject) => {
+ const timer = setTimeout(() => {
+ GlobalBus.off("event", onEvent)
+ reject(new Error("timed out waiting for instance disposal"))
+ }, 10_000)
+
+ function onEvent(event: { directory?: string; payload: { type?: string } }) {
+ if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
+ clearTimeout(timer)
+ GlobalBus.off("event", onEvent)
+ resolve()
+ }
+
+ GlobalBus.on("event", onEvent)
+ })
+}
+
+afterEach(async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+ await Instance.disposeAll()
+ 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 = new Promise<string | undefined>((resolve) => {
+ const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
+ if (event.payload.type !== "server.instance.disposed") return
+ GlobalBus.off("event", onEvent)
+ resolve(event.directory)
+ }
+ GlobalBus.on("event", onEvent)
+ })
+
+ 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).toBe(tmp.path)
+ })
+})
diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts
index 8e48284de..3d9245cd6 100644
--- a/packages/opencode/test/server/httpapi-instance.test.ts
+++ b/packages/opencode/test/server/httpapi-instance.test.ts
@@ -1,164 +1,83 @@
-import { afterEach, describe, expect, test } from "bun:test"
-import path from "path"
+import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
-import { GlobalBus } from "@/bus/global"
-import { Instance } from "../../src/project/instance"
-import { Server } from "../../src/server/server"
+import { describe, expect } from "bun:test"
+import { Config, Effect, FileSystem, Layer, Path } from "effect"
+import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
+import * as Socket from "effect/unstable/socket/Socket"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
-import * as Log from "@opencode-ai/core/util/log"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
-import { tmpdir } from "../fixture/fixture"
-
-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) {
- return await new Promise<void>((resolve, reject) => {
- const timer = setTimeout(() => {
- GlobalBus.off("event", onEvent)
- reject(new Error("timed out waiting for instance disposal"))
- }, 10_000)
-
- function onEvent(event: { directory?: string; payload: { type?: string } }) {
- if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
- clearTimeout(timer)
- GlobalBus.off("event", onEvent)
- resolve()
- }
-
- GlobalBus.on("event", onEvent)
- })
-}
-
-afterEach(async () => {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
- await Instance.disposeAll()
- await resetDatabase()
-})
-
-describe("instance HttpApi", () => {
- test("serves path and VCS read endpoints through Hono bridge", async () => {
- await using tmp = await tmpdir({ git: true })
- await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
-
- const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
- vcsDiff.searchParams.set("mode", "git")
-
- const [paths, vcs, diff] = await Promise.all([
- app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
- app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
- app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
- ])
-
- expect(paths.status).toBe(200)
- expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
-
- expect(vcs.status).toBe(200)
- expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
-
- expect(diff.status).toBe(200)
- expect(await diff.json()).toContainEqual(
- expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
+import { tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+// Flip the experimental HttpApi flag so backend selection telemetry on the
+// production routes reports the right backend, and reset the database around
+// the test so per-instance state does not leak between runs. resetDatabase()
+// already calls Instance.disposeAll(), so we don't repeat it.
+const testStateLayer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ yield* Effect.promise(() => resetDatabase())
+ yield* Effect.addFinalizer(() =>
+ Effect.promise(async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
+ await resetDatabase()
+ }),
)
- })
-
- 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 } }),
- ])
+// Mount the production HttpApi route tree on a real Node HTTP server bound to
+// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
+// keeps the test wired through the same route layer production uses, without
+// going through Server.Default()/Hono.
+const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
+ ExperimentalHttpApiServer.routes,
+ { disableListenLog: true, disableLogger: true },
+)
- expect(commands.status).toBe(200)
- expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
+const httpApiServerLayer = servedRoutes.pipe(
+ Layer.provide(Socket.layerWebSocketConstructorGlobal),
+ Layer.provideMerge(NodeHttpServer.layerTest),
+ Layer.provideMerge(NodeServices.layer),
+)
- expect(agents.status).toBe(200)
- expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
+const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
- expect(skills.status).toBe(200)
- expect(await skills.json()).toBeArray()
+const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
- 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 = new Promise<string | undefined>((resolve) => {
- const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
- if (event.payload.type !== "server.instance.disposed") return
- GlobalBus.off("event", onEvent)
- resolve(event.directory)
- }
- GlobalBus.on("event", onEvent)
- })
-
- 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).toBe(tmp.path)
- })
+describe("instance HttpApi", () => {
+ it.live("serves path and VCS read endpoints", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
+
+ const [paths, vcs, diff] = yield* Effect.all(
+ [
+ HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
+ HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
+ HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
+ HttpClientRequest.setUrlParam("mode", "git"),
+ directoryHeader(dir),
+ HttpClient.execute,
+ ),
+ ],
+ { concurrency: "unbounded" },
+ )
+
+ expect(paths.status).toBe(200)
+ expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
+
+ expect(vcs.status).toBe(200)
+ expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
+
+ expect(diff.status).toBe(200)
+ expect(yield* diff.json).toContainEqual(
+ expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
+ )
+ }),
+ )
})
diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts
new file mode 100644
index 000000000..5d2f6f474
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts
@@ -0,0 +1,81 @@
+import { NodeHttpServer } from "@effect/platform-node"
+import { Session } from "@/session/session"
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
+import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
+import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
+import {
+ WorkspaceRouteContext,
+ WorkspaceRoutingMiddleware,
+} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
+import { testEffect } from "../lib/effect"
+
+const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi)
+const fakeSession = Layer.mock(Session.Service)({})
+const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
+ Effect.succeed(
+ handlers
+ .handle("status", () => Effect.die("unexpected MCP status"))
+ .handle("add", () => Effect.die("unexpected MCP add"))
+ .handle("authStart", () =>
+ Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }),
+ )
+ .handle("authCallback", () => Effect.die("unexpected MCP authCallback"))
+ .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate"))
+ .handle("authRemove", () => Effect.die("unexpected MCP authRemove"))
+ .handle("connect", () => Effect.die("unexpected MCP connect"))
+ .handle("disconnect", () => Effect.die("unexpected MCP disconnect")),
+ ),
+)
+
+const passthroughAuthorization = Layer.succeed(
+ Authorization,
+ Authorization.of({
+ basic: (effect) => effect,
+ authToken: (effect) => effect,
+ }),
+)
+
+const passthroughInstanceContext = Layer.succeed(
+ InstanceContextMiddleware,
+ InstanceContextMiddleware.of((effect) => effect),
+)
+
+const testWorkspaceRouting = Layer.succeed(
+ WorkspaceRoutingMiddleware,
+ WorkspaceRoutingMiddleware.of((effect) =>
+ effect.pipe(
+ Effect.provideService(
+ WorkspaceRouteContext,
+ WorkspaceRouteContext.of({ directory: process.cwd() }),
+ ),
+ ),
+ ),
+)
+
+const it = testEffect(
+ HttpRouter.serve(
+ HttpApiBuilder.layer(TestHttpApi).pipe(
+ Layer.provide(testMcpHandlers),
+ Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]),
+ ),
+ { disableListenLog: true, disableLogger: true },
+ ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
+)
+
+describe("mcp HttpApi OAuth", () => {
+ it.live("preserves oauth state when starting OAuth", () =>
+ Effect.gen(function* () {
+ const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute)
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toEqual({
+ authorizationUrl: "https://auth.example/start",
+ oauthState: "state-123",
+ })
+ }),
+ )
+})
diff --git a/packages/opencode/test/server/httpapi-pty-websocket.test.ts b/packages/opencode/test/server/httpapi-pty-websocket.test.ts
new file mode 100644
index 000000000..81ee952d9
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-pty-websocket.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { handlePtyInput } from "../../src/pty/input"
+
+describe("pty HttpApi websocket input", () => {
+ test("does not forward invalid binary frames to the PTY handler", async () => {
+ const messages: Array<string | ArrayBuffer> = []
+ const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
+
+ await Effect.runPromise(handlePtyInput(handler, "ready"))
+ await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
+ await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
+
+ expect(messages).toEqual(["ready", "hello"])
+ })
+})
diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts
index 6f3a0cb1c..596ca4a5c 100644
--- a/packages/opencode/test/server/httpapi-sdk.test.ts
+++ b/packages/opencode/test/server/httpapi-sdk.test.ts
@@ -404,7 +404,7 @@ describe("HttpApi SDK", () => {
lsp,
}),
project: { worktreeSelected: record(project.data).worktree === directory },
- paths: { cwdSelected: record(paths.data).cwd === directory },
+ paths: { directorySelected: record(paths.data).directory === directory },
file: record(file.data).content,
hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts
index 593f9765c..58e02ef0f 100644
--- a/packages/opencode/test/server/httpapi-session.test.ts
+++ b/packages/opencode/test/server/httpapi-session.test.ts
@@ -1,4 +1,6 @@
import { afterEach, describe, expect } from "bun:test"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PermissionID } from "../../src/permission/schema"
@@ -9,7 +11,10 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
+import { Database } from "@/storage/db"
+import { SessionTable } from "@/session/session.sql"
import * as Log from "@opencode-ai/core/util/log"
+import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
@@ -18,9 +23,9 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
-function app() {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
- 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>) {
@@ -76,6 +81,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())
@@ -218,6 +227,91 @@ describe("session HttpApi", () => {
)
it.live(
+ "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 legacy = yield* createSession(tmp.path, { title: "legacy" })
+ const effect = yield* createSession(tmp.path, { title: "effect" })
+ const body = JSON.stringify({ time: { archived: -1 } })
+
+ 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(effectResponse.status).toBe(legacyResponse.status)
+ expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
+ }),
+ ),
+ )
+
+ it.live(
+ "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")
+ yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
+
+ const pathSession = yield* createSession(currentDir)
+ const pathlessSession = yield* createSession(currentDir)
+ yield* Effect.sync(() =>
+ Database.use((db) =>
+ db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
+ ),
+ )
+
+ const query = new URLSearchParams({
+ scope: "project",
+ path: "packages/opencode/src",
+ directory: currentDir,
+ })
+ const headers = { "x-opencode-directory": tmp.path }
+ 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(legacy).toContain(pathSession.id)
+ expect(legacy).not.toContain(pathlessSession.id)
+ expect(effect).toEqual(legacy)
+ }),
+ ),
+ )
+
+ it.live(
+ "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 }
+ const session = yield* createSession(tmp.path, { title: "messages" })
+ yield* createTextMessage(tmp.path, session.id, "first")
+ yield* createTextMessage(tmp.path, session.id, "second")
+ const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
+
+ const legacy = yield* requestWithBackend(false, route, { headers })
+ const effect = yield* requestWithBackend(true, route, { headers })
+
+ 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 through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts
index 9f7c8e9e8..3e844fad0 100644
--- a/packages/opencode/test/server/httpapi-tui.test.ts
+++ b/packages/opencode/test/server/httpapi-tui.test.ts
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "../../src/bus/global"
+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"
@@ -15,9 +17,20 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
-function app() {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
- return Server.Default().app
+function app(experimental = true) {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
+ return experimental ? Server.Default().app : Server.Legacy().app
+}
+
+function nextCommandExecute() {
+ return new Promise<unknown>((resolve) => {
+ const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
+ if (event.payload.type !== TuiEvent.CommandExecute.type) return
+ GlobalBus.off("event", listener)
+ resolve(event.payload.properties?.command)
+ }
+ GlobalBus.on("event", listener)
+ })
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
+ 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)
diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts
index 6d0649922..b52b95d86 100644
--- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts
+++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts
@@ -20,6 +20,7 @@ import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
+import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRouteContext,
workspaceRouterMiddleware,
@@ -387,6 +388,36 @@ describe("HttpApi workspace routing middleware", () => {
}),
)
+ it.live("keeps workspace control routes local even when workspace is selected", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const project = yield* Project.use.fromDirectory(dir)
+ const workspaceDir = path.join(dir, ".workspace-local")
+ const workspace = yield* createLocalWorkspace({
+ projectID: project.project.id,
+ type: "workspace-control-plane-target",
+ directory: workspaceDir,
+ })
+
+ // Workspace CRUD/status routes manage the control plane itself. Selecting
+ // a workspace should preserve the selected id for handlers, but must not
+ // swap the route context to the workspace target directory.
+ yield* HttpRouter.add(
+ "GET",
+ WorkspacePaths.list,
+ Effect.gen(function* () {
+ const route = yield* WorkspaceRouteContext
+ return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
+ }),
+ ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
+
+ const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
+ }),
+ )
+
it.live("uses directory query/header fallback when no workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()