Add gateway PTY RPC methods
This commit is contained in:
parent
da4459263d
commit
71b4fa04d9
@ -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
|
||||
|
||||
|
||||
@ -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
238
src/gateway/pty-manager.ts
Normal 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);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -90,6 +90,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...nodeHandlers,
|
||||
...nodePendingHandlers,
|
||||
...pushHandlers,
|
||||
...ptyHandlers,
|
||||
...sendHandlers,
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
|
||||
136
src/gateway/server-methods/pty.ts
Normal file
136
src/gateway/server-methods/pty.ts
Normal 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)));
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user