diff --git a/packages/opencode/src/pty/input.ts b/packages/opencode/src/pty/input.ts new file mode 100644 index 0000000000..0e4ea9a61a --- /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 0000000000..8ae945b752 --- /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 95f1405706..433f301ae4 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 e9caf0cd9d..b30714c196 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 bc26a9e597..77d064ff5a 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 8558ee793c..aa151cecec 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 65c90b9529..3d88db60db 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 cb12ccb7a7..c7c447ce85 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 c93261a0be..7b263980c5 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() +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() + +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 = (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[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 370696ddb2..e0ce524856 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(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 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 29b1ab9869..c22a09bda9 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 = [ + { 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 1be5dfffd4..9a50a9a980 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 0000000000..3330cfdd11 --- /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 = 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 6fe92a2346..915d79784c 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 74b1ecdeba..aec3743e60 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 0000000000..4f9ccc512a --- /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((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((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 8e48284dea..3d9245cd6f 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" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -void Log.init({ print: false }) +// 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() + }), + ) + }), +) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +// 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 = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app -} +const httpApiServerLayer = servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) -async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) +const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) - 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() -}) +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) 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") + 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 vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`) - vcsDiff.searchParams.set("mode", "git") + 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" }, + ) - 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(yield* paths.json).toMatchObject({ directory: dir, worktree: dir }) - expect(paths.status).toBe(200) - expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path }) + expect(vcs.status).toBe(200) + expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) }) - 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" }), - ) - }) - - 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((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) - }) + 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 0000000000..5d2f6f474d --- /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 0000000000..81ee952d96 --- /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 = [] + 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 6f3a0cb1cb..596ca4a5c4 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 593f9765c7..58e02ef0fc 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(fx: Effect.Effect) { @@ -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(response: Response) { return Effect.promise(async () => { 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(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(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( + yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + const effect = (yield* json( + 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) => diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 9f7c8e9e89..3e844fad02 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((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, 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 6d06499224..b52b95d86c 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()