@evandu/super-pi 0.1.21 → 0.1.23
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.
- package/README.md +58 -1
- package/agents/super-pi-memory/index.ts +34 -0
- package/dist/client/assets/index-BKKZnCis.css +1 -0
- package/dist/client/assets/index-LWO_0omC.js +50 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/favicon.svg +10 -0
- package/dist/client/index.html +5 -2
- package/dist/server/active-memory.d.ts +118 -0
- package/dist/server/active-memory.js +214 -0
- package/dist/server/application/conversation-service.js +1 -0
- package/dist/server/application/run-service.d.ts +12 -0
- package/dist/server/application/run-service.js +48 -4
- package/dist/server/command-handler.d.ts +16 -2
- package/dist/server/command-handler.js +24 -2
- package/dist/server/files.d.ts +1 -0
- package/dist/server/files.js +77 -46
- package/dist/server/http-server.d.ts +17 -1
- package/dist/server/http-server.js +185 -20
- package/dist/server/index.js +97 -29
- package/dist/server/memory.d.ts +25 -0
- package/dist/server/memory.js +88 -0
- package/dist/server/persistence/app-state-repository.d.ts +27 -0
- package/dist/server/persistence/app-state-repository.js +1 -0
- package/dist/server/persistence/json-state-repository.d.ts +12 -0
- package/dist/server/persistence/json-state-repository.js +24 -0
- package/dist/server/persistence/open-state-store.d.ts +3 -0
- package/dist/server/persistence/open-state-store.js +35 -0
- package/dist/server/persistence/rename.d.ts +22 -0
- package/dist/server/persistence/rename.js +44 -0
- package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
- package/dist/server/persistence/sqlite-state-repository.js +114 -0
- package/dist/server/runtime.d.ts +7 -1
- package/dist/server/runtime.js +23 -10
- package/dist/server/server-context.d.ts +12 -0
- package/dist/server/store.d.ts +12 -3
- package/dist/server/store.js +55 -82
- package/dist/server/subagent-runs.d.ts +2 -0
- package/dist/server/subagent-runs.js +6 -0
- package/dist/server/subagents.d.ts +2 -0
- package/dist/server/subagents.js +11 -3
- package/dist/server/terminal.d.ts +139 -0
- package/dist/server/terminal.js +366 -0
- package/dist/server/types.d.ts +69 -2
- package/dist/server/workflow-engine.d.ts +2 -0
- package/dist/server/workflow-engine.js +6 -0
- package/dist/server/ws-server.d.ts +11 -0
- package/dist/server/ws-server.js +42 -2
- package/package.json +2 -129
- package/dist/client/assets/index-B43FWHQ6.js +0 -43
- package/dist/client/assets/index-BY__2nV9.css +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
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 type { ServerEvent, Workspace } from "./types.js";
|
|
54
|
+
/** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
|
|
55
|
+
export declare const MAX_SESSIONS = 8;
|
|
56
|
+
/**
|
|
57
|
+
* The terminal's wire protocol, as members of the EXISTING `ClientCommand` / `ServerEvent` unions rather
|
|
58
|
+
* than a second transport.
|
|
59
|
+
*
|
|
60
|
+
* Reusing the command channel is what makes this feature cheap to wire: the WebSocket already carries
|
|
61
|
+
* authentication, and `ws-server.ts` already answers exactly one socket. A parallel HTTP endpoint with
|
|
62
|
+
* server-sent events would have had to re-implement both, and the terminal's traffic is bidirectional -
|
|
63
|
+
* keystrokes have to flow back the same way output does.
|
|
64
|
+
*/
|
|
65
|
+
export type TerminalCommand = {
|
|
66
|
+
type: "terminal.open";
|
|
67
|
+
sessionId: string;
|
|
68
|
+
workspaceId: string;
|
|
69
|
+
cols?: number;
|
|
70
|
+
rows?: number;
|
|
71
|
+
} | {
|
|
72
|
+
type: "terminal.input";
|
|
73
|
+
sessionId: string;
|
|
74
|
+
data: string;
|
|
75
|
+
} | {
|
|
76
|
+
type: "terminal.resize";
|
|
77
|
+
sessionId: string;
|
|
78
|
+
cols: number;
|
|
79
|
+
rows: number;
|
|
80
|
+
} | {
|
|
81
|
+
type: "terminal.close";
|
|
82
|
+
sessionId: string;
|
|
83
|
+
};
|
|
84
|
+
export type TerminalEvent = {
|
|
85
|
+
type: "terminal.output";
|
|
86
|
+
sessionId: string;
|
|
87
|
+
data: string;
|
|
88
|
+
} | {
|
|
89
|
+
type: "terminal.exit";
|
|
90
|
+
sessionId: string;
|
|
91
|
+
exitCode: number;
|
|
92
|
+
} | {
|
|
93
|
+
type: "terminal.error";
|
|
94
|
+
sessionId: string;
|
|
95
|
+
message: string;
|
|
96
|
+
} | {
|
|
97
|
+
type: "terminal.status";
|
|
98
|
+
enabled: boolean;
|
|
99
|
+
reason?: string;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
|
|
103
|
+
* typo in a deployment config fails closed rather than silently exposing a shell.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isTerminalEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
106
|
+
/** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
|
|
107
|
+
export declare function terminalDisabledReason(env?: NodeJS.ProcessEnv): string;
|
|
108
|
+
export declare class TerminalService {
|
|
109
|
+
private readonly sessions;
|
|
110
|
+
private readonly workspaceFor;
|
|
111
|
+
/** Whether a command from this peer is allowed to touch a shell at all. */
|
|
112
|
+
private readonly isTrustedPeer;
|
|
113
|
+
constructor(options: {
|
|
114
|
+
workspaceFor: (workspaceId: string) => Workspace | undefined;
|
|
115
|
+
isTrustedPeer: (remoteAddress: string | undefined) => boolean;
|
|
116
|
+
enabled?: boolean;
|
|
117
|
+
});
|
|
118
|
+
readonly enabled: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
|
|
121
|
+
*
|
|
122
|
+
* The obvious implementation authorises `terminal.open` and then trusts the session. That leaves a
|
|
123
|
+
* live shell reachable by a socket opened BEFORE the address changed - and, on this server, the
|
|
124
|
+
* browser is same-origin on whatever host it was opened from, so a user who tunnels in, opens a
|
|
125
|
+
* terminal, and then forwards their port to a colleague has silently widened the audience for every
|
|
126
|
+
* keystroke that follows.
|
|
127
|
+
*
|
|
128
|
+
* `isTrustedPeer` is re-evaluated per command. It is a pure function of the socket's remote address,
|
|
129
|
+
* so the cost is one predicate per keystroke and the guarantee is that no session outlives its
|
|
130
|
+
* socket's authorisation.
|
|
131
|
+
*/
|
|
132
|
+
private assertUsable;
|
|
133
|
+
handle(command: TerminalCommand, remoteAddress: string | undefined, send: (event: ServerEvent) => void): Promise<void>;
|
|
134
|
+
private enqueue;
|
|
135
|
+
private flush;
|
|
136
|
+
private kill;
|
|
137
|
+
/** Every shell, for shutdown. Exits without reporting anything - the server is going away. */
|
|
138
|
+
closeAll(): void;
|
|
139
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -77,6 +77,8 @@ export declare class WorkflowEngine {
|
|
|
77
77
|
stopConversation(conversationId: string): number;
|
|
78
78
|
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
79
79
|
abortAll(): void;
|
|
80
|
+
/** Waits until aborted plans have committed their terminal state. */
|
|
81
|
+
waitForIdle(): Promise<void>;
|
|
80
82
|
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
81
83
|
private conversationOf;
|
|
82
84
|
/**
|
|
@@ -50,6 +50,12 @@ export class WorkflowEngine {
|
|
|
50
50
|
for (const controller of this.controllers.values())
|
|
51
51
|
controller.abort();
|
|
52
52
|
}
|
|
53
|
+
/** Waits until aborted plans have committed their terminal state. */
|
|
54
|
+
async waitForIdle() {
|
|
55
|
+
while (this.controllers.size > 0) {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
53
59
|
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
54
60
|
conversationOf(workflowId) {
|
|
55
61
|
return this.owners.get(workflowId);
|
|
@@ -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;
|