@evandu/super-pi 0.1.22 → 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 +34 -0
- package/dist/client/assets/index-BKKZnCis.css +1 -0
- package/dist/client/assets/index-LWO_0omC.js +50 -0
- package/dist/client/index.html +2 -2
- package/dist/server/command-handler.d.ts +16 -2
- package/dist/server/command-handler.js +24 -2
- package/dist/server/http-server.js +27 -0
- package/dist/server/index.js +44 -3
- package/dist/server/persistence/sqlite-state-repository.js +25 -0
- package/dist/server/server-context.d.ts +11 -0
- 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/ws-server.d.ts +11 -0
- package/dist/server/ws-server.js +27 -2
- package/package.json +2 -134
- package/dist/client/assets/index-BndehVXj.css +0 -1
- package/dist/client/assets/index-DkgwgGW6.js +0 -49
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-LWO_0omC.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BKKZnCis.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -4,6 +4,7 @@ import type { ConversationService } from "./application/conversation-service.js"
|
|
|
4
4
|
import type { RunService } from "./application/run-service.js";
|
|
5
5
|
import type { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
6
6
|
import type { CatalogService } from "./application/catalog-service.js";
|
|
7
|
+
import type { TerminalService } from "./terminal.js";
|
|
7
8
|
export type SocketSender = (event: ServerEvent) => void;
|
|
8
9
|
/**
|
|
9
10
|
* Routes client commands to appropriate application services and converts results or errors.
|
|
@@ -14,9 +15,22 @@ export declare class CommandHandler {
|
|
|
14
15
|
private readonly runService;
|
|
15
16
|
private readonly extensionUiService;
|
|
16
17
|
private readonly catalogService;
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The terminal, or undefined when `SUPER_PI_TERMINAL` is off.
|
|
20
|
+
*
|
|
21
|
+
* Optional rather than always-present so that the common installation has no route to a shell AT ALL,
|
|
22
|
+
* not a route that refuses. `terminal-*` commands fall through to the unknown-command error when this
|
|
23
|
+
* is undefined, which is a refusal that names the command rather than one that pretends it exists.
|
|
24
|
+
*/
|
|
25
|
+
private readonly terminalService?;
|
|
26
|
+
constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService, terminalService?: TerminalService);
|
|
18
27
|
/**
|
|
19
28
|
* Processes a client command.
|
|
29
|
+
*
|
|
30
|
+
* `remoteAddress` is passed for the terminal alone. Every other command is authorised by the socket
|
|
31
|
+
* itself, which was already admitted at upgrade; the terminal additionally needs to know WHERE the
|
|
32
|
+
* connection came from, because it is the one command family whose blast radius is the whole machine
|
|
33
|
+
* rather than one workspace. See `TerminalService.handle`.
|
|
20
34
|
*/
|
|
21
|
-
handleCommand(command: ClientCommand, sendToSocket?: SocketSender): Promise<string | void>;
|
|
35
|
+
handleCommand(command: ClientCommand, sendToSocket?: SocketSender, remoteAddress?: string): Promise<string | void>;
|
|
22
36
|
}
|
|
@@ -7,17 +7,39 @@ export class CommandHandler {
|
|
|
7
7
|
runService;
|
|
8
8
|
extensionUiService;
|
|
9
9
|
catalogService;
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The terminal, or undefined when `SUPER_PI_TERMINAL` is off.
|
|
12
|
+
*
|
|
13
|
+
* Optional rather than always-present so that the common installation has no route to a shell AT ALL,
|
|
14
|
+
* not a route that refuses. `terminal-*` commands fall through to the unknown-command error when this
|
|
15
|
+
* is undefined, which is a refusal that names the command rather than one that pretends it exists.
|
|
16
|
+
*/
|
|
17
|
+
terminalService;
|
|
18
|
+
constructor(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService) {
|
|
11
19
|
this.workspaceService = workspaceService;
|
|
12
20
|
this.conversationService = conversationService;
|
|
13
21
|
this.runService = runService;
|
|
14
22
|
this.extensionUiService = extensionUiService;
|
|
15
23
|
this.catalogService = catalogService;
|
|
24
|
+
this.terminalService = terminalService;
|
|
16
25
|
}
|
|
17
26
|
/**
|
|
18
27
|
* Processes a client command.
|
|
28
|
+
*
|
|
29
|
+
* `remoteAddress` is passed for the terminal alone. Every other command is authorised by the socket
|
|
30
|
+
* itself, which was already admitted at upgrade; the terminal additionally needs to know WHERE the
|
|
31
|
+
* connection came from, because it is the one command family whose blast radius is the whole machine
|
|
32
|
+
* rather than one workspace. See `TerminalService.handle`.
|
|
19
33
|
*/
|
|
20
|
-
async handleCommand(command, sendToSocket) {
|
|
34
|
+
async handleCommand(command, sendToSocket, remoteAddress) {
|
|
35
|
+
if (command.type === "terminal.open" || command.type === "terminal.input" || command.type === "terminal.resize" || command.type === "terminal.close") {
|
|
36
|
+
if (!this.terminalService) {
|
|
37
|
+
sendToSocket?.({ type: "terminal.status", enabled: false, reason: "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启 Super Pi 即可使用。" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await this.terminalService.handle(command, remoteAddress, sendToSocket ?? (() => { }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
21
43
|
if (command.type === "model.rules.set") {
|
|
22
44
|
await this.catalogService.setModelRules(command.rules);
|
|
23
45
|
return;
|
|
@@ -140,6 +140,15 @@ export function createHttpServer(options) {
|
|
|
140
140
|
}
|
|
141
141
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
142
142
|
const remote = request.socket.remoteAddress ?? "";
|
|
143
|
+
/**
|
|
144
|
+
* THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
|
|
145
|
+
*
|
|
146
|
+
* Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
|
|
147
|
+
* is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
|
|
148
|
+
* predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
|
|
149
|
+
* There is consequently no code path from an HTTP request to a spawned shell, which is the property
|
|
150
|
+
* worth having: the browser's own origin could not reach it even with a forged request.
|
|
151
|
+
*/
|
|
143
152
|
if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
|
|
144
153
|
if (!context.memory) {
|
|
145
154
|
response.writeHead(503).end();
|
|
@@ -188,6 +197,24 @@ export function createHttpServer(options) {
|
|
|
188
197
|
.end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
|
|
189
198
|
return;
|
|
190
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
|
|
202
|
+
*
|
|
203
|
+
* It is deployment configuration, not application state: it does not change while the server runs,
|
|
204
|
+
* it is identical for every workspace, and putting it on `state` would drag it into the persistence
|
|
205
|
+
* layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
|
|
206
|
+
* can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
|
|
207
|
+
* that is present and refuses is a tab that invites a user to keep clicking a closed door.
|
|
208
|
+
*
|
|
209
|
+
* No address check: this leaks nothing beyond "is this build configured for a shell", which is
|
|
210
|
+
* already printed in the server's own startup banner to whoever can see the console.
|
|
211
|
+
*/
|
|
212
|
+
if (url.pathname === "/api/terminal") {
|
|
213
|
+
const enabled = context.terminalEnabled?.() ?? false;
|
|
214
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
215
|
+
.end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
191
218
|
if (url.pathname === "/api/subagent/dispatch") {
|
|
192
219
|
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
193
220
|
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
|
package/dist/server/index.js
CHANGED
|
@@ -17,7 +17,8 @@ import { RunService } from "./application/run-service.js";
|
|
|
17
17
|
import { ConversationService } from "./application/conversation-service.js";
|
|
18
18
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
19
19
|
import { CommandHandler } from "./command-handler.js";
|
|
20
|
-
import {
|
|
20
|
+
import { TerminalService, terminalDisabledReason } from "./terminal.js";
|
|
21
|
+
import { createHttpServer, getLanIps, isLocalOrPrivateAddress, parseServerArgs } from "./http-server.js";
|
|
21
22
|
import { WsBroadcaster, WsServer } from "./ws-server.js";
|
|
22
23
|
import { initRunningVersion } from "./version.js";
|
|
23
24
|
// ------------------------------------------------------------- Environment & paths
|
|
@@ -114,6 +115,19 @@ const runService = new RunService(store, runtime, subagents, broadcaster, extens
|
|
|
114
115
|
runService.setMemory(memory);
|
|
115
116
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
116
117
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
|
|
118
|
+
/**
|
|
119
|
+
* The terminal, constructed before `serverContext` because that object carries the two predicates
|
|
120
|
+
* `/api/terminal` answers with.
|
|
121
|
+
*
|
|
122
|
+
* Constructed UNCONDITIONALLY so the service exists to answer a refusal with a sentence, but it re-checks
|
|
123
|
+
* `SUPER_PI_TERMINAL` internally, so construction and `/api/terminal` cannot disagree. The workspace
|
|
124
|
+
* lookup goes through `store` rather than through the command body so that `terminal.open` has no path
|
|
125
|
+
* field to fill in, which is the structural half of the containment story documented in `terminal.ts`.
|
|
126
|
+
*/
|
|
127
|
+
const terminalService = new TerminalService({
|
|
128
|
+
workspaceFor: (workspaceId) => store.snapshot().workspaces.find((workspace) => workspace.id === workspaceId),
|
|
129
|
+
isTrustedPeer: (remoteAddress) => isLocalOrPrivateAddress(remoteAddress),
|
|
130
|
+
});
|
|
117
131
|
const serverContext = {
|
|
118
132
|
memory,
|
|
119
133
|
paths,
|
|
@@ -127,8 +141,10 @@ const serverContext = {
|
|
|
127
141
|
runService,
|
|
128
142
|
extensionUiService,
|
|
129
143
|
catalogService,
|
|
144
|
+
terminalEnabled: () => terminalService.enabled,
|
|
145
|
+
terminalDisabledReason: () => terminalDisabledReason(),
|
|
130
146
|
};
|
|
131
|
-
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService);
|
|
147
|
+
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService);
|
|
132
148
|
// ------------------------------------------------------------- Lifecycle & shutdown
|
|
133
149
|
let shutdownPromise;
|
|
134
150
|
async function closeHttpServer() {
|
|
@@ -142,6 +158,8 @@ async function shutdown(reason) {
|
|
|
142
158
|
// Stop accepting new connections first. Existing clients stay connected until
|
|
143
159
|
// their cancelled work has recorded its terminal state.
|
|
144
160
|
const httpClosed = closeHttpServer();
|
|
161
|
+
// Kill every shell BEFORE the sockets close, so a PTY is never outlived by the server that owns it.
|
|
162
|
+
terminalService.closeAll();
|
|
145
163
|
runService.abortAll();
|
|
146
164
|
workflows.abortAll();
|
|
147
165
|
releaseAllCapabilities();
|
|
@@ -161,7 +179,22 @@ process.on("SIGINT", () => { void shutdown("SIGINT").finally(() => process.exit(
|
|
|
161
179
|
process.on("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(0)); });
|
|
162
180
|
// ------------------------------------------------------------- Transports & startup
|
|
163
181
|
const httpServer = createHttpServer({ context: serverContext, onShutdown: shutdown });
|
|
164
|
-
const wsServer = new WsServer({
|
|
182
|
+
const wsServer = new WsServer({
|
|
183
|
+
httpServer,
|
|
184
|
+
broadcaster,
|
|
185
|
+
commandHandler,
|
|
186
|
+
catalogService,
|
|
187
|
+
accessToken,
|
|
188
|
+
rootDir,
|
|
189
|
+
// Closing a tab must close its shells. Routed through the service rather than reaching into it so the
|
|
190
|
+
// per-session bookkeeping (output flush, pending timer) still runs - `closeAll` is for shutdown, not
|
|
191
|
+
// for one socket's worth of sessions.
|
|
192
|
+
onSocketClose: (sessionIds) => {
|
|
193
|
+
for (const sessionId of sessionIds) {
|
|
194
|
+
terminalService.handle({ type: "terminal.close", sessionId }, "127.0.0.1", () => { });
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
165
198
|
httpServer.listen(port, host, () => {
|
|
166
199
|
const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
167
200
|
console.log(`Super Pi is running at http://${displayHost}:${port}`);
|
|
@@ -171,4 +204,12 @@ httpServer.listen(port, host, () => {
|
|
|
171
204
|
console.log(` - Network: http://${ip}:${port}`);
|
|
172
205
|
}
|
|
173
206
|
}
|
|
207
|
+
// Stated in the banner rather than left to be discovered, because the terminal is a SHELL and the
|
|
208
|
+
// operator should never have to read the source to find out one is exposed on this machine.
|
|
209
|
+
if (terminalService.enabled) {
|
|
210
|
+
console.log(" - Terminal: enabled (SUPER_PI_TERMINAL) - the right rail's 终端 tab runs a real shell.");
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.log(" - Terminal: disabled. Set SUPER_PI_TERMINAL=1 and restart to enable the 终端 tab.");
|
|
214
|
+
}
|
|
174
215
|
});
|
|
@@ -21,6 +21,8 @@ export class SqliteStateRepository {
|
|
|
21
21
|
Object.assign(state, { [String(overlay.name)]: JSON.parse(String(overlay.json)) });
|
|
22
22
|
}
|
|
23
23
|
const messages = this.db.prepare("SELECT json FROM messages WHERE conversation=?");
|
|
24
|
+
if (!Array.isArray(state.conversations))
|
|
25
|
+
state.conversations = [];
|
|
24
26
|
for (const conversation of state.conversations) {
|
|
25
27
|
const metadata = this.db.prepare("SELECT json FROM metadata WHERE conversation=?").get(conversation.id);
|
|
26
28
|
if (metadata) {
|
|
@@ -33,6 +35,8 @@ export class SqliteStateRepository {
|
|
|
33
35
|
const message = JSON.parse(String(r.json));
|
|
34
36
|
return [message.id, message];
|
|
35
37
|
}));
|
|
38
|
+
if (!Array.isArray(conversation.messages))
|
|
39
|
+
conversation.messages = [];
|
|
36
40
|
conversation.messages = conversation.messages.map(message => overrides.get(message.id) ?? message);
|
|
37
41
|
// Timestamp overlays are stored beside the base conversation metadata.
|
|
38
42
|
const stamp = this.db.prepare("SELECT stamp FROM timestamps WHERE conversation=?").get(conversation.id);
|
|
@@ -40,6 +44,27 @@ export class SqliteStateRepository {
|
|
|
40
44
|
conversation.updatedAt = String(stamp.stamp);
|
|
41
45
|
}
|
|
42
46
|
const runs = new Map(this.db.prepare("SELECT id,json FROM runs").all().map(r => [String(r.id), JSON.parse(String(r.json))]));
|
|
47
|
+
/**
|
|
48
|
+
* WHY THESE TWO ARE RE-CHECKED HERE, AND THE UPGRADE THEY PROTECT.
|
|
49
|
+
*
|
|
50
|
+
* `state.json` is the LEGACY store, and `openStateStore` imports one into an empty database verbatim.
|
|
51
|
+
* A state written by a Super Pi older than sub-agent dispatch has no `subagentRuns` key at all, so
|
|
52
|
+
* `state.subagentRuns.map` threw `Cannot read properties of undefined (reading 'map')` and the server
|
|
53
|
+
* exited 1 on its very first `load` - before the HTTP listener existed, so there was no page, no
|
|
54
|
+
* error in the UI, and nothing to retry. An upgrade that cannot start is the worst failure this
|
|
55
|
+
* project can ship.
|
|
56
|
+
*
|
|
57
|
+
* The same applies to `conversations` just above, for a state written before conversations existed.
|
|
58
|
+
* Both are defaulted to `[]` rather than repaired, because an absent collection is genuinely empty:
|
|
59
|
+
* there is nothing to invent, and `Store.openRepository` already reconciles a real (if empty) list.
|
|
60
|
+
*
|
|
61
|
+
* NOTE THAT NO BROWSER TEST COULD HAVE CAUGHT THIS. `scripts/run-check.mjs` runs the whole browser
|
|
62
|
+
* batch with `SUPER_PI_PERSISTENCE=json`, so every browser suite takes the JSON repository and the
|
|
63
|
+
* SQLite import path is never executed by the suite. It was found by running one of those suites
|
|
64
|
+
* standalone, which is the only way the SQLite default is ever used.
|
|
65
|
+
*/
|
|
66
|
+
if (!Array.isArray(state.subagentRuns))
|
|
67
|
+
state.subagentRuns = [];
|
|
43
68
|
state.subagentRuns = state.subagentRuns.map(run => runs.get(run.id) ?? run);
|
|
44
69
|
return state;
|
|
45
70
|
}
|
|
@@ -45,4 +45,15 @@ export interface ServerContext {
|
|
|
45
45
|
runService: RunService;
|
|
46
46
|
extensionUiService: ExtensionUiService;
|
|
47
47
|
catalogService: CatalogService;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the right rail's terminal tab should exist, and why not when it should not.
|
|
50
|
+
*
|
|
51
|
+
* TWO PREDICATES AND NOT A `TerminalService` HANDLE, and that is the load-bearing part. `/api/terminal`
|
|
52
|
+
* only ever needs to answer "is it on"; handing the HTTP layer the service itself would put a
|
|
53
|
+
* `spawn` within reach of a GET request, and the terminal is the one part of this server that is not
|
|
54
|
+
* confined to a workspace. Keeping the handle on `CommandHandler` alone means the only route to a
|
|
55
|
+
* shell is a WebSocket message that `TerminalService.assertUsable` has already vetted.
|
|
56
|
+
*/
|
|
57
|
+
terminalEnabled?: () => boolean;
|
|
58
|
+
terminalDisabledReason?: () => string;
|
|
48
59
|
}
|
|
@@ -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
|
+
}
|