refactor(server): unify instance httpapi middleware routing

This commit is contained in:
Kit Langton 2026-05-26 17:59:33 -04:00
parent 0ba1081cf1
commit fd11bd7bb4
16 changed files with 356 additions and 252 deletions

View file

@ -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 ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (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.

View file

@ -13,7 +13,7 @@ import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { PtyApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
@ -55,7 +55,6 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
export type RootHttpApiType = typeof RootHttpApi

View file

@ -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." })),
)

View file

@ -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,
@ -15,9 +16,10 @@ import { described } from "./metadata"
const root = "/pty"
export const Params = Schema.Struct({ ptyID: PtyID })
export const CursorQuery = Schema.Struct({
export const ConnectQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
cursor: Schema.optional(Schema.String),
[PTY_CONNECT_TICKET_QUERY]: Schema.optional(Schema.String),
})
export const ShellItem = Schema.Struct({
path: Schema.String,
@ -127,6 +129,28 @@ export const PtyApi = HttpApi.make("pty")
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: ConnectQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(PtyConnectAuthorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
@ -134,23 +158,3 @@ export const PtyApi = HttpApi.make("pty")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)

View file

@ -5,18 +5,14 @@ import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
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 { 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 { ConnectQuery } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
@ -121,37 +117,37 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
export const ptyConnectHandlers = HttpApiBuilder.group(InstanceHttpApi, "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 }
query: typeof ConnectQuery.Type
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)
if (ticket) {
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
if (ctx.query.ticket) {
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket: ctx.query.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 = ctx.query.cursor === undefined ? undefined : Number(ctx.query.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 +182,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 +190,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(

View file

@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@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* () {

View file

@ -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)
}),
)

View file

@ -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<E>(
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),
)
}),
)

View file

@ -59,7 +59,12 @@ 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 { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
@ -72,15 +77,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 +107,22 @@ 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.
// - instanceApiRoutes: typed instance routes, including the PTY websocket upgrade handler.
// - 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 instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@ -130,6 +133,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
mcpHandlers,
projectHandlers,
ptyHandlers,
ptyConnectHandlers,
questionHandlers,
permissionHandlers,
providerHandlers,
@ -141,12 +145,12 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]),
)
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([
httpApiAuthLayer,
ptyConnectHttpApiAuthLayer,
v2HttpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
workspaceRoutingLive,
instanceContextLayer,
schemaErrorLayer,
]),

View file

@ -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.

View file

@ -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),

View file

@ -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())

View file

@ -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<boolean, never, Scope.Scope>
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
}) =>
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<Capture>()
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<Capture>()
const withCapture = yield* Deferred.make<Capture>()
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}`)

View file

@ -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<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
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.

View file

@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient {
ptyID: string
directory?: string
workspace?: string
cursor?: string
ticket?: string
},
options?: Options<never, ThrowOnError>,
) {
@ -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" },
],
},
],

View file

@ -1717,6 +1717,10 @@ export type PtyForbiddenError = {
message: string
}
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type QuestionNotFoundError = {
_tag: "QuestionNotFoundError"
requestID: string
@ -1963,10 +1967,6 @@ export type WorkspaceWarpError = {
}
}
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type SyncEventMessageUpdated = {
type: "sync"
name: "message.updated.1"
@ -5715,6 +5715,46 @@ export type PtyConnectTokenResponses = {
export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses]
export type PtyConnectData = {
body?: never
path: {
ptyID: string
}
query?: {
directory?: string
workspace?: string
cursor?: string
ticket?: string
}
url: "/pty/{ptyID}/connect"
}
export type PtyConnectErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/
404: NotFoundError
}
export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]
export type PtyConnectResponses = {
/**
* Connected session
*/
200: boolean
}
export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]
export type QuestionListData = {
body?: never
path?: never
@ -8187,37 +8227,3 @@ export type ExperimentalWorkspaceWarpResponses = {
export type ExperimentalWorkspaceWarpResponse =
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
export type PtyConnectData = {
body?: never
path: {
ptyID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/pty/{ptyID}/connect"
}
export type PtyConnectErrors = {
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/
404: NotFoundError
}
export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]
export type PtyConnectResponses = {
/**
* Connected session
*/
200: boolean
}
export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]