fix(client): authenticate PTY websocket connections (#43735)

Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-21 00:02:54 +00:00 committed by GitHub
parent 384cff3768
commit 749d24ebc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 185 additions and 125 deletions

View file

@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
})
})
await page.route("**/pty*", (route) =>
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",

View file

@ -15,9 +15,7 @@ import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalConnectToken } from "@/utils/terminal-connect-token"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@ -179,7 +177,6 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory
const url = serverSDK.url
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@ -531,14 +528,6 @@ export const Terminal = (props: TerminalProps) => {
})
}
const connectToken = async () => {
const result = await terminalConnectToken({ url, id, directory })
if (result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status }))
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@ -562,23 +551,21 @@ export const Terminal = (props: TerminalProps) => {
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,
const socket = await serverSDK.pty
.connect({
ptyID: id,
location: { directory },
cursor: seek,
ticket,
}),
)
socket.binaryType = "arraybuffer"
})
.catch((err) => {
fail(err)
return undefined
})
if (!socket || once.value) return
if (disposed) {
socket.close(1000)
return
}
ws = socket
const handleOpen = () => {

View file

@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@ -61,6 +61,7 @@ type ServerSDKBase = {
scope: ServerScope
url: string
api: ServerApi
pty: ReturnType<typeof createPtyClient>
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
@ -72,6 +73,7 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
@ -93,6 +95,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
pty,
connection,
event: events.event,
}

View file

@ -1,34 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalConnectToken } from "./terminal-connect-token"
describe("terminalConnectToken", () => {
test("requests a native V2 ticket scoped to the PTY location", async () => {
const calls: { url: URL; init?: RequestInit }[] = []
const result = await terminalConnectToken({
url: "https://example.test",
id: "pty_1",
directory: "/repo/worktree",
fetch: async (url, init) => {
calls.push({ url: new URL(url instanceof Request ? url.url : url), init })
return Response.json({ data: { ticket: "ticket-1", expires_in: 30 } })
},
})
expect(result).toEqual({ status: 200, ticket: "ticket-1" })
expect(calls[0]?.url.toString()).toBe(
"https://example.test/api/pty/pty_1/connect-token?location%5Bdirectory%5D=%2Frepo%2Fworktree",
)
expect(calls[0]?.init).toEqual({ method: "POST", headers: { "x-opencode-ticket": "1" } })
})
test("returns the response status when the ticket request fails", async () => {
const result = await terminalConnectToken({
url: "https://example.test",
id: "pty_1",
directory: "/repo",
fetch: async () => new Response(null, { status: 403 }),
})
expect(result).toEqual({ status: 403 })
})
})

View file

@ -1,19 +0,0 @@
export async function terminalConnectToken(input: {
url: string
id: string
directory: string
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>
}) {
const url = new URL(`${input.url}/api/pty/${input.id}/connect-token`)
url.searchParams.set("location[directory]", input.directory)
// TODO: Luke should check this for stupidity once special PTY endpoints have generated client support.
const response = await (input.fetch ?? fetch)(url, {
method: "POST",
headers: { "x-opencode-ticket": "1" },
})
if (!response.ok) return { status: response.status }
const result = (await response.json()) as { data?: { ticket?: string } }
return { status: response.status, ticket: result.data?.ticket }
}

View file

@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses the current ticketed PTY route", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/api/pty/pty_test/connect")
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
expect(url.searchParams.has("auth_token")).toBe(false)
})
})

View file

@ -1,17 +0,0 @@
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
}) {
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
next.searchParams.set("location[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
}
return next
}

View file

