Harden remote PTY gateway sessions
This commit is contained in:
parent
71b4fa04d9
commit
f285429952
@ -117,6 +117,13 @@ openclaw health
|
||||
### Common footguns
|
||||
|
||||
- **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port.
|
||||
- **Remote PTY limits:** the Gateway remote terminal now enforces env-configurable limits.
|
||||
- `OPENCLAW_PTY_MAX_SESSIONS_PER_OWNER` (default `4`)
|
||||
- `OPENCLAW_PTY_MAX_TOTAL_SESSIONS` (default `32`)
|
||||
- `OPENCLAW_PTY_MAX_INPUT_CHUNK_BYTES` (default `65536`)
|
||||
- `OPENCLAW_PTY_MIN_COLS` / `OPENCLAW_PTY_MAX_COLS` (defaults `20` / `500`)
|
||||
- `OPENCLAW_PTY_MIN_ROWS` / `OPENCLAW_PTY_MAX_ROWS` (defaults `5` / `200`)
|
||||
- `OPENCLAW_PTY_IDLE_TIMEOUT_MS` / `OPENCLAW_PTY_IDLE_SWEEP_INTERVAL_MS` (defaults `1800000` / `60000`)
|
||||
- **Where state lives:**
|
||||
- Credentials: `~/.openclaw/credentials/`
|
||||
- Sessions: `~/.openclaw/agents/<agentId>/sessions/`
|
||||
|
||||
@ -5,6 +5,11 @@ export const ErrorCodes = {
|
||||
NOT_PAIRED: "NOT_PAIRED",
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
INVALID_PARAMS: "INVALID_PARAMS",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
RESOURCE_LIMIT: "RESOURCE_LIMIT",
|
||||
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
|
||||
132
src/gateway/pty-manager.test.ts
Normal file
132
src/gateway/pty-manager.test.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnMock = vi.fn();
|
||||
|
||||
vi.mock("@lydell/node-pty", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
function makePtyHandle() {
|
||||
let dataListener: ((value: string) => void) | null = null;
|
||||
let exitListener: ((event: { exitCode: number }) => void) | null = null;
|
||||
return {
|
||||
pid: 123,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
onData: vi.fn((listener: (value: string) => void) => {
|
||||
dataListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
onExit: vi.fn((listener: (event: { exitCode: number }) => void) => {
|
||||
exitListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
emitData(value: string) {
|
||||
dataListener?.(value);
|
||||
},
|
||||
emitExit(code: number) {
|
||||
exitListener?.({ exitCode: code });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway pty manager", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENCLAW_PTY_MAX_SESSIONS_PER_OWNER;
|
||||
delete process.env.OPENCLAW_PTY_MAX_TOTAL_SESSIONS;
|
||||
delete process.env.OPENCLAW_PTY_MAX_INPUT_CHUNK_BYTES;
|
||||
delete process.env.OPENCLAW_PTY_MIN_COLS;
|
||||
delete process.env.OPENCLAW_PTY_MAX_COLS;
|
||||
delete process.env.OPENCLAW_PTY_MIN_ROWS;
|
||||
delete process.env.OPENCLAW_PTY_MAX_ROWS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const mod = await import("./pty-manager.js");
|
||||
for (const session of mod.listGatewayPtySessionsByOwner("device:one")) {
|
||||
mod.destroyGatewayPtySession(session.sessionId);
|
||||
}
|
||||
for (const session of mod.listGatewayPtySessionsByOwner("device:two")) {
|
||||
mod.destroyGatewayPtySession(session.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces per-owner and total session limits", async () => {
|
||||
process.env.OPENCLAW_PTY_MAX_SESSIONS_PER_OWNER = "1";
|
||||
process.env.OPENCLAW_PTY_MAX_TOTAL_SESSIONS = "2";
|
||||
spawnMock.mockImplementation(() => makePtyHandle());
|
||||
const mod = await import("./pty-manager.js");
|
||||
|
||||
await mod.createGatewayPtySession({
|
||||
owner: { ownerKey: "device:one", connId: "conn-1" },
|
||||
onOutput: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
});
|
||||
await mod.createGatewayPtySession({
|
||||
owner: { ownerKey: "device:two", connId: "conn-2" },
|
||||
onOutput: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.createGatewayPtySession({
|
||||
owner: { ownerKey: "device:one", connId: "conn-1" },
|
||||
onOutput: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "PTY_LIMIT_REACHED" });
|
||||
});
|
||||
|
||||
it("enforces resize and input limits and exposes lastActive metadata", async () => {
|
||||
process.env.OPENCLAW_PTY_MAX_INPUT_CHUNK_BYTES = "4";
|
||||
process.env.OPENCLAW_PTY_MIN_COLS = "10";
|
||||
process.env.OPENCLAW_PTY_MAX_COLS = "100";
|
||||
process.env.OPENCLAW_PTY_MIN_ROWS = "5";
|
||||
process.env.OPENCLAW_PTY_MAX_ROWS = "50";
|
||||
const handle = makePtyHandle();
|
||||
spawnMock.mockImplementation(() => handle);
|
||||
const mod = await import("./pty-manager.js");
|
||||
|
||||
const session = await mod.createGatewayPtySession({
|
||||
owner: { ownerKey: "device:one", connId: "conn-1" },
|
||||
onOutput: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mod.listGatewayPtySessionsByOwner("device:one")[0]).toMatchObject({
|
||||
sessionId: session.sessionId,
|
||||
createdAt: expect.any(Number),
|
||||
lastActive: expect.any(Number),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
expect(() => mod.writeGatewayPtySession(session.sessionId, "12345")).toThrowError(
|
||||
/exceeds 4 bytes/,
|
||||
);
|
||||
expect(() => mod.resizeGatewayPtySession(session.sessionId, 9, 6)).toThrowError(
|
||||
/cols must be between 10 and 100/,
|
||||
);
|
||||
expect(() => mod.resizeGatewayPtySession(session.sessionId, 20, 51)).toThrowError(
|
||||
/rows must be between 5 and 50/,
|
||||
);
|
||||
});
|
||||
|
||||
it("kills sessions bound to a disconnected connection", async () => {
|
||||
const handle = makePtyHandle();
|
||||
spawnMock.mockImplementation(() => handle);
|
||||
const mod = await import("./pty-manager.js");
|
||||
|
||||
const session = await mod.createGatewayPtySession({
|
||||
owner: { ownerKey: "device:one", connId: "conn-1" },
|
||||
onOutput: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mod.destroyGatewayPtySessionsForConn("conn-1")).toBe(1);
|
||||
expect(mod.getGatewayPtySession(session.sessionId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export type GatewayPtyOwner = {
|
||||
ownerKey: string;
|
||||
@ -16,6 +16,7 @@ export type GatewayPtySession = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
createdAt: number;
|
||||
lastActive: number;
|
||||
exitedAt?: number;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
@ -54,35 +55,135 @@ type ActiveSession = GatewayPtySession & {
|
||||
exitDispose?: PtyDisposable | null;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, ActiveSession>();
|
||||
export class GatewayPtyError extends Error {
|
||||
code:
|
||||
| "PTY_NOT_FOUND"
|
||||
| "PTY_ACCESS_DENIED"
|
||||
| "PTY_INVALID_ARGS"
|
||||
| "PTY_LIMIT_REACHED"
|
||||
| "PTY_INPUT_TOO_LARGE";
|
||||
|
||||
function sanitizeDim(value: unknown, fallback: number, max: number): number {
|
||||
constructor(
|
||||
code:
|
||||
| "PTY_NOT_FOUND"
|
||||
| "PTY_ACCESS_DENIED"
|
||||
| "PTY_INVALID_ARGS"
|
||||
| "PTY_LIMIT_REACHED"
|
||||
| "PTY_INPUT_TOO_LARGE",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GatewayPtyError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map<string, ActiveSession>();
|
||||
let idleSweepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function intFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function getPtyLimits() {
|
||||
const minCols = Math.max(1, intFromEnv("OPENCLAW_PTY_MIN_COLS", 20));
|
||||
const maxCols = Math.max(minCols, intFromEnv("OPENCLAW_PTY_MAX_COLS", 500));
|
||||
const minRows = Math.max(1, intFromEnv("OPENCLAW_PTY_MIN_ROWS", 5));
|
||||
const maxRows = Math.max(minRows, intFromEnv("OPENCLAW_PTY_MAX_ROWS", 200));
|
||||
return {
|
||||
minCols,
|
||||
maxCols,
|
||||
minRows,
|
||||
maxRows,
|
||||
maxSessionsPerOwner: Math.max(1, intFromEnv("OPENCLAW_PTY_MAX_SESSIONS_PER_OWNER", 4)),
|
||||
maxTotalSessions: Math.max(1, intFromEnv("OPENCLAW_PTY_MAX_TOTAL_SESSIONS", 32)),
|
||||
maxInputChunkBytes: Math.max(1, intFromEnv("OPENCLAW_PTY_MAX_INPUT_CHUNK_BYTES", 65536)),
|
||||
idleTimeoutMs: Math.max(0, intFromEnv("OPENCLAW_PTY_IDLE_TIMEOUT_MS", 30 * 60 * 1000)),
|
||||
idleSweepIntervalMs: Math.max(
|
||||
1000,
|
||||
intFromEnv("OPENCLAW_PTY_IDLE_SWEEP_INTERVAL_MS", 60 * 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeInitialDim(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
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)));
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new GatewayPtyError("PTY_INVALID_ARGS", `${label} must be a finite number`);
|
||||
}
|
||||
const next = Math.floor(n);
|
||||
if (next < min || next > max) {
|
||||
throw new GatewayPtyError("PTY_INVALID_ARGS", `${label} must be between ${min} and ${max}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function sanitizeResizeDim(
|
||||
value: unknown,
|
||||
current: number,
|
||||
min: number,
|
||||
max: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (value == null) {
|
||||
return current;
|
||||
}
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new GatewayPtyError("PTY_INVALID_ARGS", `${label} must be a finite number`);
|
||||
}
|
||||
const next = Math.floor(n);
|
||||
if (next < min || next > max) {
|
||||
throw new GatewayPtyError("PTY_INVALID_ARGS", `${label} must be between ${min} and ${max}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveDefaultShell(): string {
|
||||
const shell = (process.env.OPENCLAW_PTY_SHELL || process.env.SHELL || "").trim();
|
||||
if (shell) return shell;
|
||||
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)
|
||||
? 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;
|
||||
if (!requested?.trim()) {
|
||||
return defaultShell;
|
||||
}
|
||||
const candidate = requested.trim();
|
||||
const allowed = resolveAllowedShells(defaultShell);
|
||||
if (!allowed.has(candidate)) {
|
||||
throw new Error(`shell not allowed: ${candidate}`);
|
||||
throw new GatewayPtyError("PTY_INVALID_ARGS", `shell is not allowed: ${candidate}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@ -91,7 +192,9 @@ 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;
|
||||
if (!requested?.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
const expanded = requested.startsWith("~/") ? path.join(home, requested.slice(2)) : requested;
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
@ -99,7 +202,9 @@ function resolveCwd(requested?: string): string {
|
||||
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;
|
||||
if (typeof value === "string") {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -107,10 +212,77 @@ function toStringEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
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");
|
||||
if (!spawn) {
|
||||
throw new Error("PTY support is unavailable");
|
||||
}
|
||||
return spawn;
|
||||
}
|
||||
|
||||
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,
|
||||
lastActive: session.lastActive,
|
||||
exitedAt: session.exitedAt,
|
||||
exitCode: session.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
function markActive(session: ActiveSession): void {
|
||||
session.lastActive = Date.now();
|
||||
}
|
||||
|
||||
function ensureCapacity(ownerKey: string): void {
|
||||
const limits = getPtyLimits();
|
||||
if (sessions.size >= limits.maxTotalSessions) {
|
||||
throw new GatewayPtyError(
|
||||
"PTY_LIMIT_REACHED",
|
||||
`PTY session limit reached (${limits.maxTotalSessions} total)`,
|
||||
);
|
||||
}
|
||||
const ownerSessions = Array.from(sessions.values()).filter(
|
||||
(session) => session.owner.ownerKey === ownerKey,
|
||||
);
|
||||
if (ownerSessions.length >= limits.maxSessionsPerOwner) {
|
||||
throw new GatewayPtyError(
|
||||
"PTY_LIMIT_REACHED",
|
||||
`PTY session limit reached (${limits.maxSessionsPerOwner} per owner)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureIdleSweep(): void {
|
||||
const { idleTimeoutMs, idleSweepIntervalMs } = getPtyLimits();
|
||||
if (idleTimeoutMs <= 0) {
|
||||
if (idleSweepTimer) {
|
||||
clearInterval(idleSweepTimer);
|
||||
idleSweepTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (idleSweepTimer) {
|
||||
return;
|
||||
}
|
||||
idleSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const session of sessions.values()) {
|
||||
if (now - session.lastActive >= idleTimeoutMs) {
|
||||
destroyGatewayPtySession(session.sessionId);
|
||||
}
|
||||
}
|
||||
if (sessions.size === 0 && idleSweepTimer) {
|
||||
clearInterval(idleSweepTimer);
|
||||
idleSweepTimer = null;
|
||||
}
|
||||
}, idleSweepIntervalMs);
|
||||
idleSweepTimer.unref?.();
|
||||
}
|
||||
|
||||
export async function createGatewayPtySession(params: {
|
||||
owner: GatewayPtyOwner;
|
||||
cols?: number;
|
||||
@ -120,12 +292,15 @@ export async function createGatewayPtySession(params: {
|
||||
onOutput: (event: { sessionId: string; data: string; connId: string }) => void;
|
||||
onExit: (event: { sessionId: string; code: number | null; connId: string }) => void;
|
||||
}): Promise<GatewayPtySession> {
|
||||
ensureCapacity(params.owner.ownerKey);
|
||||
const spawn = await loadSpawn();
|
||||
const cols = sanitizeDim(params.cols, 80, 500);
|
||||
const rows = sanitizeDim(params.rows, 24, 200);
|
||||
const limits = getPtyLimits();
|
||||
const cols = sanitizeInitialDim(params.cols, 80, limits.minCols, limits.maxCols, "cols");
|
||||
const rows = sanitizeInitialDim(params.rows, 24, limits.minRows, limits.maxRows, "rows");
|
||||
const shell = resolveShell(params.shell);
|
||||
const cwd = resolveCwd(params.cwd);
|
||||
const sessionId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const pty = spawn(shell, [], {
|
||||
name: process.env.TERM || "xterm-256color",
|
||||
cols,
|
||||
@ -140,11 +315,13 @@ export async function createGatewayPtySession(params: {
|
||||
cwd,
|
||||
cols,
|
||||
rows,
|
||||
createdAt: Date.now(),
|
||||
createdAt: now,
|
||||
lastActive: now,
|
||||
pty,
|
||||
};
|
||||
session.outputDispose =
|
||||
pty.onData((data) => {
|
||||
markActive(session);
|
||||
params.onOutput({ sessionId, data, connId: session.owner.connId });
|
||||
}) ?? null;
|
||||
session.exitDispose =
|
||||
@ -158,23 +335,10 @@ export async function createGatewayPtySession(params: {
|
||||
}
|
||||
}) ?? null;
|
||||
sessions.set(sessionId, session);
|
||||
ensureIdleSweep();
|
||||
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)
|
||||
@ -188,29 +352,49 @@ export function getGatewayPtySession(sessionId: string): GatewayPtySession | und
|
||||
|
||||
export function touchGatewayPtySessionOwner(params: { sessionId: string; connId: string }): void {
|
||||
const session = sessions.get(params.sessionId);
|
||||
if (!session) return;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
session.owner.connId = params.connId;
|
||||
markActive(session);
|
||||
}
|
||||
|
||||
export function writeGatewayPtySession(sessionId: string, data: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new Error(`PTY session not found: ${sessionId}`);
|
||||
if (!session) {
|
||||
throw new GatewayPtyError("PTY_NOT_FOUND", `PTY session not found: ${sessionId}`);
|
||||
}
|
||||
const byteLength = Buffer.byteLength(data, "utf8");
|
||||
const { maxInputChunkBytes } = getPtyLimits();
|
||||
if (byteLength > maxInputChunkBytes) {
|
||||
throw new GatewayPtyError(
|
||||
"PTY_INPUT_TOO_LARGE",
|
||||
`PTY input exceeds ${maxInputChunkBytes} bytes`,
|
||||
);
|
||||
}
|
||||
markActive(session);
|
||||
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);
|
||||
if (!session) {
|
||||
throw new GatewayPtyError("PTY_NOT_FOUND", `PTY session not found: ${sessionId}`);
|
||||
}
|
||||
const limits = getPtyLimits();
|
||||
const nextCols = sanitizeResizeDim(cols, session.cols, limits.minCols, limits.maxCols, "cols");
|
||||
const nextRows = sanitizeResizeDim(rows, session.rows, limits.minRows, limits.maxRows, "rows");
|
||||
session.cols = nextCols;
|
||||
session.rows = nextRows;
|
||||
markActive(session);
|
||||
session.pty.resize?.(nextCols, nextRows);
|
||||
}
|
||||
|
||||
export function destroyGatewayPtySession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
try {
|
||||
session.outputDispose?.dispose();
|
||||
@ -221,6 +405,20 @@ export function destroyGatewayPtySession(sessionId: string): void {
|
||||
try {
|
||||
session.pty.kill("SIGKILL");
|
||||
} catch {}
|
||||
if (sessions.size === 0 && idleSweepTimer) {
|
||||
clearInterval(idleSweepTimer);
|
||||
idleSweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyGatewayPtySessionsForConn(connId: string): number {
|
||||
const sessionIds = Array.from(sessions.values())
|
||||
.filter((session) => session.owner.connId === connId)
|
||||
.map((session) => session.sessionId);
|
||||
for (const sessionId of sessionIds) {
|
||||
destroyGatewayPtySession(sessionId);
|
||||
}
|
||||
return sessionIds.length;
|
||||
}
|
||||
|
||||
export function assertGatewayPtyOwnership(params: {
|
||||
@ -229,10 +427,16 @@ export function assertGatewayPtyOwnership(params: {
|
||||
connId: string;
|
||||
}): GatewayPtySession {
|
||||
const session = sessions.get(params.sessionId);
|
||||
if (!session) throw new Error(`PTY session not found: ${params.sessionId}`);
|
||||
if (!session) {
|
||||
throw new GatewayPtyError("PTY_NOT_FOUND", `PTY session not found: ${params.sessionId}`);
|
||||
}
|
||||
if (session.owner.ownerKey !== params.ownerKey) {
|
||||
throw new Error(`PTY session access denied: ${params.sessionId}`);
|
||||
throw new GatewayPtyError(
|
||||
"PTY_ACCESS_DENIED",
|
||||
`PTY session does not belong to this gateway client: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
session.owner.connId = params.connId;
|
||||
markActive(session);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import {
|
||||
assertGatewayPtyOwnership,
|
||||
createGatewayPtySession,
|
||||
destroyGatewayPtySession,
|
||||
GatewayPtyError,
|
||||
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): {
|
||||
@ -16,7 +17,10 @@ function getPtyOwner(client: { connect?: { device?: { id?: string } }; connId?:
|
||||
} {
|
||||
const connId = client?.connId?.trim();
|
||||
if (!connId) {
|
||||
throw new Error("PTY requires an authenticated gateway connection");
|
||||
throw new GatewayPtyError(
|
||||
"PTY_INVALID_ARGS",
|
||||
"PTY requires an authenticated gateway connection",
|
||||
);
|
||||
}
|
||||
const deviceId = client?.connect?.device?.id?.trim() || undefined;
|
||||
return {
|
||||
@ -38,6 +42,25 @@ function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function mapPtyError(error: unknown) {
|
||||
if (error instanceof GatewayPtyError) {
|
||||
switch (error.code) {
|
||||
case "PTY_NOT_FOUND":
|
||||
return errorShape(ErrorCodes.NOT_FOUND, error.message);
|
||||
case "PTY_ACCESS_DENIED":
|
||||
return errorShape(ErrorCodes.FORBIDDEN, error.message);
|
||||
case "PTY_LIMIT_REACHED":
|
||||
return errorShape(ErrorCodes.RESOURCE_LIMIT, error.message);
|
||||
case "PTY_INPUT_TOO_LARGE":
|
||||
return errorShape(ErrorCodes.PAYLOAD_TOO_LARGE, error.message);
|
||||
case "PTY_INVALID_ARGS":
|
||||
default:
|
||||
return invalidParams(error.message);
|
||||
}
|
||||
}
|
||||
return invalidParams(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
export const ptyHandlers: GatewayRequestHandlers = {
|
||||
"pty.create": async ({ client, params, respond, context }) => {
|
||||
try {
|
||||
@ -57,7 +80,7 @@ export const ptyHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, { sessionId: session.sessionId, cwd: session.cwd, shell: session.shell });
|
||||
} catch (error) {
|
||||
respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error)));
|
||||
respond(false, undefined, mapPtyError(error));
|
||||
}
|
||||
},
|
||||
"pty.write": ({ client, params, respond }) => {
|
||||
@ -70,14 +93,14 @@ export const ptyHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
if (typeof data !== "string") {
|
||||
respond(false, undefined, invalidParams("pty.write requires data"));
|
||||
respond(false, undefined, invalidParams("pty.write requires string 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)));
|
||||
respond(false, undefined, mapPtyError(error));
|
||||
}
|
||||
},
|
||||
"pty.resize": ({ client, params, respond }) => {
|
||||
@ -92,7 +115,7 @@ export const ptyHandlers: GatewayRequestHandlers = {
|
||||
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)));
|
||||
respond(false, undefined, mapPtyError(error));
|
||||
}
|
||||
},
|
||||
"pty.kill": ({ client, params, respond }) => {
|
||||
@ -107,30 +130,22 @@ export const ptyHandlers: GatewayRequestHandlers = {
|
||||
destroyGatewayPtySession(sessionId);
|
||||
respond(true, { ok: true });
|
||||
} catch (error) {
|
||||
respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error)));
|
||||
respond(false, undefined, mapPtyError(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,
|
||||
};
|
||||
});
|
||||
const sessions = listGatewayPtySessionsByOwner(owner.ownerKey).map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
createdAt: session.createdAt,
|
||||
lastActive: session.lastActive,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
}));
|
||||
respond(true, { sessions });
|
||||
} catch (error) {
|
||||
respond(false, undefined, invalidParams(error instanceof Error ? error.message : String(error)));
|
||||
respond(false, undefined, mapPtyError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -9,6 +9,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
|
||||
import type { AuthRateLimiter } from "../auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||
import { isLoopbackAddress } from "../net.js";
|
||||
import { destroyGatewayPtySessionsForConn } from "../pty-manager.js";
|
||||
import { getHandshakeTimeoutMs } from "../server-constants.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
@ -242,6 +243,9 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
upsertPresence(client.presenceKey, { reason: "disconnect" });
|
||||
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
|
||||
}
|
||||
if (client?.connId) {
|
||||
destroyGatewayPtySessionsForConn(client.connId);
|
||||
}
|
||||
if (client?.connect?.role === "node") {
|
||||
const context = buildRequestContext();
|
||||
const nodeId = context.nodeRegistry.unregister(connId);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user