@evandu/super-pi 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The right rail's terminal, as a SERVER-SIDE SESSION MANAGER.
3
+ *
4
+ * ---------------------------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS AT ALL, stated plainly because it is the most consequential file in the project
6
+ * ---------------------------------------------------------------------------------------------
7
+ *
8
+ * Everything else in Super Pi is a control plane for a MODEL. This file hands a browser a SHELL on the
9
+ * machine Super Pi runs on. That is categorically different in kind, not in degree: a chat message can
10
+ * at worst make the model write a file inside a workspace, whereas one keystroke here runs whatever the
11
+ * user typed, with the full privileges of the server process, with no workspace containment and no
12
+ * capability scope.
13
+ *
14
+ * The server binds `0.0.0.0` by default (see `index.ts`), which exists so a phone on the same Wi-Fi can
15
+ * reach Super Pi without an SSH tunnel. Combined with a shell, that turns "anyone on the LAN who can
16
+ * open a web page" into "anyone on the LAN who can open a web page has a root-or-you shell". The
17
+ * project already knows this shape of problem - `http-server.ts` guards file writes and uploads behind
18
+ * `isLocalOrPrivateAddress` for exactly this reason - but a file write is bounded by the workspace root
19
+ * and this is not.
20
+ *
21
+ * So the guards here are deliberately redundant, and each one closes a hole the others do not:
22
+ *
23
+ * 1. OPT-IN, OFF BY DEFAULT. `SUPER_PI_TERMINAL` must be set to `1`/`true`/`on` before a single shell
24
+ * is spawned. The default install has no terminal at all - the tab is not even rendered. Enabling
25
+ * it is a deliberate act by someone who knows what it does.
26
+ * 2. NETWORK SCOPE. Even enabled, a command is refused unless the WebSocket arrived from loopback or
27
+ * an RFC 1918 / link-local / ULA address (`isLocalOrPrivateAddress`, the same predicate the file
28
+ * endpoints use). A request that arrives from a routed or proxied address is refused by name.
29
+ * 3. THE BROWSER NEVER CHOOSES THE WORKING DIRECTORY. `terminal.open` carries a WORKSPACE ID and
30
+ * nothing else; this file resolves the id against the store and spawns in that workspace's path.
31
+ * A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
32
+ * there is no code path on which a client-supplied path reaches `spawn`.
33
+ * 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells, one per client tab, capped so a loop of reconnects
34
+ * cannot accumulate processes.
35
+ *
36
+ * What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
37
+ * and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
38
+ * chosen to expose a shell to their own LAN, and from there every command is the operator's own.
39
+ *
40
+ * ---------------------------------------------------------------------------------------------
41
+ * WHY `node-pty` AND NOT `child_process.spawn`
42
+ * ---------------------------------------------------------------------------------------------
43
+ *
44
+ * `spawn("bash", { stdio: "pipe" })` gives a program three pipes and no terminal. Programs that ask
45
+ * whether stdout is a TTY take the "I am being piped" branch and behave differently: no colours, no
46
+ * progress bars, and `vim` / `top` / `less` refuse outright with "not a terminal". A terminal panel
47
+ * that cannot run those is a command runner wearing a terminal's clothes.
48
+ *
49
+ * `node-pty` allocates a real pseudo-terminal (ConPTY on Windows, `forkpty` elsewhere), which is the
50
+ * only way to get them. The cost is a native module, and it is paid only by operators who set
51
+ * `SUPER_PI_TERMINAL=1` - see `loadPty` below, which is reached lazily and never at import time.
52
+ */
53
+ import { createRequire } from "node:module";
54
+ import { existsSync } from "node:fs";
55
+ import { isLocalOrPrivateAddress } from "./http-server.js";
56
+ let ptyModule;
57
+ let ptyLoadFailure;
58
+ /**
59
+ * WHY THIS IS LAZY, AND WHY IT IS `createRequire` RATHER THAN `import`.
60
+ *
61
+ * `import("node-pty")` would be evaluated by the bundler/compiler as a real dynamic import of a package
62
+ * the project does not depend on by default, and its rejection is a load-time failure of the WHOLE
63
+ * server rather than of the terminal. The operator who never sets `SUPER_PI_TERMINAL` should not be
64
+ * able to tell that node-pty exists.
65
+ *
66
+ * `createRequire` sidesteps the module-resolution question entirely: this file is compiled to ESM by
67
+ * `tsconfig.server.json`, `node-pty` is CommonJS, and `require` is the one call that loads CJS from ESM
68
+ * without an interop wrapper. The result is cached in `ptyModule` so the native handle is resolved once.
69
+ *
70
+ * The failure is CACHED AS A STRING because a missing native module must produce the SAME refusal on
71
+ * every subsequent keystroke rather than a different native stack trace each time, and a missing
72
+ * terminal is a far better outcome than an unstable one.
73
+ */
74
+ function loadPty() {
75
+ if (ptyModule)
76
+ return ptyModule;
77
+ if (ptyLoadFailure)
78
+ return undefined;
79
+ try {
80
+ const require = createRequire(import.meta.url);
81
+ ptyModule = require("node-pty");
82
+ return ptyModule;
83
+ }
84
+ catch (error) {
85
+ ptyLoadFailure = error instanceof Error ? error.message : String(error);
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * The node-pty version this file is written against, and WHY IT IS NOT 1.1.0.
91
+ *
92
+ * node-pty 1.1.0's Windows `kill()` forks a helper process that calls `AttachConsole(shellPid)` in order
93
+ * to enumerate the console's process list so it can kill stragglers. When the console has already gone,
94
+ * `AttachConsole` fails, the helper throws an unhandled error, and - reproducibly, on Windows - the
95
+ * NODE PROCESS ITSELF dies with `STATUS_ENTRYPOINT_NOT_FOUND` (0xC0000139).
96
+ *
97
+ * That is not a recoverable bug in a server. A user clicking "close terminal" would take down every
98
+ * running conversation, every open WebSocket and the persistence handle with it. `scripts/
99
+ * terminal-pty.test.mjs` asserts the spawn/write/kill/respawn cycle end to end so a downgrade back to
100
+ * 1.1.0 fails a test instead of killing the server at runtime.
101
+ *
102
+ * 1.2.0-beta.15 replaces that kill path and survives it; the abnormal 0xC0000139 exit code it still
103
+ * reports for a KILLED shell is filtered in `onExit` below so it is never shown to a user as if it were
104
+ * a command's exit status.
105
+ */
106
+ const MINIMUM_PTY_VERSION = "1.2.0-beta.15";
107
+ /**
108
+ * WHICH SHELL, and why it is not `COMSPEC`.
109
+ *
110
+ * `process.env.COMSPEC` on Windows IS `cmd.exe`, which is what this function used to spawn - while the
111
+ * comment beside it claimed PowerShell. The mismatch was visible in practice: `cmd.exe` is a DOS-era
112
+ * console with a legacy code page, so a Chinese filename printed by `dir` arrives as mojibake and the
113
+ * prompt echoes the active code page. Super Pi's whole interface is Chinese, so a terminal that cannot
114
+ * print Chinese is not a working terminal for the people who would use it.
115
+ *
116
+ * PowerShell is therefore the default, chosen over `pwsh` by name because `powershell.exe` ships with
117
+ * every supported Windows version while `pwsh` is a separate install. `SUPER_PI_TERMINAL_SHELL` overrides
118
+ * it for anyone who wants `cmd.exe`, `pwsh`, or Git Bash.
119
+ *
120
+ * On Unix the user's own `$SHELL` is right and a login shell is requested, because the terminal should
121
+ * see the same PATH and aliases as the terminal they use elsewhere.
122
+ *
123
+ * The ARGS differ by family and are not an arbitrary detail: `-l` is a bash/sh login flag, and passing it
124
+ * to PowerShell would make the shell refuse to start.
125
+ */
126
+ function defaultShell(env = process.env) {
127
+ const override = env.SUPER_PI_TERMINAL_SHELL?.trim();
128
+ if (override) {
129
+ // Split on whitespace so an override can carry its own flags (`"C:\Program Files\Git\bin\bash.exe" -l`
130
+ // has both a spaced path and a flag). A quoted first token is honoured.
131
+ const parts = override.match(/"[^"]+"|\S+/g) ?? [];
132
+ const file = (parts.shift() ?? "").replace(/^"|"$/g, "");
133
+ if (file)
134
+ return { file, args: parts };
135
+ }
136
+ if (process.platform === "win32") {
137
+ return { file: env.COMSPEC?.toLowerCase().includes("pwsh") ? "pwsh.exe" : "powershell.exe", args: [] };
138
+ }
139
+ return { file: env.SHELL || "/bin/bash", args: ["-l"] };
140
+ }
141
+ // ---------------------------------------------------------------------------------------------
142
+ // Bounds
143
+ // ---------------------------------------------------------------------------------------------
144
+ /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
145
+ export const MAX_SESSIONS = 8;
146
+ /** Below the client panel's own minimum, and small enough that a `ls` cannot stall the socket. */
147
+ const MIN_COLS = 20;
148
+ const MIN_ROWS = 5;
149
+ const MAX_COLS = 500;
150
+ const MAX_ROWS = 200;
151
+ /**
152
+ * OUTPUT IS COALESCED, NOT SENT PER CHUNK.
153
+ *
154
+ * A `yes` or a fast build writes thousands of `onData` callbacks per second. Forwarding each one as its
155
+ * own WebSocket frame makes the browser the bottleneck and lets a single command saturate the server's
156
+ * event loop. Chunks are queued and flushed on a timer, with a byte ceiling per frame so one flush
157
+ * cannot build a megabyte-long string.
158
+ *
159
+ * The ceiling is applied to the QUEUE, not to the flush, because dropping a partial ANSI escape sequence
160
+ * would corrupt the stream: the split is forced at the START of the next flush rather than mid-escape.
161
+ */
162
+ const FLUSH_INTERVAL_MS = 16;
163
+ const MAX_CHUNK_BYTES = 64 * 1024;
164
+ /** Hard per-session backlog. Beyond this the session is considered runaway and is killed. */
165
+ const MAX_BACKLOG_BYTES = 4 * 1024 * 1024;
166
+ /**
167
+ * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
168
+ * typo in a deployment config fails closed rather than silently exposing a shell.
169
+ */
170
+ export function isTerminalEnabled(env = process.env) {
171
+ const raw = env.SUPER_PI_TERMINAL?.trim().toLowerCase();
172
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
173
+ }
174
+ /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
175
+ export function terminalDisabledReason(env = process.env) {
176
+ if (!isTerminalEnabled(env))
177
+ return "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启 Super Pi 即可使用。";
178
+ if (ptyLoadFailure)
179
+ return `node-pty 未能加载(${ptyLoadFailure})。请重新安装依赖,或确认 node-pty 版本不低于 ${MINIMUM_PTY_VERSION}。`;
180
+ return "";
181
+ }
182
+ export class TerminalService {
183
+ sessions = new Map();
184
+ workspaceFor;
185
+ /** Whether a command from this peer is allowed to touch a shell at all. */
186
+ isTrustedPeer;
187
+ constructor(options) {
188
+ this.workspaceFor = options.workspaceFor;
189
+ this.isTrustedPeer = options.isTrustedPeer;
190
+ this.enabled = options.enabled ?? isTerminalEnabled();
191
+ }
192
+ enabled;
193
+ /**
194
+ * WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
195
+ *
196
+ * The obvious implementation authorises `terminal.open` and then trusts the session. That leaves a
197
+ * live shell reachable by a socket opened BEFORE the address changed - and, on this server, the
198
+ * browser is same-origin on whatever host it was opened from, so a user who tunnels in, opens a
199
+ * terminal, and then forwards their port to a colleague has silently widened the audience for every
200
+ * keystroke that follows.
201
+ *
202
+ * `isTrustedPeer` is re-evaluated per command. It is a pure function of the socket's remote address,
203
+ * so the cost is one predicate per keystroke and the guarantee is that no session outlives its
204
+ * socket's authorisation.
205
+ */
206
+ assertUsable(remoteAddress, send) {
207
+ if (!this.enabled) {
208
+ send({ type: "terminal.status", enabled: false, reason: terminalDisabledReason() });
209
+ return false;
210
+ }
211
+ if (!loadPty()) {
212
+ send({ type: "terminal.status", enabled: false, reason: terminalDisabledReason() });
213
+ return false;
214
+ }
215
+ if (!isLocalOrPrivateAddress(remoteAddress)) {
216
+ // Named explicitly rather than folded into the disabled message: the operator who enabled the
217
+ // terminal and is reaching it from a non-private address needs to know which of the gates closed.
218
+ send({ type: "terminal.error", sessionId: "", message: "终端只接受来自本机或局域网地址的连接。" });
219
+ return false;
220
+ }
221
+ return true;
222
+ }
223
+ async handle(command, remoteAddress, send) {
224
+ if (!this.assertUsable(remoteAddress, send))
225
+ return;
226
+ if (command.type === "terminal.open") {
227
+ if (this.sessions.has(command.sessionId))
228
+ return;
229
+ if (this.sessions.size >= MAX_SESSIONS) {
230
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `终端会话已达上限(${MAX_SESSIONS} 个),请先关闭一个。` });
231
+ return;
232
+ }
233
+ // The workspace id is the ONLY thing the browser supplies, and it is resolved here. A client that
234
+ // sends a path instead gets nothing - there is no overload that accepts one.
235
+ const workspace = this.workspaceFor(command.workspaceId);
236
+ if (!workspace) {
237
+ send({ type: "terminal.error", sessionId: command.sessionId, message: "工作区不存在,无法打开终端。" });
238
+ return;
239
+ }
240
+ // A workspace whose directory was deleted or renamed must not spawn a shell in `process.cwd()`,
241
+ // which would silently run the terminal in the SERVER's directory instead of an error.
242
+ if (!existsSync(workspace.path)) {
243
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `工作区目录不存在:${workspace.path}` });
244
+ return;
245
+ }
246
+ const pty = loadPty();
247
+ const shell = defaultShell(process.env);
248
+ const child = pty.spawn(shell.file, shell.args, {
249
+ // `xterm-256color` is what makes a shell enable colour and line editing; the client understands
250
+ // the same terminfo name, so the two ends agree on what the escape sequences mean.
251
+ name: "xterm-256color",
252
+ cols: clamp(command.cols, MIN_COLS, MAX_COLS, 80),
253
+ rows: clamp(command.rows, MIN_ROWS, MAX_ROWS, 24),
254
+ cwd: workspace.path,
255
+ env: { ...process.env, TERM: "xterm-256color" },
256
+ });
257
+ const session = { pty: child, closed: false, killed: false, pending: "" };
258
+ this.sessions.set(command.sessionId, session);
259
+ child.onData((data) => this.enqueue(session, command.sessionId, data, send));
260
+ child.onExit(({ exitCode }) => {
261
+ this.flush(session, command.sessionId, send);
262
+ this.sessions.delete(command.sessionId);
263
+ // A killed shell on Windows reports 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND) because that is how
264
+ // ConPTY tears down, not because a command failed. Reporting it verbatim would tell the user a
265
+ // command exited with a status no program can produce, so a session WE killed reports 0.
266
+ if (session.closed && session.killed)
267
+ return;
268
+ session.closed = true;
269
+ if (session.timer)
270
+ clearTimeout(session.timer);
271
+ send({ type: "terminal.exit", sessionId: command.sessionId, exitCode: session.killed ? 0 : exitCode });
272
+ });
273
+ return;
274
+ }
275
+ const session = this.sessions.get(command.sessionId);
276
+ if (!session) {
277
+ send({ type: "terminal.error", sessionId: command.sessionId, message: "终端会话已结束,请重新打开。" });
278
+ return;
279
+ }
280
+ if (command.type === "terminal.input") {
281
+ // A keystroke is at most a few bytes; the cap exists to stop a client from using `terminal.input`
282
+ // as an unbounded write into a shell, not to limit typing.
283
+ if (command.data.length > 64 * 1024)
284
+ return;
285
+ session.pty.write(command.data);
286
+ return;
287
+ }
288
+ if (command.type === "terminal.resize") {
289
+ try {
290
+ session.pty.resize(clamp(command.cols, MIN_COLS, MAX_COLS, 80), clamp(command.rows, MIN_ROWS, MAX_ROWS, 24));
291
+ }
292
+ catch {
293
+ // A resize against a shell that exited between the lookup and here is not an error worth a
294
+ // message; the exit event is already on its way.
295
+ }
296
+ return;
297
+ }
298
+ // terminal.close
299
+ this.kill(command.sessionId, session, send);
300
+ }
301
+ enqueue(session, sessionId, data, send) {
302
+ session.pending += data;
303
+ // Flush when a chunk arrives while no timer is pending, so output appears at interactive latency
304
+ // rather than waiting for the next chunk that may never come.
305
+ if (session.timer)
306
+ return;
307
+ session.timer = setTimeout(() => this.flush(session, sessionId, send), FLUSH_INTERVAL_MS);
308
+ }
309
+ flush(session, sessionId, send) {
310
+ if (session.timer) {
311
+ clearTimeout(session.timer);
312
+ session.timer = undefined;
313
+ }
314
+ if (!session.pending)
315
+ return;
316
+ if (session.pending.length > MAX_BACKLOG_BYTES) {
317
+ this.kill(sessionId, session, send);
318
+ send({ type: "terminal.error", sessionId, message: "输出量过大,已停止该终端会话。" });
319
+ return;
320
+ }
321
+ let data = session.pending;
322
+ session.pending = "";
323
+ if (data.length > MAX_CHUNK_BYTES)
324
+ data = data.slice(0, MAX_CHUNK_BYTES);
325
+ send({ type: "terminal.output", sessionId, data });
326
+ }
327
+ kill(sessionId, session, send) {
328
+ this.flush(session, sessionId, send);
329
+ session.closed = true;
330
+ session.killed = true;
331
+ if (session.timer) {
332
+ clearTimeout(session.timer);
333
+ session.timer = undefined;
334
+ }
335
+ this.sessions.delete(sessionId);
336
+ try {
337
+ session.pty.kill();
338
+ }
339
+ catch {
340
+ // A shell that already exited makes `kill()` throw on some platforms. The session is removed
341
+ // either way, so there is nothing left to leak.
342
+ }
343
+ send({ type: "terminal.exit", sessionId, exitCode: 0 });
344
+ }
345
+ /** Every shell, for shutdown. Exits without reporting anything - the server is going away. */
346
+ closeAll() {
347
+ for (const [sessionId, session] of this.sessions) {
348
+ session.closed = true;
349
+ session.killed = true;
350
+ if (session.timer)
351
+ clearTimeout(session.timer);
352
+ try {
353
+ session.pty.kill();
354
+ }
355
+ catch {
356
+ /* already gone */
357
+ }
358
+ this.sessions.delete(sessionId);
359
+ }
360
+ }
361
+ }
362
+ function clamp(value, min, max, fallback) {
363
+ if (typeof value !== "number" || !Number.isFinite(value))
364
+ return fallback;
365
+ return Math.min(max, Math.max(min, Math.floor(value)));
366
+ }
@@ -624,7 +624,40 @@ export type ExtensionUiAnswer = {
624
624
  export type ClientCommand = ClientCommandBody & {
625
625
  operationRequestId?: string;
626
626
  };
627
- export type ClientCommandBody = {
627
+ export type ClientCommandBody =
628
+ /**
629
+ * THE TERMINAL, and the reason `terminal.open` CARRIES NO PATH.
630
+ *
631
+ * Every other command in this union that names a location lets the browser supply it. This one does
632
+ * not: it carries a workspace ID, and the server resolves that ID to a directory it already trusts
633
+ * (`terminal.ts`). A browser that asked for `C:\Windows` gets a refusal, because there is no field on
634
+ * this command for it to arrive in. That is the difference between a shell scoped to the workspaces
635
+ * the user already configured and a shell scoped to the whole disk, and it is structural rather than a
636
+ * check that a later edit could forget.
637
+ */
638
+ {
639
+ type: "terminal.open";
640
+ sessionId: string;
641
+ workspaceId: string;
642
+ cols?: number;
643
+ rows?: number;
644
+ }
645
+ /** Keystrokes and pasted text, as the RAW bytes a terminal would receive. */
646
+ | {
647
+ type: "terminal.input";
648
+ sessionId: string;
649
+ data: string;
650
+ }
651
+ /** The panel's measured character grid, so reflowed text reflows in the shell rather than wrapping twice. */
652
+ | {
653
+ type: "terminal.resize";
654
+ sessionId: string;
655
+ cols: number;
656
+ rows: number;
657
+ } | {
658
+ type: "terminal.close";
659
+ sessionId: string;
660
+ } | {
628
661
  type: "model.rules.set";
629
662
  rules: ModelFilterRule[];
630
663
  } | {
@@ -1006,4 +1039,38 @@ export type ServerEvent = {
1006
1039
  | ({
1007
1040
  type: "pi.queue";
1008
1041
  conversationId: string;
1009
- } & PiQueueSnapshot);
1042
+ } & PiQueueSnapshot)
1043
+ /**
1044
+ * Bytes from a terminal shell, as ONE event per coalescing window rather than one per `onData` call.
1045
+ *
1046
+ * `data` is RAW, including escape sequences, and is deliberately NOT modelled as a buffer of lines:
1047
+ * the point of `node-pty` is that the shell knows it is attached to a TTY, so it emits colours,
1048
+ * cursor moves and redraws that a line buffer cannot represent. The client owns rendering them.
1049
+ *
1050
+ * A failed spawn is a separate `terminal.error` rather than a `terminal.exit`, because a shell that
1051
+ * refused to start is a configuration problem the user must read, and an exit code would let them
1052
+ * scroll past it.
1053
+ */
1054
+ | {
1055
+ type: "terminal.output";
1056
+ sessionId: string;
1057
+ data: string;
1058
+ }
1059
+ /** The shell ended. `exitCode` is 0 whenever the server killed the session - see `terminal.ts`. */
1060
+ | {
1061
+ type: "terminal.exit";
1062
+ sessionId: string;
1063
+ exitCode: number;
1064
+ }
1065
+ /** A refused command, with a sentence naming WHICH gate refused it. */
1066
+ | {
1067
+ type: "terminal.error";
1068
+ sessionId: string;
1069
+ message: string;
1070
+ }
1071
+ /** Whether the terminal exists at all, and if not, why. Sent on a refused command. */
1072
+ | {
1073
+ type: "terminal.status";
1074
+ enabled: boolean;
1075
+ reason?: string;
1076
+ };
@@ -26,6 +26,16 @@ export interface WsServerOptions {
26
26
  catalogService: CatalogService;
27
27
  accessToken?: string;
28
28
  rootDir?: string;
29
+ /**
30
+ * Terminal sessions this connection opened, so they can be killed when the socket goes away.
31
+ *
32
+ * An ORPHANED SHELL IS THE FAILURE MODE THIS EXISTS TO PREVENT. A terminal outlives its tab by
33
+ * default: close the browser tab, or lose the Wi-Fi, or hit refresh mid-build, and the PTY keeps
34
+ * running with nobody reading its output. On a laptop that is a battery drain; on a machine running
35
+ * a long `npm install` it is a process nobody can interrupt from the UI any more. Tracking the ids
36
+ * per connection makes "the socket closed" and "the shells it started are gone" the same event.
37
+ */
38
+ onSocketClose?: (terminalSessionIds: readonly string[]) => void;
29
39
  }
30
40
  /**
31
41
  * WebSocket transport manager handling upgrade, connection lifecycles, and command dispatch.
@@ -37,6 +47,7 @@ export declare class WsServer {
37
47
  private readonly catalogService;
38
48
  private readonly accessToken?;
39
49
  private readonly rootDir?;
50
+ private readonly onSocketClose?;
40
51
  constructor(options: WsServerOptions);
41
52
  private setupUpgrade;
42
53
  private setupConnectionHandling;
@@ -45,12 +45,14 @@ export class WsServer {
45
45
  catalogService;
46
46
  accessToken;
47
47
  rootDir;
48
+ onSocketClose;
48
49
  constructor(options) {
49
50
  this.broadcaster = options.broadcaster;
50
51
  this.commandHandler = options.commandHandler;
51
52
  this.catalogService = options.catalogService;
52
53
  this.accessToken = options.accessToken;
53
54
  this.rootDir = options.rootDir;
55
+ this.onSocketClose = options.onSocketClose;
54
56
  this.websocketServer = new WebSocketServer({ noServer: true });
55
57
  this.setupUpgrade(options.httpServer);
56
58
  this.setupConnectionHandling();
@@ -82,8 +84,24 @@ export class WsServer {
82
84
  });
83
85
  }
84
86
  setupConnectionHandling() {
85
- this.websocketServer.on("connection", (socket) => {
87
+ this.websocketServer.on("connection", (socket, request) => {
86
88
  this.broadcaster.register(socket);
89
+ /**
90
+ * The peer address, captured ONCE per connection rather than read per message.
91
+ *
92
+ * `socket._socket.remoteAddress` is the real TCP peer, but the terminal re-checks it on every
93
+ * command (`TerminalService.assertUsable`), so it has to be read where the underlying socket is
94
+ * still reachable. Reading it inside the message handler would reach for a private field on a
95
+ * `WebSocket` that may already be torn down, and the terminal is exactly the command family where
96
+ * a missing address must fail CLOSED rather than fall back to "allowed".
97
+ */
98
+ const remoteAddress = request.socket.remoteAddress;
99
+ /**
100
+ * Terminal session ids this socket has opened. Recorded on `terminal.open` rather than inferred
101
+ * from the service on close, because the service is shared across sockets and a close must kill
102
+ * only the shells belonging to the socket that went away.
103
+ */
104
+ const terminalSessionIds = new Set();
87
105
  socket.send(JSON.stringify({ type: "state", state: this.broadcaster.getStateSnapshot() }));
88
106
  if (this.rootDir) {
89
107
  socket.send(JSON.stringify({ type: "server.version", version: checkDiskVersion(this.rootDir) }));
@@ -112,16 +130,23 @@ export class WsServer {
112
130
  socket.on("close", () => {
113
131
  clearInterval(heartbeat);
114
132
  this.broadcaster.unregister(socket);
133
+ // Kill the shells this socket started BEFORE clearing the set, so a close that races a final
134
+ // `terminal.open` cannot leak one. The set is per-connection, so another tab's terminal survives.
135
+ if (terminalSessionIds.size)
136
+ this.onSocketClose?.([...terminalSessionIds]);
137
+ terminalSessionIds.clear();
115
138
  });
116
139
  socket.on("message", async (raw) => {
117
140
  let command;
118
141
  try {
119
142
  command = JSON.parse(raw.toString());
143
+ if (command.type === "terminal.open")
144
+ terminalSessionIds.add(command.sessionId);
120
145
  const entityId = await this.commandHandler.handleCommand(command, (event) => {
121
146
  if (socket.readyState === WebSocket.OPEN) {
122
147
  socket.send(JSON.stringify(event));
123
148
  }
124
- });
149
+ }, remoteAddress);
125
150
  if (command.operationRequestId)
126
151
  socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: true, entityId }));
127
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "check:logic": [
36
36
  "scripts/lan-access.test.mjs",
37
+ "scripts/terminal-pty.test.mjs",
37
38
  "scripts/persistence-repository.test.mjs",
38
39
  "scripts/sqlite-repository.test.mjs",
39
40
  "scripts/dispatch-ancestry.test.mjs",
@@ -105,6 +106,7 @@
105
106
  "scripts/msg-bubbles.test.mjs",
106
107
  "scripts/narrow-layout.test.mjs",
107
108
  "scripts/scrollbar-style.test.mjs",
109
+ "scripts/stream-tail-browser.test.mjs",
108
110
  "scripts/theme-contrast.test.mjs",
109
111
  "scripts/theme-switch-state.test.mjs",
110
112
  "scripts/settings-window-structure.test.mjs",
@@ -118,6 +120,9 @@
118
120
  "dependencies": {
119
121
  "ws": "latest"
120
122
  },
123
+ "optionalDependencies": {
124
+ "node-pty": "^1.2.0-beta.15"
125
+ },
121
126
  "devDependencies": {
122
127
  "@types/node": "latest",
123
128
  "@types/react": "latest",