summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-05-03 22:56:14 -0400
committerGitHub <[email protected]>2026-05-03 22:56:14 -0400
commit7bc26dafae09d326a0f66d2b69b379bc19b3b26e (patch)
tree1684c2f53ff22edd05039bfb3a08bb2eb3daf652
parentce89bcb8e238401ea8fee000dc54539057d47dc4 (diff)
downloadopencode-7bc26dafae09d326a0f66d2b69b379bc19b3b26e.tar.gz
opencode-7bc26dafae09d326a0f66d2b69b379bc19b3b26e.zip
feat(server): pty websocket auth tickets (#25660)
-rw-r--r--packages/app/src/components/terminal.tsx25
-rw-r--r--packages/app/src/utils/terminal-websocket-url.ts9
-rw-r--r--packages/opencode/src/effect/app-runtime.ts2
-rw-r--r--packages/opencode/src/pty/ticket.ts66
-rw-r--r--packages/opencode/src/server/cors.ts20
-rw-r--r--packages/opencode/src/server/error.ts3
-rw-r--r--packages/opencode/src/server/middleware.ts3
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts15
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts34
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts11
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts5
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts8
-rw-r--r--packages/opencode/src/server/routes/instance/pty.ts86
-rw-r--r--packages/opencode/src/server/server.ts4
-rw-r--r--packages/opencode/src/server/shared/pty-ticket.ts15
-rw-r--r--packages/opencode/test/pty/ticket.test.ts59
-rw-r--r--packages/opencode/test/server/httpapi-listen.test.ts131
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts34
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts45
19 files changed, 545 insertions, 30 deletions
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index d4212e32e..7bcc02d62 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 c1c7abad4..06facdc7d 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 e8c8025ea..76ed26d30 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 000000000..d40301cad
--- /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<typeof ConnectToken.Type>
+ consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<string, Scope>({ 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 62a181af3..92296a3b7 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<string> }
+export const CorsConfig = Context.Reference<CorsOptions | undefined>("@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 7c5861d91..506e79818 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 d2cc9b538..898acaf08 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 d54bda4a8..3304ab9fb 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 2e2c4ee1c..e5ff300a2 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 2a8f1cf4d..6c6d0cd1f 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 2944ced69..a3754c2e1 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 3f9f3f660..89b5641e5 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<unknown>
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 bff0b7191..fb8d5e356 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 6c7a6743d..3971214f3 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 000000000..0efd06e6a
--- /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 000000000..1b7d6005b
--- /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 3ee57dc10..af4c0a01c 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<ReturnType<typeof startListener>>, id: string, dir: string) {
+function socketURL(listener: Awaited<ReturnType<typeof startListener>>, 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<ReturnType<typeof startListener>>,
+ 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<ReturnType<typeof startListener>>, 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<ReturnType<typeof startListener>>, 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<string, string> }) {
+ // 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<string, string> }) => WebSocket
+ const ws = new Ctor(url, init)
+ await withTimeout(
+ new Promise<void>((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<ReturnType<typeof startListener>>, 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<void>((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 74c584462..e94132c2b 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,
@@ -2346,6 +2348,38 @@ export class Pty extends HeyApiClient {
}
/**
+ * Create PTY WebSocket token
+ *
+ * Create a short-lived ticket for opening a PTY WebSocket connection.
+ */
+ public connectToken<ThrowOnError extends boolean = false>(
+ parameters: {
+ ptyID: string
+ directory?: string
+ workspace?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "ptyID" },
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<PtyConnectTokenResponses, PtyConnectTokenErrors, ThrowOnError>({
+ url: "/pty/{ptyID}/connect-token",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
* Connect to PTY session
*
* Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 79ef42d9e..86c5a762b 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
@@ -6653,6 +6694,10 @@ export type PtyConnectData = {
export type PtyConnectErrors = {
/**
+ * Forbidden
+ */
+ 403: EffectHttpApiErrorForbidden
+ /**
* Not found
*/
404: NotFoundError