refactor(core): canonicalize pty service (#32182)

This commit is contained in:
Shoubhit Dash 2026-06-14 16:16:39 +05:30 committed by GitHub
commit f2cf607376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1132 additions and 504 deletions

View file

@ -11,6 +11,7 @@ import { SkillGroup } from "./groups/skill"
import { EventGroup } from "./groups/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { PtyGroup } from "./groups/pty"
import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
@ -34,6 +35,7 @@ export const Api = HttpApi.make("server")
.add(CommandGroup)
.add(SkillGroup)
.add(EventGroup)
.add(PtyGroup)
.add(QuestionGroup)
.add(ReferenceGroup)
.add(ProjectCopyGroup)

View file

@ -0,0 +1,34 @@
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
if (input.startsWith("http://127.0.0.1:")) return true
if (input.startsWith("oc://renderer")) return true
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return true
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
}
}

View file

@ -84,3 +84,18 @@ export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFo
},
{ httpApiStatus: 404 },
) {}
export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
"ForbiddenError",
{ message: Schema.String },
{ httpApiStatus: 403 },
) {}
export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>()(
"PtyNotFoundError",
{
ptyID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}

View file

@ -0,0 +1,144 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ForbiddenError, PtyNotFoundError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
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 = /^\/api\/pty\/[^/]+\/connect$/
// Authorization middleware skips credential checks when this matches; the PTY connect handler
// is then responsible for consuming and validating the ticket.
export function hasPtyConnectTicketURL(url: URL) {
return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}
export const PtyGroup = HttpApiGroup.make("server.pty")
.add(
HttpApiEndpoint.get("pty.list", "/api/pty", {
query: LocationQuery,
success: Location.response(Schema.Array(Pty.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.list",
summary: "List PTY sessions",
description: "List PTY sessions for a location, including exited sessions retained until removal.",
}),
),
)
.add(
HttpApiEndpoint.post("pty.create", "/api/pty", {
query: LocationQuery,
payload: Pty.CreateInput,
success: Location.response(Pty.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.create",
summary: "Create PTY session",
description: "Create a pseudo-terminal session for a location.",
}),
),
)
.add(
HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
query: LocationQuery,
success: Location.response(Pty.Info),
error: PtyNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.get",
summary: "Get PTY session",
description: "Get one PTY session, including its exit code once exited.",
}),
),
)
.add(
HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
query: LocationQuery,
payload: Pty.UpdateInput,
success: Location.response(Pty.Info),
error: PtyNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.update",
summary: "Update PTY session",
description: "Update the title or viewport size of one PTY session.",
}),
),
)
.add(
HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: PtyNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.remove",
summary: "Remove PTY session",
description: "Terminate and remove one PTY session.",
}),
),
)
.add(
HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: PtyID },
query: LocationQuery,
success: Location.response(PtyTicket.ConnectToken),
error: [ForbiddenError, PtyNotFoundError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.connectToken",
summary: "Create PTY WebSocket token",
description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.",
}),
),
)
.add(
// Query fields are decoded in the raw handler after the existence check so a missing
// session responds with an empty 404 before any upgrade work.
HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", {
params: { ptyID: PtyID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.connect",
summary: "Connect to PTY session",
description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.",
transform: (operation) => ({
...operation,
parameters: [
...(operation.parameters ?? []),
...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
})),
],
}),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." }))
.middleware(LocationMiddleware)

View file

