From 71b4fa04d9dac15f76d89b8b7661d42af491e1eb Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 14 Mar 2026 08:26:53 -0500 Subject: [PATCH] Add gateway PTY RPC methods --- docs/gateway/protocol.md | 6 + src/gateway/method-scopes.ts | 1 + src/gateway/pty-manager.ts | 238 +++++++++++++++++++++++++++++ src/gateway/server-methods-list.ts | 5 + src/gateway/server-methods.ts | 1 + src/gateway/server-methods/pty.ts | 136 +++++++++++++++++ 6 files changed, 387 insertions(+) create mode 100644 src/gateway/pty-manager.ts create mode 100644 src/gateway/server-methods/pty.ts diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 9c886a31716..5f5aaa9b9e0 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -145,6 +145,8 @@ Common scopes: - `operator.read` - `operator.write` + - includes PTY lifecycle methods: `pty.create`, `pty.write`, `pty.resize`, `pty.kill` + - `pty.list` is readable by `operator.read` / `operator.write` - `operator.admin` - `operator.approvals` - `operator.pairing` @@ -181,6 +183,10 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - `source`: `core` or `plugin` - `pluginId`: plugin owner when `source="plugin"` - `optional`: whether a plugin tool is optional +- PTY helpers: + - `pty.create`, `pty.write`, `pty.resize`, `pty.kill` require `operator.write` + - `pty.list` is available to `operator.read` / `operator.write` + - PTY events are targeted to the owning operator connection/device: `pty.output`, `pty.exit` ## Exec approvals diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f4f57259212..094d45ce00c 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -67,6 +67,7 @@ const METHOD_SCOPE_GROUPS: Record = { "voicewake.get", "sessions.list", "sessions.get", + "pty.list", "sessions.preview", "sessions.resolve", "sessions.usage", diff --git a/src/gateway/pty-manager.ts b/src/gateway/pty-manager.ts new file mode 100644 index 00000000000..b1722b94f78 --- /dev/null +++ b/src/gateway/pty-manager.ts @@ -0,0 +1,238 @@ +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; + +export type GatewayPtyOwner = { + ownerKey: string; + connId: string; + deviceId?: string; +}; + +export type GatewayPtySession = { + sessionId: string; + owner: GatewayPtyOwner; + shell: string; + cwd: string; + cols: number; + rows: number; + createdAt: number; + exitedAt?: number; + exitCode?: number | null; +}; + +type PtyExitEvent = { exitCode: number; signal?: number }; +type PtyDisposable = { dispose: () => void }; +type PtySpawnHandle = { + pid: number; + write: (data: string | Buffer) => void; + resize?: (cols: number, rows: number) => void; + onData: (listener: (value: string) => void) => PtyDisposable | void; + onExit: (listener: (event: PtyExitEvent) => void) => PtyDisposable | void; + kill: (signal?: string) => void; +}; + +type PtySpawn = ( + file: string, + args: string[] | string, + options: { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + }, +) => PtySpawnHandle; + +type PtyModule = { + spawn?: PtySpawn; + default?: { spawn?: PtySpawn }; +}; + +type ActiveSession = GatewayPtySession & { + pty: PtySpawnHandle; + outputDispose?: PtyDisposable | null; + exitDispose?: PtyDisposable | null; +}; + +const sessions = new Map(); + +function sanitizeDim(value: unknown, fallback: number, max: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.max(1, Math.min(max, Math.floor(n))); +} + +function resolveDefaultShell(): string { + const shell = (process.env.OPENCLAW_PTY_SHELL || process.env.SHELL || "").trim(); + if (shell) return shell; + return process.platform === "win32" ? "powershell.exe" : "/bin/zsh"; +} + +function resolveAllowedShells(defaultShell: string): Set { + const raw = (process.env.OPENCLAW_PTY_ALLOWED_SHELLS || "").trim(); + const values = raw + ? raw.split(",").map((v) => v.trim()).filter(Boolean) + : [defaultShell]; + return new Set(values); +} + +function resolveShell(requested?: string): string { + const defaultShell = resolveDefaultShell(); + if (!requested?.trim()) return defaultShell; + const candidate = requested.trim(); + const allowed = resolveAllowedShells(defaultShell); + if (!allowed.has(candidate)) { + throw new Error(`shell not allowed: ${candidate}`); + } + return candidate; +} + +function resolveCwd(requested?: string): string { + const base = process.env.OPENCLAW_PTY_CWD || process.cwd(); + const home = os.homedir(); + const fallback = path.resolve(base || home); + if (!requested?.trim()) return fallback; + const expanded = requested.startsWith("~/") ? path.join(home, requested.slice(2)) : requested; + return path.resolve(expanded); +} + +function toStringEnv(env: NodeJS.ProcessEnv): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") out[key] = value; + } + return out; +} + +async function loadSpawn(): Promise { + const mod = (await import("@lydell/node-pty")) as unknown as PtyModule; + const spawn = mod.spawn ?? mod.default?.spawn; + if (!spawn) throw new Error("PTY support is unavailable"); + return spawn; +} + +export async function createGatewayPtySession(params: { + owner: GatewayPtyOwner; + cols?: number; + rows?: number; + cwd?: string; + shell?: string; + onOutput: (event: { sessionId: string; data: string; connId: string }) => void; + onExit: (event: { sessionId: string; code: number | null; connId: string }) => void; +}): Promise { + const spawn = await loadSpawn(); + const cols = sanitizeDim(params.cols, 80, 500); + const rows = sanitizeDim(params.rows, 24, 200); + const shell = resolveShell(params.shell); + const cwd = resolveCwd(params.cwd); + const sessionId = crypto.randomUUID(); + const pty = spawn(shell, [], { + name: process.env.TERM || "xterm-256color", + cols, + rows, + cwd, + env: toStringEnv(process.env), + }); + const session: ActiveSession = { + sessionId, + owner: { ...params.owner }, + shell, + cwd, + cols, + rows, + createdAt: Date.now(), + pty, + }; + session.outputDispose = + pty.onData((data) => { + params.onOutput({ sessionId, data, connId: session.owner.connId }); + }) ?? null; + session.exitDispose = + pty.onExit((event) => { + session.exitedAt = Date.now(); + session.exitCode = event.exitCode ?? null; + try { + params.onExit({ sessionId, code: session.exitCode, connId: session.owner.connId }); + } finally { + destroyGatewayPtySession(sessionId); + } + }) ?? null; + sessions.set(sessionId, session); + return publicSession(session); +} + +function publicSession(session: ActiveSession): GatewayPtySession { + return { + sessionId: session.sessionId, + owner: { ...session.owner }, + shell: session.shell, + cwd: session.cwd, + cols: session.cols, + rows: session.rows, + createdAt: session.createdAt, + exitedAt: session.exitedAt, + exitCode: session.exitCode, + }; +} + +export function listGatewayPtySessionsByOwner(ownerKey: string): GatewayPtySession[] { + return Array.from(sessions.values()) + .filter((session) => session.owner.ownerKey === ownerKey) + .map(publicSession); +} + +export function getGatewayPtySession(sessionId: string): GatewayPtySession | undefined { + const session = sessions.get(sessionId); + return session ? publicSession(session) : undefined; +} + +export function touchGatewayPtySessionOwner(params: { sessionId: string; connId: string }): void { + const session = sessions.get(params.sessionId); + if (!session) return; + session.owner.connId = params.connId; +} + +export function writeGatewayPtySession(sessionId: string, data: string): void { + const session = sessions.get(sessionId); + if (!session) throw new Error(`PTY session not found: ${sessionId}`); + session.pty.write(data); +} + +export function resizeGatewayPtySession(sessionId: string, cols?: number, rows?: number): void { + const session = sessions.get(sessionId); + if (!session) throw new Error(`PTY session not found: ${sessionId}`); + const nextCols = sanitizeDim(cols, session.cols, 500); + const nextRows = sanitizeDim(rows, session.rows, 200); + session.cols = nextCols; + session.rows = nextRows; + session.pty.resize?.(nextCols, nextRows); +} + +export function destroyGatewayPtySession(sessionId: string): void { + const session = sessions.get(sessionId); + if (!session) return; + sessions.delete(sessionId); + try { + session.outputDispose?.dispose(); + } catch {} + try { + session.exitDispose?.dispose(); + } catch {} + try { + session.pty.kill("SIGKILL"); + } catch {} +} + +export function assertGatewayPtyOwnership(params: { + sessionId: string; + ownerKey: string; + connId: string; +}): GatewayPtySession { + const session = sessions.get(params.sessionId); + if (!session) throw new Error(`PTY session not found: ${params.sessionId}`); + if (session.owner.ownerKey !== params.ownerKey) { + throw new Error(`PTY session access denied: ${params.sessionId}`); + } + session.owner.connId = params.connId; + return publicSession(session); +} diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 205bb633e70..d3d1c7494bc 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -99,6 +99,11 @@ const BASE_METHODS = [ "agent.identity.get", "agent.wait", "browser.request", + "pty.create", + "pty.write", + "pty.resize", + "pty.kill", + "pty.list", // WebChat WebSocket-native chat methods "chat.history", "chat.abort", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f6f052f8cc2..48a3217ac76 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -90,6 +90,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...nodeHandlers, ...nodePendingHandlers, ...pushHandlers, + ...ptyHandlers, ...sendHandlers, ...usageHandlers, ...agentHandlers, diff --git a/src/gateway/server-methods/pty.ts b/src/gateway/server-methods/pty.ts new file mode 100644 index 00000000000..4a40d4f340c --- /dev/null +++ b/src/gateway/server-methods/pty.ts @@ -0,0 +1,136 @@ +import { + assertGatewayPtyOwnership, + createGatewayPtySession, + destroyGatewayPtySession, + listGatewayPtySessionsByOwner, + resizeGatewayPtySession, + writeGatewayPtySession, +} from "../pty-manager.js"; +import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function getPtyOwner(client: { connect?: { device?: { id?: string } }; connId?: string } | null): { + ownerKey: string; + connId: string; + deviceId?: string; +} { + const connId = client?.connId?.trim(); + if (!connId) { + throw new Error("PTY requires an authenticated gateway connection"); + } + const deviceId = client?.connect?.device?.id?.trim() || undefined; + return { + ownerKey: deviceId ? `device:${deviceId}` : `conn:${connId}`, + connId, + deviceId, + }; +} + +function invalidParams(message: string) { + return errorShape(ErrorCodes.INVALID_PARAMS, message); +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export const ptyHandlers: GatewayRequestHandlers = { + "pty.create": async ({ client, params, respond, context }) => { + try { + const owner = getPtyOwner(client); + const session = await createGatewayPtySession({ + owner, + cols: asNumber(params.cols), + rows: asNumber(params.rows), + cwd: asString(params.cwd), + shell: asString(params.shell), + onOutput: ({ sessionId, data, connId }) => { + context.broadcastToConnIds("pty.output", { sessionId, data }, new Set([connId])); + }, + onExit: ({ sessionId, code, connId }) => { + context.broadcastToConnIds("pty.exit", { sessionId, code }, new Set([connId])); + }, + }); + respond(true, { sessionId: session.sessionId, cwd: session.cwd, shell: session.shell }); + } catch (error) { + respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error))); + } + }, + "pty.write": ({ client, params, respond }) => { + try { + const owner = getPtyOwner(client); + const sessionId = asString(params.sessionId)?.trim(); + const data = asString(params.data); + if (!sessionId) { + respond(false, undefined, invalidParams("pty.write requires sessionId")); + return; + } + if (typeof data !== "string") { + respond(false, undefined, invalidParams("pty.write requires data")); + return; + } + assertGatewayPtyOwnership({ sessionId, ownerKey: owner.ownerKey, connId: owner.connId }); + writeGatewayPtySession(sessionId, data); + respond(true, { ok: true }); + } catch (error) { + respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error))); + } + }, + "pty.resize": ({ client, params, respond }) => { + try { + const owner = getPtyOwner(client); + const sessionId = asString(params.sessionId)?.trim(); + if (!sessionId) { + respond(false, undefined, invalidParams("pty.resize requires sessionId")); + return; + } + assertGatewayPtyOwnership({ sessionId, ownerKey: owner.ownerKey, connId: owner.connId }); + resizeGatewayPtySession(sessionId, asNumber(params.cols), asNumber(params.rows)); + respond(true, { ok: true }); + } catch (error) { + respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error))); + } + }, + "pty.kill": ({ client, params, respond }) => { + try { + const owner = getPtyOwner(client); + const sessionId = asString(params.sessionId)?.trim(); + if (!sessionId) { + respond(false, undefined, invalidParams("pty.kill requires sessionId")); + return; + } + assertGatewayPtyOwnership({ sessionId, ownerKey: owner.ownerKey, connId: owner.connId }); + destroyGatewayPtySession(sessionId); + respond(true, { ok: true }); + } catch (error) { + respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error))); + } + }, + "pty.list": ({ client, respond }) => { + try { + const owner = getPtyOwner(client); + const sessions = listGatewayPtySessionsByOwner(owner.ownerKey).map((session) => { + const current = assertGatewayPtyOwnership({ + sessionId: session.sessionId, + ownerKey: owner.ownerKey, + connId: owner.connId, + }); + return { + sessionId: current.sessionId, + shell: current.shell, + cwd: current.cwd, + cols: current.cols, + rows: current.rows, + createdAt: current.createdAt, + }; + }); + respond(true, { sessions }); + } catch (error) { + respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error))); + } + }, +};