Add gateway PTY RPC methods

This commit is contained in:
Val Alexander 2026-03-14 08:26:53 -05:00
parent da4459263d
commit 71b4fa04d9
No known key found for this signature in database
6 changed files with 387 additions and 0 deletions

View File

@ -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

View File

@ -67,6 +67,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"voicewake.get",
"sessions.list",
"sessions.get",
"pty.list",
"sessions.preview",
"sessions.resolve",
"sessions.usage",

238
src/gateway/pty-manager.ts Normal file
View File

@ -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<string, string>;
},
) => PtySpawnHandle;
type PtyModule = {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
type ActiveSession = GatewayPtySession & {
pty: PtySpawnHandle;
outputDispose?: PtyDisposable | null;
exitDispose?: PtyDisposable | null;
};
const sessions = new Map<string, ActiveSession>();
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<string> {
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<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (typeof value === "string") out[key] = value;
}
return out;
}
async function loadSpawn(): Promise<PtySpawn> {
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<GatewayPtySession> {
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);
}

View File

@ -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",

View File

@ -90,6 +90,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...nodeHandlers,
...nodePendingHandlers,
...pushHandlers,
...ptyHandlers,
...sendHandlers,
...usageHandlers,
...agentHandlers,

View File

@ -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)));
}
},
};