Harden remote PTY gateway sessions

This commit is contained in:
Val Alexander 2026-03-14 08:43:15 -05:00
parent 71b4fa04d9
commit f285429952
No known key found for this signature in database
6 changed files with 427 additions and 60 deletions

View File

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

View File

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

View 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();
});
});

View File

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

View File

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

View File

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