From 76d814e7476c98dfc18b5cc51f959f6feae0ef9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 27 May 2026 08:45:11 -0400 Subject: [PATCH] refactor(server): unify instance httpapi middleware routing Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage. --- .../server/routes/instance/httpapi/AGENTS.md | 10 +- .../routes/instance/httpapi/groups/event.ts | 7 +- .../routes/instance/httpapi/groups/pty.ts | 22 +++- .../routes/instance/httpapi/handlers/pty.ts | 46 ++++--- .../httpapi/middleware/authorization.ts | 26 +++- .../httpapi/middleware/instance-context.ts | 9 +- .../httpapi/middleware/workspace-routing.ts | 24 +--- .../server/routes/instance/httpapi/server.ts | 44 +++---- packages/opencode/test/server/AGENTS.md | 4 +- .../server/httpapi-instance-context.test.ts | 98 ++++++++++----- ...ts => httpapi-instance-route-auth.test.ts} | 9 +- .../httpapi-promptasync-context.test.ts | 89 ++++++++++---- .../opencode/test/server/httpapi-pty.test.ts | 8 ++ .../server/httpapi-public-openapi.test.ts | 6 + .../server/httpapi-workspace-routing.test.ts | 113 +++++++++--------- .../test/server/httpapi-workspace.test.ts | 14 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + 18 files changed, 340 insertions(+), 195 deletions(-) rename packages/opencode/test/server/{httpapi-raw-route-auth.test.ts => httpapi-instance-route-auth.test.ts} (88%) diff --git a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md index a6ccf794dd..c44db1edbb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md +++ b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md @@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type. -Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks. +Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree. ```ts -export const rawRoute = HttpRouter.use((router) => +export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service - yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty)) + return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty)) }), ) ``` +Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback. + Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request. Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services. @@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context, Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary. -When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled. +When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy. diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/event.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/event.ts index 7ebc229ee5..6de2214443 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/event.ts @@ -1,6 +1,8 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { WorkspaceRoutingQuery } from "../middleware/workspace-routing" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" export const EventPaths = { event: "/event", @@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add( }), ), ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization) .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 3adc4e5c36..cca9d0013c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,9 +1,10 @@ import { Pty } from "@/pty" import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" +import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../middleware/authorization" +import { Authorization, PtyConnectAuthorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, @@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty") export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiGroup.make("pty-connect") .add( + // Decode PTY connection query fields in the raw handler after checking + // existence, preserving the established empty-404 response ordering. HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, - query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( @@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( summary: "Connect to PTY session", description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + transform: (operation) => ({ + ...operation, + parameters: [ + ...(operation.parameters ?? []), + ...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + ], + }), }), ), ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(PtyConnectAuthorization), ) 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 4644b02934..f9349464be 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -10,13 +10,13 @@ import { PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, } from "@/server/shared/pty-ticket" -import { Effect } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { Effect, Option, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import * as ApiError from "../errors" -import { CursorQuery, Params, PtyPaths } from "../groups/pty" +import { CursorQuery, PtyConnectApi } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { @@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }), ) -export const ptyConnectRoute = HttpRouter.use((router) => +export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig - yield* router.add( - "GET", - PtyPaths.connect, - Effect.gen(function* () { - const params = yield* HttpRouter.schemaPathParams(Params) - const exists = yield* pty.get(params.ptyID).pipe( + + return handlers.handleRaw( + "connect", + Effect.fn("PtyHttpApi.connect")(function* (ctx: { + params: { ptyID: PtyID } + request: HttpServerRequest.HttpServerRequest + }) { + const exists = yield* pty.get(ctx.params.ptyID).pipe( Effect.as(true), Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), ) if (!exists) return HttpServerResponse.empty({ status: 404 }) - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - const request = yield* HttpServerRequest.HttpServerRequest - const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) + const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams) + if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 }) + const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) if (ticket) { - const valid = validOrigin(request, cors) - ? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) }) + const valid = validOrigin(ctx.request, cors) + ? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) : false if (!valid) return HttpServerResponse.empty({ status: 403 }) } - const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) + const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie(request.upgrade) + const socket = yield* Effect.orDie(ctx.request.upgrade) const write = yield* socket.writer const closeAccepted = (event: Socket.CloseEvent) => socket @@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) => }, } const handler = yield* pty - .connect(params.ptyID, adapter, cursor) + .connect(ctx.params.ptyID, adapter, cursor) .pipe( Effect.catchTag("Pty.NotFoundError", () => closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), @@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) => ) if (!handler) return HttpServerResponse.empty() - // No `pending[]`-style early-frame buffer (the legacy handler had one). - // `request.upgrade` returns a Socket without running the WS handshake; the - // handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves - // and the message callback is registered. The client therefore can't fire - // `open` and start sending until the listener is already wired. Don't move - // `runRaw` ahead of `pty.connect` without re-introducing a buffer. + // The handshake runs inside `socket.runRaw`, after the input callback is + // registered, so the client cannot send frames before PTY input is wired. yield* socket .runRaw((message) => handlePtyInput(handler, message)) .pipe( diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index a36d97a1fa..c21de79a24 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service( }, ) {} +export class PtyConnectAuthorization extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiPtyConnectAuthorization", + { + error: HttpApiError.UnauthorizedNoContent, + }, +) {} + function emptyCredential() { return { username: "", @@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") if (isPublicUIPath(request.method, url.pathname)) return yield* effect - if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), ) @@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect( }), ) +export const ptyConnectAuthorizationLayer = Layer.effect( + PtyConnectAuthorization, + Effect.gen(function* () { + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect) + return PtyConnectAuthorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const url = new URL(request.url, "http://localhost") + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( + Effect.flatMap((credential) => validateCredential(effect, credential, config)), + ) + }), + ) + }), +) + export const v2AuthorizationLayer = Layer.effect( V2Authorization, Effect.gen(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 90f3ce4773..e39eb7394a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,7 +1,7 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" -import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { WorkspaceRouteContext } from "./workspace-routing" @@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect( return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) }), ) - -export const instanceRouterMiddleware = HttpRouter.middleware()( - Effect.gen(function* () { - const store = yield* InstanceStore.Service - return (effect) => provideInstanceContext(effect, store) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 8bffe59640..68ba95a7c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer, Option, Schema } from "effect" -import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InvalidRequestError } from "../errors" @@ -219,7 +219,10 @@ function routeHttpApiWorkspace( const sessionID = getWorkspaceRouteSessionID(requestURL(request)) const session = sessionID ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( - Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)), + Effect.catchIf( + (error): error is NotFoundError => NotFoundError.isInstance(error), + () => Effect.succeed(undefined), + ), Effect.catchDefect(() => Effect.succeed(undefined)), ) : undefined @@ -242,20 +245,3 @@ export const workspaceRoutingLayer = Layer.effect( ) }), ) - -export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()( - Effect.gen(function* () { - const makeWebSocket = yield* Socket.WebSocketConstructor - const workspace = yield* Workspace.Service - const client = yield* HttpClient.HttpClient - return (effect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const plan = yield* planRequest(request) - return yield* routeWorkspace(client, effect, plan) - }).pipe( - Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), - Effect.provideService(Workspace.Service, workspace), - ) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 6ccc995c66..45a71a7bf3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -59,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { PublicApi } from "./public" -import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization" +import { + authorizationLayer, + authorizationRouterMiddleware, + ptyConnectAuthorizationLayer, + v2AuthorizationLayer, +} from "./middleware/authorization" import { EventApi } from "./groups/event" +import { PtyConnectApi } from "./groups/pty" import { eventHandlers } from "./handlers/event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp" import { permissionHandlers } from "./handlers/permission" import { projectHandlers } from "./handlers/project" import { providerHandlers } from "./handlers/provider" -import { ptyConnectRoute, ptyHandlers } from "./handlers/pty" +import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty" import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" -import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" -import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" +import { instanceContextLayer } from "./middleware/instance-context" +import { workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { compressionLayer } from "./middleware/compression" @@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) => // Route tree: // - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi. -// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware. -// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below. +// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract. +// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth. +// - instanceApiRoutes: remaining typed instance routes. // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) -const instanceRouterLayer = authorizationRouterMiddleware - .combine(instanceRouterMiddleware) - .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer)) const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( Layer.provide(eventHandlers), - Layer.provide(instanceRouterLayer), + Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]), +) +const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe( + Layer.provide(ptyConnectHandlers), + Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]), ) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ @@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) -const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( - Layer.provide([ - httpApiAuthLayer, - v2HttpApiAuthLayer, - workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), - instanceContextLayer, - schemaErrorLayer, - ]), +const instanceRoutes = instanceApiRoutes.pipe( + Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]), ) // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so @@ -184,7 +186,7 @@ type RouteRequirements = export function createRoutes( corsOptions?: CorsOptions, ): Layer.Layer { - return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, compressionLayer, diff --git a/packages/opencode/test/server/AGENTS.md b/packages/opencode/test/server/AGENTS.md index bed2b52695..754977375b 100644 --- a/packages/opencode/test/server/AGENTS.md +++ b/packages/opencode/test/server/AGENTS.md @@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory. - Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy. - Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it. -- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. -- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`. +- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. +- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`. - For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits. - Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific. - For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant. diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 35dbf97ba0..c464c42db1 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 { describe, expect } from "bun:test" -import { Effect, Fiber, Layer } from "effect" -import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { Effect, Fiber, Layer, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" +import { Session } from "../../src/session/session" 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 { + InstanceContextMiddleware, + instanceContextLayer, +} from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + workspaceRoutingLayer, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { withFixedWorkspaceID } from "../fixture/flag" @@ -47,9 +56,10 @@ const it = testEffect( ), ) -const instanceContextTestLayer = instanceRouterMiddleware - .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) +const instanceContextTestLayer = Layer.mergeAll( + instanceContextLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), +) const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", @@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri const probeInstanceContext = Effect.gen(function* () { const instance = yield* InstanceRef const workspaceID = yield* WorkspaceRef - return yield* HttpServerResponse.json({ + return { directory: instance?.directory, worktree: instance?.worktree, projectID: instance?.project.id, workspaceID, - }) + } }) -const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => - HttpRouter.add("GET", probePath, probeInstanceContext).pipe( - Layer.provide(instanceContextTestLayer), - HttpRouter.serve, - Layer.build, - ) +const ProbeResult = Schema.Struct({ + directory: Schema.optional(Schema.String), + worktree: Schema.optional(Schema.String), + projectID: Schema.optional(Schema.String), + workspaceID: Schema.optional(Schema.String), +}) + +const ProbeApi = HttpApi.make("instance-context-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.post("dispose", "/dispose-probe", { + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware), +) + +const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("get", () => probeInstanceContext) + .handle("session", () => probeInstanceContext) + .handle( + "dispose", + Effect.fn("InstanceContextProbe.dispose")(function* () { + const instance = yield* InstanceRef + if (!instance) return false + yield* markInstanceForDisposal(instance) + return true + }), + ), +) + +const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(instanceContextTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), +) + +const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build) const waitDisposedEvent = waitGlobalBusEvent({ message: "timed out waiting for instance disposal", @@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({ }).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) 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) + HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ) describe("HttpApi instance context middleware", () => { it.live("provides instance context from the routed directory", () => @@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => { directory: dir, worktree: dir, projectID: project.project.id, + workspaceID: null, }) }), ) @@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => { type: "instance-context-workspace-ref", directory: workspaceDir, }) - yield* serveProbe("/session") + yield* serveProbe() const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( HttpClientRequest.setHeader("x-opencode-directory", dir), @@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => { // is true. Combined with the env override, the route must stay Local with // the configured workspace id (not divert to the requested workspace's // local directory). - yield* serveProbe("/session") + yield* serveProbe() const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( HttpClientRequest.setHeader("x-opencode-directory", dir), diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts similarity index 88% rename from packages/opencode/test/server/httpapi-raw-route-auth.test.ts rename to packages/opencode/test/server/httpapi-instance-route-auth.test.ts index 7436c10817..4713540b9c 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts @@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { ServerAuth } from "../../src/server/auth" import { PtyID } from "../../src/pty/schema" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) { } function basic(username: string, password: string) { - return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return ServerAuth.header({ username, password }) ?? "" } async function cancelBody(response: Response) { @@ -47,8 +48,8 @@ afterEach(async () => { await resetDatabase() }) -describe("HttpApi raw route authorization", () => { - test("requires configured auth before opening the raw instance event stream", async () => { +describe("HttpApi instance route authorization", () => { + test("requires configured auth before opening the instance event stream", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const server = app({ password: "secret" }) const headers = { "x-opencode-directory": tmp.path } @@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => { expect(authed.status).toBe(200) }) - test("requires configured auth before resolving the raw PTY websocket route", async () => { + test("requires configured auth before resolving the PTY websocket route", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const server = app({ password: "secret" }) const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending()) diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index 84f3df5b8c..6a1d309073 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -9,10 +9,11 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Scope } from "effect" +import { Deferred, Effect, Layer, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { mkdir } from "node:fs/promises" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" -import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" -import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { Session } from "../../src/session/session" +import { + InstanceContextMiddleware, + instanceContextLayer, +} from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + workspaceRoutingLayer, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace" @@ -52,9 +61,10 @@ const it = testEffect( ), ) -const instanceContextTestLayer = instanceRouterMiddleware - .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) +const instanceContextTestLayer = Layer.mergeAll( + instanceContextLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), +) const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", @@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () { return { directory: instance?.directory, workspaceID } satisfies Capture }) +const ProbeApi = HttpApi.make("handler-context-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }), + HttpApiEndpoint.post("streamWithout", "/stream-probe-without", { + query: WorkspaceRoutingQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })), + }), + HttpApiEndpoint.post("streamWith", "/stream-probe-with", { + query: WorkspaceRoutingQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })), + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware), +) + +const serveProbes = (input: { + fork?: Effect.Effect + streamWithout?: Effect.Effect + streamWith?: Effect.Effect +}) => + HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide( + HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("fork", () => input.fork ?? Effect.succeed(false)) + .handleRaw( + "streamWithout", + () => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })), + ) + .handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))), + ), + ), + Layer.provide(instanceContextTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), + HttpRouter.serve, + Layer.build, + ) + describe("HttpApi handler context inheritance", () => { // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits // the request's Context — including InstanceRef and WorkspaceRef provided @@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => { const { dir, workspace } = yield* setupWorkspace("local-fork") const capture = yield* Deferred.make() - yield* HttpRouter.add( - "POST", - "/fork-probe", - Effect.gen(function* () { + yield* serveProbes({ + fork: Effect.gen(function* () { const scope = yield* Scope.Scope yield* Effect.gen(function* () { yield* Deferred.succeed(capture, yield* captureInstance) }).pipe(Effect.forkIn(scope, { startImmediately: true })) - return HttpServerResponse.empty({ status: 204 }) + return true }), - ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + }) const response = yield* HttpClient.post( `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, ) - expect(response.status).toBe(204) + expect(response.status).toBe(200) const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) expect(observed.directory).toBe(dir) @@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => { const withoutCapture = yield* Deferred.make() const withCapture = yield* Deferred.make() - yield* HttpRouter.add( - "POST", - "/stream-probe-without", - Effect.gen(function* () { + yield* serveProbes({ + streamWithout: Effect.gen(function* () { return HttpServerResponse.stream( Stream.fromEffect( Effect.gen(function* () { @@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => { { contentType: "application/json" }, ) }), - ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) - - yield* HttpRouter.add( - "POST", - "/stream-probe-with", - Effect.gen(function* () { + streamWith: Effect.gen(function* () { const instance = yield* InstanceRef const workspaceID = yield* WorkspaceRef return HttpServerResponse.stream( @@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => { { contentType: "application/json" }, ) }), - ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + }) const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 029cdb9582..f26bc68e67 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => { expect(response.status).toBe(404) }) + test("returns 404 for missing PTY websocket before decoding cursor query", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(404) + }) + test("returns typed not found errors for missing PTY HTTP resources", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path } diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 74a3aa68c1..86a3521a4a 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -9,6 +9,7 @@ type OpenApiResponse = { readonly content?: Record } type OpenApiOperation = { + readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }> readonly responses?: Record readonly security?: unknown } @@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => { expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe( "PtyForbiddenError", ) + expect( + spec.paths["/pty/{ptyID}/connect"]?.get?.parameters + ?.filter((parameter) => parameter.in === "query") + .map((parameter) => parameter.name), + ).toEqual(["directory", "workspace", "cursor", "ticket"]) }) test("documents project not-found errors", () => { diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 02a1361ba4..bc7074e23c 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,6 +1,6 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue, Ref } from "effect" +import { Context, Effect, Layer, Queue, Ref, Schema } from "effect" import { FetchHttpClient, HttpClient, @@ -11,6 +11,7 @@ import { HttpServerResponse, } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import Http from "node:http" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -20,10 +21,13 @@ import type { WorkspaceAdapter } 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 { Session } from "../../src/session/session" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, WorkspaceRouteContext, - workspaceRouterMiddleware, + workspaceRoutingLayer, } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { Database } from "../../src/storage/db" @@ -66,7 +70,7 @@ type TestHandler = ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect -const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( +const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe( Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]), ) @@ -203,16 +207,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => return HttpServerResponse.empty() }) -const serveRouteContextProbe = HttpRouter.add( - "GET", - "/probe", - Effect.gen(function* () { - // The fake route exposes the context installed by the middleware, so tests - // can assert routing decisions without pulling in the production API tree. - const route = yield* WorkspaceRouteContext - return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) - }), -).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) +const ProbeResult = Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optional(Schema.String), +}) + +const ProbeApi = HttpApi.make("workspace-routing-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }), + HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.get("workspace", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, + success: ProbeResult, + }), + ) + .middleware(WorkspaceRoutingMiddleware), +) + +const routeContextResponse = Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return { directory: route.directory, workspaceID: route.workspaceID } +}) + +const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("get", () => routeContextResponse) + .handle("patch", () => Effect.succeed(false)) + .handle("session", () => routeContextResponse) + .handle("workspace", () => routeContextResponse), +) + +const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(workspaceRoutingTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), + HttpRouter.serve, + Layer.build, +) describe("HttpApi workspace routing middleware", () => { it.live("proxies remote workspace HTTP requests through the selected workspace target", () => @@ -250,11 +283,7 @@ describe("HttpApi workspace routing middleware", () => { // The local /probe handler should not run. Selecting a remote workspace // should make the middleware call HttpApiProxy.http instead. - yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( - Layer.provide(workspaceRoutingTestLayer), - HttpRouter.serve, - Layer.build, - ) + yield* serveProbe const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( HttpClientRequest.setHeaders({ @@ -325,9 +354,11 @@ describe("HttpApi workspace routing middleware", () => { startWorkspaceSyncing: () => Effect.die("unused"), }) - yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + yield* HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), Layer.provide(workspaceRoutingTestLayer), Layer.provide(Layer.succeed(Workspace.Service, workspace)), + Layer.provide(Layer.mock(Session.Service)({})), HttpRouter.serve, Layer.build, ) @@ -351,11 +382,7 @@ describe("HttpApi workspace routing middleware", () => { url: "http://127.0.0.1:1/base", }) - yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( - Layer.provide(workspaceRoutingTestLayer), - HttpRouter.serve, - Layer.build, - ) + yield* serveProbe const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) @@ -378,11 +405,7 @@ describe("HttpApi workspace routing middleware", () => { // The client connects to the local test server. The middleware should // detect the WebSocket upgrade and proxy it to the remote /base/probe. - yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( - Layer.provide(workspaceRoutingTestLayer), - HttpRouter.serve, - Layer.build, - ) + yield* serveProbe const socket = yield* Socket.makeWebSocket( `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`, @@ -406,11 +429,7 @@ describe("HttpApi workspace routing middleware", () => { const workspaceID = WorkspaceID.ascending("wrk_missing") // If the middleware resolves the workspace first, this handler is never // reached and the response should be the middleware error response. - yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( - Layer.provide(workspaceRoutingTestLayer), - HttpRouter.serve, - Layer.build, - ) + yield* serveProbe const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) @@ -433,14 +452,7 @@ describe("HttpApi workspace routing middleware", () => { // GET /session is a control-plane route: it lists sessions for the main // process and should not be redirected into the selected workspace target. - yield* HttpRouter.add( - "GET", - "/session", - 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) + yield* serveProbe const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`) @@ -463,14 +475,7 @@ describe("HttpApi workspace routing middleware", () => { // 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) + yield* serveProbe const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`) @@ -484,7 +489,7 @@ describe("HttpApi workspace routing middleware", () => { const dir = yield* tmpdirScoped() const queryDir = path.join(dir, "query-target") const headerDir = path.join(dir, "header-target") - yield* serveRouteContextProbe + yield* serveProbe // Without a selected workspace, the middleware falls back to request // directory hints before using the process cwd. @@ -495,9 +500,9 @@ describe("HttpApi workspace routing middleware", () => { ) expect(queryResponse.status).toBe(200) - expect(yield* queryResponse.json).toEqual({ directory: queryDir }) + expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null }) expect(headerResponse.status).toBe(200) - expect(yield* headerResponse.json).toEqual({ directory: headerDir }) + expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null }) }), ) @@ -513,7 +518,7 @@ describe("HttpApi workspace routing middleware", () => { directory: workspaceDir, }) - yield* serveRouteContextProbe + yield* serveProbe // /probe is not a control-plane route, so selecting a local workspace // should swap the route context to the workspace target directory. diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 2e10d325f6..e1c6e5701d 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" @@ -344,6 +345,7 @@ describe("workspace HttpApi", () => { proxied.push(request) const url = new URL(request.url) if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/event") return eventStreamResponse() if (url.pathname === "/base/sync/history") return Response.json([]) return new Response( JSON.stringify({ @@ -413,6 +415,18 @@ describe("workspace HttpApi", () => { ]) expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") + + const eventURL = new URL(`http://localhost${EventPaths.event}`) + eventURL.searchParams.set("workspace", workspace.id) + const eventResponse = yield* request(eventURL.toString(), dir) + expect(eventResponse.status).toBe(200) + expect(eventResponse.headers.get("content-type")).toContain("text/event-stream") + if (!eventResponse.body) throw new Error("missing proxied event response body") + const eventReader = eventResponse.body.getReader() + const event = yield* Effect.promise(() => eventReader.read()) + yield* Effect.promise(() => eventReader.cancel()) + expect(new TextDecoder().decode(event.value)).toContain("server.connected") + expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true) } finally { void remote.stop(true) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index cd17e70fdf..a6b327fab4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient { ptyID: string directory?: string workspace?: string + cursor?: string + ticket?: string }, options?: Options, ) { @@ -2644,6 +2646,8 @@ export class Pty extends HeyApiClient { { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "cursor" }, + { in: "query", key: "ticket" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 26b99e25e8..ebcb4271c4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8200,6 +8200,8 @@ export type PtyConnectData = { query?: { directory?: string workspace?: string + cursor?: string + ticket?: string } url: "/pty/{ptyID}/connect" }