From f285429952f1650f5d99048771ff7eeace940a1e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 14 Mar 2026 08:43:15 -0500 Subject: [PATCH] Harden remote PTY gateway sessions --- docs/start/setup.md | 7 + src/gateway/protocol/schema/error-codes.ts | 5 + src/gateway/pty-manager.test.ts | 132 ++++++++++ src/gateway/pty-manager.ts | 278 ++++++++++++++++++--- src/gateway/server-methods/pty.ts | 61 +++-- src/gateway/server/ws-connection.ts | 4 + 6 files changed, 427 insertions(+), 60 deletions(-) create mode 100644 src/gateway/pty-manager.test.ts diff --git a/docs/start/setup.md b/docs/start/setup.md index 205f14d20a5..26f21382645 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -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//sessions/` diff --git a/src/gateway/protocol/schema/error-codes.ts b/src/gateway/protocol/schema/error-codes.ts index 37e002a7993..31dbe4e72f9 100644 --- a/src/gateway/protocol/schema/error-codes.ts +++ b/src/gateway/protocol/schema/error-codes.ts @@ -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; diff --git a/src/gateway/pty-manager.test.ts b/src/gateway/pty-manager.test.ts new file mode 100644 index 00000000000..d4c309d860f --- /dev/null +++ b/src/gateway/pty-manager.test.ts @@ -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(); + }); +}); diff --git a/src/gateway/pty-manager.ts b/src/gateway/pty-manager.ts index b1722b94f78..ea659211be9 100644 --- a/src/gateway/pty-manager.ts +++ b/src/gateway/pty-manager.ts @@ -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(); +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(); +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 { 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 { const out: Record = {}; 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 { 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"); + 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 { + 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); } diff --git a/src/gateway/server-methods/pty.ts b/src/gateway/server-methods/pty.ts index 4a40d4f340c..4f6397015f9 100644 --- a/src/gateway/server-methods/pty.ts +++ b/src/gateway/server-methods/pty.ts @@ -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)); } }, }; diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 1a66cbdfe63..1592b285fef 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -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);