From 7bc26dafae09d326a0f66d2b69b379bc19b3b26e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 22:56:14 -0400 Subject: [PATCH] feat(server): pty websocket auth tickets (#25660) --- packages/app/src/components/terminal.tsx | 25 +++- .../app/src/utils/terminal-websocket-url.ts | 9 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/pty/ticket.ts | 66 +++++++++ packages/opencode/src/server/cors.ts | 20 +++ packages/opencode/src/server/error.ts | 3 + packages/opencode/src/server/middleware.ts | 3 + .../routes/instance/httpapi/groups/pty.ts | 15 +- .../routes/instance/httpapi/handlers/pty.ts | 34 ++++- .../httpapi/middleware/authorization.ts | 11 +- .../server/routes/instance/httpapi/server.ts | 5 +- .../src/server/routes/instance/index.ts | 8 +- .../src/server/routes/instance/pty.ts | 86 ++++++++++-- packages/opencode/src/server/server.ts | 4 +- .../opencode/src/server/shared/pty-ticket.ts | 15 ++ packages/opencode/test/pty/ticket.test.ts | 59 ++++++++ .../test/server/httpapi-listen.test.ts | 131 +++++++++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 45 ++++++ 19 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/pty/ticket.ts create mode 100644 packages/opencode/src/server/shared/pty-ticket.ts create mode 100644 packages/opencode/test/pty/ticket.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d4212e32e9..7bcc02d62d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -479,6 +479,21 @@ export const Terminal = (props: TerminalProps) => { return false }) + const connectToken = async () => { + const result = await client.pty.connectToken( + { ptyID: id }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if ((result.response.status === 404 || result.response.status === 405) && password) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + const retry = (err: unknown) => { if (disposed) return if (reconn !== undefined) return @@ -498,16 +513,24 @@ export const Terminal = (props: TerminalProps) => { }, ms) } - const open = () => { + const open = async () => { if (disposed) return drop?.() + const ticket = await connectToken().catch((err) => { + fail(err) + return undefined + }) + if (once.value) return + if (disposed) return + const socket = new WebSocket( terminalWebSocketURL({ url, id, directory, cursor: seek, + ticket, sameOrigin, username, password, diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index c1c7abad4a..06facdc7d2 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -5,8 +5,9 @@ export function terminalWebSocketURL(input: { id: string directory: string cursor: number - sameOrigin: boolean - username: string + ticket?: string + sameOrigin?: boolean + username?: string password?: string authToken?: boolean }) { @@ -14,6 +15,10 @@ export function terminalWebSocketURL(input: { next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + next.searchParams.set("ticket", input.ticket) + return next + } if (input.password && (!input.sameOrigin || input.authToken)) next.searchParams.set( "auth_token", diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index e8c8025ea3..76ed26d302 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,6 +46,7 @@ import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" @@ -98,6 +99,7 @@ export const AppLayer = Layer.mergeAll( Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts new file mode 100644 index 0000000000..d40301cad2 --- /dev/null +++ b/packages/opencode/src/pty/ticket.ts @@ -0,0 +1,66 @@ +export * as PtyTicket from "./ticket" + +import { WorkspaceID } from "@/control-plane/schema" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { PtyID } from "@/pty/schema" +import { PositiveInt } from "@/util/schema" +import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" + +const DEFAULT_TTL = Duration.seconds(60) +const CAPACITY = 10_000 + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}) + +export type Scope = { + readonly ptyID: PtyID + readonly directory?: string + readonly workspaceID?: WorkspaceID +} + +export interface Interface { + issue(input: Scope): Effect.Effect + consume(input: Scope & { readonly ticket: string }): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PtyTicket") {} + +function matches(record: Scope, input: Scope) { + return record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID +} + +// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is +// never invoked; it dies if it ever is, which would signal a misuse of the Service interface. +const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") + +// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. +export const make = (ttl: Duration.Input = DEFAULT_TTL) => + Effect.gen(function* () { + const cache = yield* Cache.make({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl }) + const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl)))) + return Service.of({ + issue: Effect.fn("PtyTicket.issue")(function* (input) { + const ticket = crypto.randomUUID() + yield* Cache.set(cache, ticket, input) + return { ticket, expires_in: expiresIn } + }), + consume: Effect.fn("PtyTicket.consume")(function* (input) { + return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input)) + }), + }) + }) + +export const layer = Layer.effect(Service, make()) + +export const defaultLayer = layer + +export const scope = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + workspaceID, + } +}) diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 62a181af3a..92296a3b7d 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,7 +1,13 @@ +import { Context } from "effect" + const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ export type CorsOptions = { readonly cors?: ReadonlyArray } +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true @@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (opencodeOrigin.test(input)) return true return opts?.cors?.includes(input) ?? false } + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index 7c5861d919..506e798187 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -21,6 +21,9 @@ export const ERRORS = { }, }, }, + 403: { + description: "Forbidden", + }, 404: { description: "Not found", content: { diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index d2cc9b538d..898acaf089 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,7 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" const log = Log.create({ service: "server" }) @@ -44,6 +45,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) @@ -58,6 +60,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M const attributes = { method: c.req.method, path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. ...backendAttributes, } log.info("request", attributes) 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 d54bda4a84..3304ab9fbf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,4 +1,5 @@ import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -23,6 +24,7 @@ export const PtyPaths = { get: `${root}/:ptyID`, update: `${root}/:ptyID`, remove: `${root}/:ptyID`, + connectToken: `${root}/:ptyID/connect-token`, connect: `${root}/:ptyID/connect`, } as const @@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty") description: "Remove and terminate a specific pseudo-terminal (PTY) session.", }), ), + HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { + params: { ptyID: PtyID }, + success: described(PtyTicket.ConnectToken, "WebSocket connect token"), + error: [HttpApiError.Forbidden, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived ticket for opening a PTY WebSocket connection.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) @@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: described(Schema.Boolean, "Connected session"), - error: HttpApiError.NotFound, + error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connect", 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 2e2c4ee1cb..e5ff300a2a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,8 +1,15 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" 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 { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" @@ -11,9 +18,15 @@ import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" +function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { + return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) +} + export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig const shells = Effect.fn("PtyHttpApi.shells")(function* () { return yield* Effect.promise(() => Shell.list()) @@ -54,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler return true }) + const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) + return yield* new HttpApiError.Forbidden({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) + }) + return handlers .handle("shells", shells) .handle("list", list) @@ -61,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("get", get) .handle("update", update) .handle("remove", remove) + .handle("connectToken", connectToken) }), ) export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig yield* router.add( "GET", PtyPaths.connect, @@ -75,12 +99,20 @@ export const ptyConnectRoute = HttpRouter.use((router) => if (!(yield* pty.get(params.ptyID))) 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) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const socket = yield* Effect.orDie(request.upgrade) const write = yield* socket.writer const closeAccepted = (event: Socket.CloseEvent) => socket 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 2a8f1cf4d4..6c6d0cd1f1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -2,6 +2,7 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" +import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -55,7 +56,11 @@ function decodeCredential(input: string) { } function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + return credentialFromURL(new URL(request.url, "http://localhost"), request) +} + +function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) { + const token = url.searchParams.get(AUTH_TOKEN_QUERY) if (token) return decodeCredential(token) const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") if (match) return decodeCredential(match[1]) @@ -86,7 +91,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* credentialFromRequest(request).pipe( + const url = new URL(request.url, "http://localhost") + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 2944ced695..a3754c2e19 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -25,6 +25,7 @@ import { ProviderAuth } from "@/provider/auth" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -44,7 +45,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -163,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) { ProviderAuth.defaultLayer, Provider.defaultLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, @@ -187,6 +189,7 @@ export function createRoutes(corsOptions?: CorsOptions) { FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 3f9f3f6607..89b5641e58 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -39,10 +39,11 @@ import { SessionPaths } from "./httpapi/groups/session" import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() - const handler = ExperimentalHttpApiServer.webHandler().handler + const handler = ExperimentalHttpApiServer.webHandler(opts).handler const context = Context.empty() as Context.Context app.all("/api/*", (c) => handler(c.req.raw, context)) @@ -107,6 +108,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) @@ -158,7 +160,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { return app .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) + .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index bff0b71915..fb8d5e356d 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import type { Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect, Schema } from "effect" @@ -6,10 +7,19 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { Shell } from "@/shell/shell" import { NotFoundError } from "@/storage/storage" import { errors } from "../../error" import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { 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 { zod as effectZod } from "@/util/effect-zod" const ShellItem = z.object({ path: z.string(), @@ -18,7 +28,11 @@ const ShellItem = z.object({ }) const decodePtyID = Schema.decodeUnknownSync(PtyID) -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { return new Hono() .get( "/shells", @@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return true }), ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) .get( "/:ptyID/connect", describeRoute({ @@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }, - ...errors(404), + ...errors(403, 404), }, }), validator("param", z.object({ ptyID: PtyID.zod })), @@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { } const id = decodePtyID(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined if ( !(await runRequest( "PtyRoutes.connect", @@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), )) ) { - throw new Error("Session not found") + throw new NotFoundError({ message: "Session not found" }) } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) + } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined type Socket = { readyState: number diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6c7a6743db..3971214f3d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -120,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), runtime, } } @@ -136,7 +136,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .route("/", ControlPlaneRoutes()) .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) .route("/", UIRoutes()), runtime, } diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts new file mode 100644 index 0000000000..0efd06e6a7 --- /dev/null +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -0,0 +1,15 @@ +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ + +// Auth middleware skips Basic Auth when this matches; the PTY connect handler +// is then responsible for validating the ticket. +export function isPtyConnectPath(pathname: string) { + return PTY_CONNECT_PATH.test(pathname) +} + +export function hasPtyConnectTicketURL(url: URL) { + return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts new file mode 100644 index 0000000000..1b7d6005bf --- /dev/null +++ b/packages/opencode/test/pty/ticket.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { WorkspaceID } from "../../src/control-plane/schema" +import { PtyID } from "../../src/pty/schema" +import { PtyTicket } from "../../src/pty/ticket" +import { testEffect } from "../lib/effect" + +const it = testEffect(PtyTicket.layer) +const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) + +describe("PTY websocket tickets", () => { + it.live("consumes tickets once", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" } + const issued = yield* tickets.issue(scope) + + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true) + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different request", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) + + expect( + yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket }), + ).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) + }), + ) + + itExpiring.live("rejects tickets after the TTL elapses", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID }) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))) + + expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different workspace", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const workspaceID = WorkspaceID.ascending() + const issued = yield* tickets.issue({ ptyID, workspaceID }) + + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 3ee57dc108..af4c0a01ce 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -31,8 +31,8 @@ afterEach(async () => { await resetDatabase() }) -async function startListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,19 +40,53 @@ async function startListener() { return Server.listen({ hostname: "127.0.0.1", port: 0 }) } +async function startNoAuthListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = auth.username + delete process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + function authorization() { return `Basic ${btoa(`${auth.username}:${auth.password}`)}` } -function socketURL(listener: Awaited>, id: string, dir: string) { +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) url.protocol = "ws:" url.searchParams.set("directory", dir) url.searchParams.set("cursor", "-1") - url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`)) + if (ticket) url.searchParams.set("ticket", ticket) return url } +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + async function createCat(listener: Awaited>, dir: string) { const response = await fetch(new URL(PtyPaths.create, listener.url), { method: "POST", @@ -81,6 +115,28 @@ async function openSocket(url: URL) { return ws } +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + function stop(listener: Awaited>, label: string) { return withTimeout(listener.stop(true), 10_000, label) } @@ -125,7 +181,9 @@ describe("HttpApi Server.listen", () => { ) const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) const message = waitForMessage(ws, (message) => message.includes("ping-listen")) @@ -140,7 +198,8 @@ describe("HttpApi Server.listen", () => { const restarted = await startListener() try { const nextInfo = await createCat(restarted, tmp.path) - const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path)) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) nextWs.send("ping-restarted\n") expect(await nextMessage).toContain("ping-restarted") @@ -152,4 +211,64 @@ describe("HttpApi Server.listen", () => { if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) } }) + + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 74c5844626..e94132c2b2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -99,6 +99,8 @@ import type { ProviderOauthCallbackResponses, PtyConnectErrors, PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, PtyCreateErrors, PtyCreateResponses, PtyGetErrors, @@ -2345,6 +2347,38 @@ export class Pty extends HeyApiClient { }) } + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + /** * Connect to PTY session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 79ef42d9e1..86c5a762b1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1563,6 +1563,10 @@ export type McpUnsupportedOAuthError = { error: string } +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + export type ProviderAuthMethod = { type: "oauth" | "api" label: string @@ -4671,6 +4675,43 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect-token" +} + +export type PtyConnectTokenErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] + +export type PtyConnectTokenResponses = { + /** + * WebSocket connect token + */ + 200: { + ticket: string + expires_in: number + } +} + +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] + export type QuestionListData = { body?: never path?: never @@ -6652,6 +6693,10 @@ export type PtyConnectData = { } export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden /** * Not found */