Просмотр исходного кода

test: port instance HttpApi path/vcs read coverage to Effect

Kit Langton 4 месяцев назад
Родитель
Сommit
dddfcbf0d8
23 измененных файлов с 717 добавлено и 221 удалено
  1. 24 0
      packages/opencode/src/pty/input.ts
  2. 12 0
      packages/opencode/src/server/cors.ts
  3. 2 10
      packages/opencode/src/server/middleware.ts
  4. 1 0
      packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
  5. 1 2
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  6. 2 3
      packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
  7. 1 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  8. 4 3
      packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
  9. 46 15
      packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts
  10. 8 19
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  11. 1 0
      packages/opencode/src/server/workspace.ts
  12. 7 3
      packages/opencode/src/session/session.ts
  13. 64 0
      packages/opencode/test/server/httpapi-cors.test.ts
  14. 12 3
      packages/opencode/test/server/httpapi-event.test.ts
  15. 58 1
      packages/opencode/test/server/httpapi-instance-context.test.ts
  16. 138 0
      packages/opencode/test/server/httpapi-instance.legacy.test.ts
  17. 73 154
      packages/opencode/test/server/httpapi-instance.test.ts
  18. 81 0
      packages/opencode/test/server/httpapi-mcp-oauth.test.ts
  19. 16 0
      packages/opencode/test/server/httpapi-pty-websocket.test.ts
  20. 1 1
      packages/opencode/test/server/httpapi-sdk.test.ts
  21. 97 3
      packages/opencode/test/server/httpapi-session.test.ts
  22. 37 3
      packages/opencode/test/server/httpapi-tui.test.ts
  23. 31 0
      packages/opencode/test/server/httpapi-workspace-routing.test.ts

+ 24 - 0
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
+    }),
+  )
+}

+ 12 - 0
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
+}

+ 2 - 10
packages/opencode/src/server/middleware.ts

@@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth"
 import { cors } from "hono/cors"
 import { cors } from "hono/cors"
 import { compress } from "hono/compress"
 import { compress } from "hono/compress"
 import * as ServerBackend from "./backend"
 import * as ServerBackend from "./backend"
+import { isAllowedCorsOrigin } from "./cors"
 
 
 const log = Log.create({ service: "server" })
 const log = Log.create({ service: "server" })
 
 
@@ -70,16 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
   return cors({
   return cors({
     maxAge: 86_400,
     maxAge: 86_400,
     origin(input) {
     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
     },
     },
   })
   })
 }
 }

