@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.
@@ -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-DkgwgGW6.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BndehVXj.css">
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
- constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService);
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
- constructor(workspaceService, conversationService, runService, extensionUiService, catalogService) {
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");
@@ -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 { createHttpServer, getLanIps, parseServerArgs } from "./http-server.js";
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({ httpServer, broadcaster, commandHandler, catalogService, accessToken, rootDir });
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
+ }