@ -31,6 +31,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
@ -1431,12 +1432,21 @@ export type Endpoint20_4Input = {
export type Endpoint20_4Output = void
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
export type Endpoint20_5Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly "x-opencode-ticket"?: string | undefined
}
export type Endpoint20_5Output = { readonly location: Location.Info; readonly data: PtyTicket.ConnectToken }
export type PtyConnectTokenOperation<E = never> = (input: Endpoint20_5Input) => Effect.Effect<Endpoint20_5Output, E>
export interface PtyApi<E = never> {
readonly list: PtyListOperation<E>
readonly create: PtyCreateOperation<E>
readonly get: PtyGetOperation<E>
readonly update: PtyUpdateOperation<E>
readonly remove: PtyRemoveOperation<E>
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type Endpoint21_0Input = {

View file

@ -186,6 +186,8 @@ import type {
Endpoint20_3Output,
Endpoint20_4Input,
Endpoint20_4Output,
Endpoint20_5Input,
Endpoint20_5Output,
Endpoint21_0Input,
Endpoint21_0Output,
Endpoint21_1Input,
@ -1093,12 +1095,22 @@ const Endpoint20_4 = (raw: RawClient["server.pty"]) => (input: Endpoint20_4Input
),
)
const Endpoint20_5 = (raw: RawClient["server.pty"]) => (input: Endpoint20_5Input) =>
preserveEffect<Endpoint20_5Output>()(
raw["pty.connectToken"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
list: Endpoint20_0(raw),
create: Endpoint20_1(raw),
get: Endpoint20_2(raw),
update: Endpoint20_3(raw),
remove: Endpoint20_4(raw),
connect: { token: Endpoint20_5(raw) },
})
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>

View file

@ -182,6 +182,8 @@ import type {
PtyUpdateOutput,
PtyRemoveInput,
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@ -1570,6 +1572,21 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
connect: {
token: (input: PtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<PtyConnectTokenOutput>(
{
method: "POST",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
query: { location: input["location"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
},
requestOptions,
),
},
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>

View file

@ -156,6 +156,8 @@ export type SessionStatus =
}
| { type: "busy" }
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
export type ReferenceGitSource = {
@ -2273,6 +2275,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
@ -5467,6 +5473,19 @@ export type PtyRemoveInput = {
export type PtyRemoveOutput = void
export type PtyConnectTokenInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
}
export type PtyConnectTokenOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PtyTicketConnectToken
}
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -1,2 +1,3 @@
export * from "./data"
export * from "./connection"
export * from "./pty"

View file

@ -0,0 +1,34 @@
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
export type PtyClientOptions = {
readonly url: string
readonly openSocket?: (url: URL) => WebSocket
}
export type PtyConnectInput = {
readonly ptyID: PtyConnectTokenInput["ptyID"]
readonly location?: PtyConnectTokenInput["location"]
readonly cursor?: number
}
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PtyConnectInput) {
const result = await api.pty.connect.token({
ptyID: input.ptyID,
location: input.location,
"x-opencode-ticket": "1",
})
const url = new URL(`/api/pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
if (input.location?.directory) url.searchParams.set("location[directory]", input.location.directory)
if (input.location?.workspace) url.searchParams.set("location[workspace]", input.location.workspace)
if (input.cursor !== undefined) url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("ticket", result.data.ticket)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
const socket = options.openSocket?.(url) ?? new WebSocket(url)
socket.binaryType = "arraybuffer"
return socket
},
}
}

View file

@ -47,7 +47,8 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "../src/promise"
import { createPtyClient } from "../src/solid"
describe("createPtyClient", () => {
test("mints an authenticated ticket before opening the terminal socket", async () => {
let request: Request | undefined
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({
location: {
directory: "/repo/worktree",
project: { id: "project_1", directory: "/repo", canonical: "/repo" },
},
data: { ticket: "ticket-1", expires_in: 60 },
})
},
})
const pty = createPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
},
})
expect(
await pty.connect({
ptyID: "pty_1",
location: { directory: "/repo/worktree", workspace: "workspace_1" },
cursor: 42,
}),
).toBe(socket)
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"https://server.example/api/pty/pty_1/connect-token?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=workspace_1",
)
expect(request?.headers.get("authorization")).toBe("Basic credential")
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
expect(socketURL?.toString()).toBe(
"wss://server.example/api/pty/pty_1/connect?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=workspace_1&cursor=42&ticket=ticket-1",
)
expect(socket.binaryType).toBe("arraybuffer")
})
test("does not open a socket when ticket minting fails", async () => {
let opened = false
const api = OpenCode.make({
baseUrl: "http://localhost:4096",
fetch: async () => new Response(null, { status: 401 }),
})
const pty = createPtyClient(api, {
url: "http://localhost:4096",
openSocket() {
opened = true
return { binaryType: "blob" } as unknown as WebSocket
},
})
await expect(pty.connect({ ptyID: "pty_1", location: { directory: "/repo" } })).rejects.toThrow()
expect(opened).toBe(false)
})
})

View file

@ -64,5 +64,5 @@ export const groupNames = {
"server.config": "config",
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
export const promiseOmitEndpoints = new Set(["pty.connect"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect"])

View file

@ -101,6 +101,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
query: LocationQuery,
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
success: Location.response(PtyTicket.ConnectToken),
error: [ForbiddenError, PtyNotFoundError],
})