refactor(core): canonicalize pty service (#32182)
This commit is contained in:
parent
7efade2d53
commit
f2cf607376
30 changed files with 1132 additions and 504 deletions
|
|
@ -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)
|
||||
|
|
|
|||
34
packages/server/src/cors.ts
Normal file
34
packages/server/src/cors.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
) {}
|
||||
|
|
|
|||
144
packages/server/src/groups/pty.ts
Normal file
144
packages/server/src/groups/pty.ts
Normal 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)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
221
packages/server/src/handlers/pty.ts
Normal file
221
packages/server/src/handlers/pty.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue