fix(server): support desktop PTY websockets with HttpApi (#25598)

This commit is contained in:
Kit Langton
2026-05-03 14:23:29 -04:00
committed by GitHub
parent adb7cb1037
commit 387220f368
17 changed files with 564 additions and 436 deletions
@@ -2,12 +2,14 @@ import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
@@ -80,9 +82,22 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
: undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
const services = yield* Effect.context()
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),
)
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
if (!registered) {
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
return HttpServerResponse.empty()
}
const bridge = yield* EffectBridge.make()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
@@ -100,7 +115,10 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
if (!handler) {
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
return HttpServerResponse.empty()
}
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
@@ -1,23 +1,29 @@
import { ServerAuth } from "@/server/auth"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
// full handler, so a downstream failure can make the next auth alternative run
// and remap an authorized NotFound into Unauthorized.
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
},
},
) {}
function emptyCredential() {
return {
username: "",
password: Redacted.make(""),
}
}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
@@ -31,19 +37,14 @@ function validateCredential<A, E, R>(
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
Effect.match({
onFailure: () => emptyCredential,
onFailure: emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential
if (parts.length !== 2) return emptyCredential()
return {
username: parts[0],
password: Redacted.make(parts[1]),
@@ -53,6 +54,14 @@ function decodeCredential(input: string) {
)
}
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
const token = new URL(request.url, "http://localhost").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])
return Effect.succeed(emptyCredential())
}
function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
@@ -77,21 +86,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) {
return yield* decodeCredential(match[1]).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
if (token) {
return yield* decodeCredential(token).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
})
}),
)
@@ -100,12 +97,14 @@ export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
return Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential, config),
authToken: (effect, { credential }) =>
decodeCredential(Redacted.value(credential)).pipe(
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
),
})
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
}),
)
@@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WebSocketTracker } from "../websocket-tracker"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
return request.source instanceof Request ? request.source : undefined
@@ -28,6 +29,30 @@ export function websocket(
})
const writeInbound = yield* inbound.writer
const writeOutbound = yield* outbound.writer
const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect<void, unknown>) =>
socket
.runRaw(() => Effect.void, {
onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)),
})
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const closeAccepted = Effect.all(
[closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)],
{ concurrency: "unbounded", discard: true },
)
const registered = yield* WebSocketTracker.register(
Effect.all(
[writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT())],
{ concurrency: "unbounded", discard: true },
),
)
if (!registered) {
yield* closeAccepted
return HttpServerResponse.empty()
}
yield* outbound
.runRaw((message) => writeInbound(message))
@@ -0,0 +1,52 @@
import { Context, Effect, Layer, Option } from "effect"
import * as Socket from "effect/unstable/socket/Socket"
export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing")
type Close = Effect.Effect<void, unknown>
export interface Interface {
readonly add: (close: Close) => Effect.Effect<boolean>
readonly remove: (close: Close) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiWebSocketTracker") {}
export const layer = Layer.sync(Service)(() => {
const sockets = new Set<Close>()
let closing = false
return Service.of({
add: (close) =>
Effect.gen(function* () {
if (closing) return false
sockets.add(close)
return true
}),
remove: (close) =>
Effect.sync(() => {
sockets.delete(close)
}),
closeAll: Effect.gen(function* () {
closing = true
const active = Array.from(sockets)
sockets.clear()
yield* Effect.all(
active.map((close) => close.pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))),
{ concurrency: "unbounded", discard: true },
)
}),
})
})
export const register = (close: Close) =>
Effect.gen(function* () {
const tracker = yield* Effect.serviceOption(Service)
if (Option.isNone(tracker)) return true
const registered = yield* tracker.value.add(close)
if (!registered) return false
yield* Effect.addFinalizer(() => tracker.value.remove(close))
return true
})
export * as WebSocketTracker from "./websocket-tracker"