+ 1 - 0
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 StatusMap = Schema.Record(Schema.String, MCP.Status)
 export const AuthStartResponse = Schema.Struct({
 export const AuthStartResponse = Schema.Struct({
   authorizationUrl: Schema.String,
   authorizationUrl: Schema.String,
+  oauthState: Schema.String,
 })
 })
 export const AuthCallbackPayload = Schema.Struct({
 export const AuthCallbackPayload = Schema.Struct({
   code: Schema.String,
   code: Schema.String,

+ 1 - 2
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 { Todo } from "@/session/todo"
 import { MessageID, PartID, SessionID } from "@/session/schema"
 import { MessageID, PartID, SessionID } from "@/session/schema"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
-import { NonNegativeInt } from "@/util/schema"
 import { Schema, SchemaGetter, Struct } from "effect"
 import { Schema, SchemaGetter, Struct } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { Authorization } from "../middleware/authorization"
@@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({
   permission: Schema.optional(Permission.Ruleset),
   permission: Schema.optional(Permission.Ruleset),
   time: Schema.optional(
   time: Schema.optional(
     Schema.Struct({
     Schema.Struct({
-      archived: Schema.optional(NonNegativeInt),
+      archived: Schema.optional(Session.ArchivedTimestamp),
     }),
     }),
   ),
   ),
 })
 })

+ 2 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts

@@ -1,6 +1,7 @@
 import { EffectBridge } from "@/effect/bridge"
 import { EffectBridge } from "@/effect/bridge"
 import { Pty } from "@/pty"
 import { Pty } from "@/pty"
 import { PtyID } from "@/pty/schema"
 import { PtyID } from "@/pty/schema"
+import { handlePtyInput } from "@/pty/input"
 import { Shell } from "@/shell/shell"
 import { Shell } from "@/shell/shell"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -102,9 +103,7 @@ export const ptyConnectRoute = HttpRouter.add(
     if (!handler) return HttpServerResponse.empty()
     if (!handler) return HttpServerResponse.empty()
 
 
     yield* socket
     yield* socket
-      .runRaw((message) => {
-        handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
-      })
+      .runRaw((message) => handlePtyInput(handler, message))
       .pipe(
       .pipe(
         Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
         Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
         Effect.ensuring(
         Effect.ensuring(

+ 1 - 1
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, () =>
       return Instance.restore(instance, () =>
         Array.from(
         Array.from(
           Session.list({
           Session.list({
-            directory: ctx.query.directory,
+            directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
             scope: ctx.query.scope,
             scope: ctx.query.scope,
             path: ctx.query.path,
             path: ctx.query.path,
             roots: ctx.query.roots,
             roots: ctx.query.roots,

+ 4 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts

@@ -28,8 +28,8 @@ const commandAliases = {
 export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
 export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const bus = yield* Bus.Service
     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: {
     const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
       payload: typeof TuiEvent.PromptAppend.properties.Type
       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: {
     const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
       payload: typeof CommandPayload.Type
       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
       return true
     })
     })
 
 

+ 46 - 15
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 { Instance, type InstanceContext } from "@/project/instance"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
 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]) =>
 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) =>
 export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const response = yield* effect
     const response = yield* effect
     const request = yield* HttpServerRequest.HttpServerRequest
     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)
     disposeAfterResponse.delete(request.source)
-    yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
+    yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
     return response
     return response
   })
   })

+ 8 - 19
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -1,6 +1,6 @@
 import { Context, Effect, Layer } from "effect"
 import { Context, Effect, Layer } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 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 * as Socket from "effect/unstable/socket/Socket"
 import { Account } from "@/account/account"
 import { Account } from "@/account/account"
 import { Agent } from "@/agent/agent"
 import { Agent } from "@/agent/agent"
@@ -31,6 +31,7 @@ import { ToolRegistry } from "@/tool/registry"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { Vcs } from "@/project/vcs"
 import { Vcs } from "@/project/vcs"
 import { Worktree } from "@/worktree"
 import { Worktree } from "@/worktree"
+import { isAllowedCorsOrigin } from "@/server/cors"
 import { InstanceHttpApi, RootHttpApi } from "./api"
 import { InstanceHttpApi, RootHttpApi } from "./api"
 import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
 import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
 import { eventRoute } from "./event"
 import { eventRoute } from "./event"
@@ -55,7 +56,6 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
 import { disposeMiddleware } from "./lifecycle"
 import { disposeMiddleware } from "./lifecycle"
 import { memoMap } from "@opencode-ai/core/effect/memo-map"
 import { memoMap } from "@opencode-ai/core/effect/memo-map"
 import * as ServerBackend from "@/server/backend"
 import * as ServerBackend from "@/server/backend"
-import type { Predicate } from "effect/Predicate"
 
 
 export const context = Context.makeUnsafe<unknown>(new Map())
 export const context = Context.makeUnsafe<unknown>(new Map())
 
 
@@ -69,6 +69,11 @@ const runtime = HttpRouter.middleware()(
   ),
   ),
 ).layer
 ).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 rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
 const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
 const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
   Layer.provide([
   Layer.provide([
@@ -105,24 +110,8 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
 )
 )
 
 
 export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).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([
   Layer.provide([
+    cors,
     runtime,
     runtime,
     Account.defaultLayer,
     Account.defaultLayer,
     Agent.defaultLayer,
     Agent.defaultLayer,

+ 1 - 0
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" }
 type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
 
 
 const RULES: Array<Rule> = [
 const RULES: Array<Rule> = [
+  { path: "/experimental/workspace", action: "local" },
   { path: "/session/status", action: "forward" },
   { path: "/session/status", action: "forward" },
   { method: "GET", path: "/session", action: "local" },
   { method: "GET", path: "/session", action: "local" },
 ]
 ]

+ 7 - 3
packages/opencode/src/session/session.ts

@@ -142,11 +142,15 @@ const Share = Schema.Struct({
   url: Schema.String,
   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({
 const Time = Schema.Struct({
   created: NonNegativeInt,
   created: NonNegativeInt,
   updated: NonNegativeInt,
   updated: NonNegativeInt,
   compacting: optionalOmitUndefined(NonNegativeInt),
   compacting: optionalOmitUndefined(NonNegativeInt),
-  archived: optionalOmitUndefined(NonNegativeInt),
+  archived: optionalOmitUndefined(ArchivedTimestamp),
 })
 })
 
 
 const Revert = Schema.Struct({
 const Revert = Schema.Struct({
@@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
 )
 )
 export const SetArchivedInput = Schema.Struct({
 export const SetArchivedInput = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
-  time: Schema.optional(NonNegativeInt),
+  time: Schema.optional(ArchivedTimestamp),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export const SetPermissionInput = Schema.Struct({
 export const SetPermissionInput = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
@@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({
   created: Schema.optional(Schema.NullOr(NonNegativeInt)),
   created: Schema.optional(Schema.NullOr(NonNegativeInt)),
   updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
   updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
   compacting: 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({
 const UpdatedInfo = Schema.Struct({

+ 64 - 0
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")
+    }),
+  )
+})

+ 12 - 3
packages/opencode/test/server/httpapi-event.test.ts

@@ -11,9 +11,9 @@ void Log.init({ print: false })
 
 
 const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 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) {
 async function readFirstChunk(response: Response) {
@@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
     expect(response.headers.get("x-content-type-options")).toBe("nosniff")
     expect(response.headers.get("x-content-type-options")).toBe("nosniff")
     expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
     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))
+  })
 })
 })

+ 58 - 1
packages/opencode/test/server/httpapi-instance-context.test.ts

@@ -1,7 +1,8 @@
 import { NodeHttpServer, NodeServices } from "@effect/platform-node"
 import { NodeHttpServer, NodeServices } from "@effect/platform-node"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "@/bus/global"
 import { describe, expect } from "bun:test"
 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 { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
 import * as Socket from "effect/unstable/socket/Socket"
 import * as Socket from "effect/unstable/socket/Socket"
 import { mkdir } from "node:fs/promises"
 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 { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Project } from "../../src/project/project"
 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 { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
 import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
 import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
@@ -84,6 +86,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
     Layer.build,
     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", () => {
 describe("HttpApi instance context middleware", () => {
   it.live("provides instance context from the routed directory", () =>
   it.live("provides instance context from the routed directory", () =>
     Effect.gen(function* () {
     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 })
+    }),
+  )
 })
 })

+ 138 - 0
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)
+  })
+})

+ 73 - 154
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 { 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 { 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 { 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" }),
+      )
+    }),
+  )
 })
 })

+ 81 - 0
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",
+      })
+    }),
+  )
+})

+ 16 - 0
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"])
+  })
+})

+ 1 - 1
packages/opencode/test/server/httpapi-sdk.test.ts

@@ -404,7 +404,7 @@ describe("HttpApi SDK", () => {
             lsp,
             lsp,
           }),
           }),
           project: { worktreeSelected: record(project.data).worktree === directory },
           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,
           file: record(file.data).content,
           hasProject: array(projects.data).length > 0,
           hasProject: array(projects.data).length > 0,
           foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
           foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),

+ 97 - 3
packages/opencode/test/server/httpapi-session.test.ts

@@ -1,4 +1,6 @@
 import { afterEach, describe, expect } from "bun:test"
 import { afterEach, describe, expect } from "bun:test"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { PermissionID } from "../../src/permission/schema"
 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 { Session } from "@/session/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { MessageV2 } from "../../src/session/message-v2"
 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 * as Log from "@opencode-ai/core/util/log"
+import { eq } from "drizzle-orm"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 import { tmpdir } from "../fixture/fixture"
 import { it } from "../lib/effect"
 import { it } from "../lib/effect"
@@ -18,9 +23,9 @@ void Log.init({ print: false })
 
 
 const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 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>) {
 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))
   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) {
 function json<T>(response: Response) {
   return Effect.promise(async () => {
   return Effect.promise(async () => {
     if (response.status !== 200) throw new Error(await response.text())
     if (response.status !== 200) throw new Error(await response.text())
@@ -217,6 +226,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(
   it.live(
     "serves message mutation routes through Hono bridge",
     "serves message mutation routes through Hono bridge",
     withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
     withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>

+ 37 - 3
packages/opencode/test/server/httpapi-tui.test.ts

@@ -1,6 +1,8 @@
 import { afterEach, describe, expect, test } from "bun:test"
 import { afterEach, describe, expect, test } from "bun:test"
 import type { Context } from "hono"
 import type { Context } from "hono"
 import { Flag } from "@opencode-ai/core/flag/flag"
 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 { SessionID } from "../../src/session/schema"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
 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
 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) {
 async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
     expect(missing.status).toBe(404)
     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 () => {
   test("serves TUI control queue through experimental Effect routes", async () => {
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
     const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)

+ 31 - 0
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 { Workspace } from "../../src/control-plane/workspace"
 import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
 import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
 import { Project } from "../../src/project/project"
 import { Project } from "../../src/project/project"
+import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
 import {
 import {
   WorkspaceRouteContext,
   WorkspaceRouteContext,
   workspaceRouterMiddleware,
   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", () =>
   it.live("uses directory query/header fallback when no workspace is selected", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const dir = yield* tmpdirScoped()
       const dir = yield* tmpdirScoped()