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" }