diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 0f8b0c78b03..8c30bb75030 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -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", diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index e2ccca634c6..0ade5dfcfc2 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -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 = () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 8d842cd913d..48cb239bcfc 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -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 connection: { status: Accessor attempt: Accessor @@ -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, } diff --git a/packages/app/src/utils/terminal-connect-token.test.ts b/packages/app/src/utils/terminal-connect-token.test.ts deleted file mode 100644 index 4101bd03735..00000000000 --- a/packages/app/src/utils/terminal-connect-token.test.ts +++ /dev/null @@ -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 }) - }) -}) diff --git a/packages/app/src/utils/terminal-connect-token.ts b/packages/app/src/utils/terminal-connect-token.ts deleted file mode 100644 index aa23c204477..00000000000 --- a/packages/app/src/utils/terminal-connect-token.ts +++ /dev/null @@ -1,19 +0,0 @@ -export async function terminalConnectToken(input: { - url: string - id: string - directory: string - fetch?: (input: string | URL | Request, init?: RequestInit) => Promise -}) { - 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 } -} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts deleted file mode 100644 index 3c6e61a0f2a..00000000000 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts deleted file mode 100644 index bf2aef98b6d..00000000000 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ /dev/null @@ -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 -} diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index dbcc071fc2c..6af198b3999 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 = (input: Endpoint20_4Input) => Effect.Effect +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 = (input: Endpoint20_5Input) => Effect.Effect + export interface PtyApi { readonly list: PtyListOperation readonly create: PtyCreateOperation readonly get: PtyGetOperation readonly update: PtyUpdateOperation readonly remove: PtyRemoveOperation + readonly connect: { readonly token: PtyConnectTokenOperation } } export type Endpoint21_0Input = { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dccea27fa09..956e58e4f5a 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()( + 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) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3d5b02b21e0..e9a9e91ce6e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + 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) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3595110af77..50baeb8e0b9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 diff --git a/packages/client/src/solid/index.ts b/packages/client/src/solid/index.ts index 9fd41baf55a..4684e3b2e0b 100644 --- a/packages/client/src/solid/index.ts +++ b/packages/client/src/solid/index.ts @@ -1,2 +1,3 @@ export * from "./data" export * from "./connection" +export * from "./pty" diff --git a/packages/client/src/solid/pty.ts b/packages/client/src/solid/pty.ts new file mode 100644 index 00000000000..93b42482939 --- /dev/null +++ b/packages/client/src/solid/pty.ts @@ -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 + }, + } +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 46e17ca1949..af95ca4e626 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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"]) diff --git a/packages/client/test/solid-pty.test.ts b/packages/client/test/solid-pty.test.ts new file mode 100644 index 00000000000..d7d1e943f2a --- /dev/null +++ b/packages/client/test/solid-pty.test.ts @@ -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) + }) +}) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 50d3003a946..d17ccd1cac4 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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"]) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index a8fd1a3bc96..35db87dddfc 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -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], })