@ -1,6 +1,7 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Layer } from "effect"
import { layer as locationLayer } from "./groups/location"
import { sessionLocationLayer } from "./middleware/session-location"
@ -15,6 +16,7 @@ import { SkillHandler } from "./handlers/skill"
import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { PtyHandler } from "./handlers/pty"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
@ -39,6 +41,7 @@ export const handlers = Layer.mergeAll(
CommandHandler,
SkillHandler,
EventHandler,
PtyHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
@ -48,6 +51,7 @@ export const handlers = Layer.mergeAll(
Layer.provide(SessionV2.defaultLayer),
Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(PtyTicket.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(Credential.defaultLayer),
)

View file

@ -0,0 +1,221 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Api } from "../api"
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
import { ForbiddenError, PtyNotFoundError } from "../errors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "../groups/pty"
import { response } from "../groups/location"
const ticketScope = Effect.gen(function* () {
const location = yield* Location.Service
return { directory: location.directory as string, workspaceID: location.workspaceID }
})
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
return handlers
.handle(
"pty.list",
Effect.fn(function* () {
return yield* response((yield* Pty.Service).list())
}),
)
.handle(
"pty.create",
Effect.fn(function* (ctx) {
const pty = yield* Pty.Service
return yield* response(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
)
}),
)
.handle(
"pty.get",
Effect.fn(function* (ctx) {
const pty = yield* Pty.Service
return yield* response(
pty
.get(ctx.params.ptyID)
.pipe(
Effect.catchTag(
"Pty.NotFoundError",
() =>
new PtyNotFoundError({
ptyID: ctx.params.ptyID,
message: `PTY session not found: ${ctx.params.ptyID}`,
}),
),
),
)
}),
)
.handle(
"pty.update",
Effect.fn(function* (ctx) {
const pty = yield* Pty.Service
return yield* response(
pty
.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
.pipe(
Effect.catchTag(
"Pty.NotFoundError",
() =>
new PtyNotFoundError({
ptyID: ctx.params.ptyID,
message: `PTY session not found: ${ctx.params.ptyID}`,
}),
),
),
)
}),
)
.handle(
"pty.remove",
Effect.fn(function* (ctx) {
const pty = yield* Pty.Service
yield* pty
.remove(ctx.params.ptyID)
.pipe(
Effect.catchTag(
"Pty.NotFoundError",
() =>
new PtyNotFoundError({
ptyID: ctx.params.ptyID,
message: `PTY session not found: ${ctx.params.ptyID}`,
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"pty.connectToken",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
// The custom header forces a CORS preflight, so cross-origin browser pages cannot
// mint tickets without passing the server's origin policy.
if (
request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
)
return yield* new ForbiddenError({ message: "Invalid PTY connect token request" })
const pty = yield* Pty.Service
yield* pty
.get(ctx.params.ptyID)
.pipe(
Effect.catchTag(
"Pty.NotFoundError",
() =>
new PtyNotFoundError({
ptyID: ctx.params.ptyID,
message: `PTY session not found: ${ctx.params.ptyID}`,
}),
),
)
return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) }))
}),
)
.handleRaw(
"pty.connect",
Effect.fn("PtyHandler.connect")(function* (ctx) {
const pty = yield* Pty.Service
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 url = new URL(ctx.request.url, "http://localhost")
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = url.searchParams.get("cursor")
const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor)
const cursor =
cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1
? cursorNumber
: undefined
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
// Outbound frames flow through one queue drained by a single writer so replay, live
// output, and the close frame keep their order.
// TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route.
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
const attachment = yield* pty
.attach(ctx.params.ptyID, {
cursor,
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
})
.pipe(
Effect.catchTags({
"Pty.NotFoundError": () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
"Pty.ExitedError": () =>
closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)),
}),
)
if (!attachment) return HttpServerResponse.empty()
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
attachment.activate()
const drain = Effect.gen(function* () {
while (true) {
const item = yield* Queue.take(outbox)
yield* write(item)
if (item instanceof Socket.CloseEvent) return
}
})
yield* Effect.race(
drain,
socket.runRaw((message) => {
const decoded = PtyProtocol.decodeInput(message)
if (decoded !== undefined) attachment.write(decoded)
}),
).pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(Effect.sync(() => attachment.detach())),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
)

View file

@ -1,5 +1,6 @@
import { ServerAuth } from "../auth"
import { UnauthorizedError } from "../errors"
import { hasPtyConnectTicketURL } from "../groups/pty"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@ -45,6 +46,9 @@ export const authorizationLayer = Layer.effect(
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
// credential checks here; the connect handler consumes and validates the ticket.
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect
const credential = yield* credentialFromRequest(request)
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>