@evandu/super-pi 0.1.95 → 0.1.97
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/bin/super-pi.js +29 -4
- package/dist/client/.super-pi-client-sources.json +1 -1
- package/dist/extension.js +13 -3
- package/dist/server/pi-command.js +24 -0
- package/dist/server/runtime.d.ts +19 -0
- package/dist/server/runtime.js +40 -10
- package/package.json +5 -3
- package/scripts/patch-brace-expansion.mjs +179 -0
package/bin/super-pi.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -15,6 +15,31 @@ const home = process.env.SUPER_PI_AUTOSTART_HOME ?? homedir();
|
|
|
15
15
|
const envPort = Number(process.env.SUPER_PI_PORT ?? 3080);
|
|
16
16
|
const defaultPort = Number.isInteger(envPort) && envPort > 0 ? envPort : 3080;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* This installation's own version.
|
|
20
|
+
*
|
|
21
|
+
* READ AT RUN TIME, not baked in, because there can be more than one Super Pi on a machine and the one
|
|
22
|
+
* that matters is the one that is RUNNING. The two usual places are the global npm install - which is
|
|
23
|
+
* what the `super_pi` command runs - and the Pi extension's own install - which is what the
|
|
24
|
+
* `/super_pi` slash command loads. They are separate installations on purpose (Super Pi must not
|
|
25
|
+
* require Pi to be installed first), so their versions can differ, and a person comparing the two needs
|
|
26
|
+
* to see which is which. A bare version number cannot say that; a version number AND a path can.
|
|
27
|
+
*/
|
|
28
|
+
function ownVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
31
|
+
return { version: pkg.version ?? "unknown", location: root };
|
|
32
|
+
} catch {
|
|
33
|
+
return { version: "unknown", location: root };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The line that names what is running, so two installations can be told apart at a glance. */
|
|
38
|
+
function runningBanner(url) {
|
|
39
|
+
const { version, location } = ownVersion();
|
|
40
|
+
return `Super Pi v${version} is running at ${url}\n installed at ${location}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
function options(args) {
|
|
19
44
|
const value = { port: defaultPort, host: process.env.SUPER_PI_HOST ?? "0.0.0.0", browser: true };
|
|
20
45
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -57,12 +82,12 @@ const command = implicitStart ? "start" : raw?.toLowerCase();
|
|
|
57
82
|
const args = implicitStart ? [raw, ...rest] : rest;
|
|
58
83
|
try {
|
|
59
84
|
if (command === "help" || command === "--help" || command === "-h") { usage(); process.exit(0); }
|
|
60
|
-
if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}` :
|
|
85
|
+
if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url)); process.exit(0); }
|
|
61
86
|
if (command === "autostart" || command === "startup") { const action = args.shift()?.toLowerCase() ?? "status"; const value = options(args); if (action === "enable") await enable(value); else if (action === "disable") await disable(); else if (action === "status") await status(); else throw new Error("Autostart action must be enable, disable, or status."); process.exit(0); }
|
|
62
87
|
if (!["start", "stop", "restart", "update"].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
63
88
|
if (command === "update") { const npm = platform === "win32" ? "npm.cmd" : "npm"; const args = prefix ? ["install", "--prefix", prefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"]; const child = spawn(npm, args, { stdio: "inherit", shell: platform === "win32", env: process.env }); const code = await new Promise((resolve) => child.on("close", resolve)); if (code !== 0) process.exit(code ?? 1); }
|
|
64
89
|
const value = options(args);
|
|
65
90
|
if (command === "stop") { console.log(await running(value.port) ? (await stop(value.port) ? `Shutdown requested on port ${value.port}.` : "Shutdown request failed.") : `Super Pi is not running on port ${value.port}.`); process.exit(0); }
|
|
66
91
|
if ((command === "restart" || command === "update") && await running(value.port)) { await stop(value.port); await new Promise((resolve) => setTimeout(resolve, 600)); }
|
|
67
|
-
await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}` :
|
|
68
|
-
} catch (error) { console.error(error instanceof Error ? error.message : String(error)); usage(); process.exit(1); }
|
|
92
|
+
await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url));
|
|
93
|
+
} catch (error) { console.error(error instanceof Error ? error.message : String(error)); usage(); process.exit(1); }
|
|
@@ -24,5 +24,5 @@
|
|
|
24
24
|
"src/client/types.ts": "6487df00c164370550b5977f9b23b17e22aff4bfb59dfe1e52200042340e3769",
|
|
25
25
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
26
26
|
},
|
|
27
|
-
"recordedAt": "2026-10-
|
|
27
|
+
"recordedAt": "2026-10-20T17:20:14.918Z"
|
|
28
28
|
}
|
package/dist/extension.js
CHANGED
|
@@ -83,14 +83,24 @@ export default function (pi) {
|
|
|
83
83
|
if (!running) {
|
|
84
84
|
ctx.ui.notify?.("Starting Super Pi web server...", "info");
|
|
85
85
|
const serverScript = resolveServerScript();
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// The Pi that is hosting Super Pi, passed down so that the ONE-SHOT work - the sub-agents,
|
|
87
|
+
// which spawn a Pi process - runs on the same Pi that is running this extension.
|
|
88
|
+
//
|
|
89
|
+
// IT IS NOT `SUPER_PI_PI_COMMAND`, and that difference is load-bearing. That name means "the user
|
|
90
|
+
// named this Pi", and naming a Pi is what tells Super Pi to run CONVERSATIONS by spawning it
|
|
91
|
+
// rather than by using Pi's SDK - the SDK is a library, and it can only be the one that came with
|
|
92
|
+
// Super Pi. So a Pi named there cannot be a conversation's Pi, and a conversation that spawns a
|
|
93
|
+
// process for every turn is the slow architecture this all replaced.
|
|
94
|
+
//
|
|
95
|
+
// Setting it here therefore made every conversation started from inside Pi slow, with nothing
|
|
96
|
+
// saying why. The hosting Pi belongs to the one-shot paths, and this is where it is said.
|
|
97
|
+
const hostPi = process.argv[1] || "pi";
|
|
88
98
|
const child = spawn(process.execPath, [serverScript], {
|
|
89
99
|
detached: true,
|
|
90
100
|
stdio: "ignore",
|
|
91
101
|
env: {
|
|
92
102
|
...process.env,
|
|
93
|
-
|
|
103
|
+
SUPER_PI_HOST_PI: hostPi,
|
|
94
104
|
SUPER_PI_PORT: String(port),
|
|
95
105
|
},
|
|
96
106
|
});
|
|
@@ -126,6 +126,30 @@ export function resolvePiCommand(options = {}) {
|
|
|
126
126
|
searched: [...onPath, ...globalDirs],
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
+
// THE PI THAT IS HOSTING SUPER PI, when Super Pi was started from inside Pi itself.
|
|
130
|
+
//
|
|
131
|
+
// It is NOT the same thing as an explicitly named Pi, and the difference is the whole point:
|
|
132
|
+
// `SUPER_PI_PI_COMMAND` means "the USER named this Pi", and naming a Pi is what tells Super Pi to
|
|
133
|
+
// run CONVERSATIONS by spawning it - the SDK is a library, and it can only be the one that came with
|
|
134
|
+
// Super Pi. A conversation that spawns a process for every turn is the slow architecture this all
|
|
135
|
+
// replaced, so the hosting Pi must NOT set that flag.
|
|
136
|
+
//
|
|
137
|
+
// What the hosting Pi IS good for is the one-shot work: the sub-agents spawn a Pi process, and they
|
|
138
|
+
// should run on the same Pi that is hosting Super Pi. So it is preferred over the bundled copy here,
|
|
139
|
+
// without being a user override.
|
|
140
|
+
const hostPi = (env.SUPER_PI_HOST_PI ?? "").trim();
|
|
141
|
+
if (hostPi) {
|
|
142
|
+
if (/[\\/]/.test(hostPi)) {
|
|
143
|
+
const full = nativeEntry(hostPi, platform) ?? hostPi;
|
|
144
|
+
if (exists(full))
|
|
145
|
+
return { found: true, command: full, via: "the Pi that is hosting Super Pi", explicit: false };
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const hit = firstHit([hostPi], [...onPath, ...globalDirs], exists, platform);
|
|
149
|
+
if (hit)
|
|
150
|
+
return { found: true, command: hit.command, via: `the Pi that is hosting Super Pi (${hit.dir})`, explicit: false };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
129
153
|
// THE COPY OF PI THAT CAME WITH SUPER PI, and it comes FIRST among the automatic choices.
|
|
130
154
|
//
|
|
131
155
|
// Super Pi depends on Pi's package, so npm installs it and puts its `pi` executable in
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -515,6 +515,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
515
515
|
private warnedMissingExtension;
|
|
516
516
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
517
517
|
private warnedMissingSdk;
|
|
518
|
+
/**
|
|
519
|
+
* How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
|
|
520
|
+
*
|
|
521
|
+
* is the slower one, and it exists for the test suite: those suites drive a fake Pi that
|
|
522
|
+
* speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
|
|
523
|
+
* it. It is an explicit request rather than something inferred from which Pi happens to be named,
|
|
524
|
+
* because inferring it is what made the Pi extension start conversations on the slow path without
|
|
525
|
+
* saying why.
|
|
526
|
+
*/
|
|
527
|
+
private readonly sessionMode;
|
|
518
528
|
/**
|
|
519
529
|
* Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
|
|
520
530
|
* per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
|
|
@@ -571,6 +581,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
571
581
|
toolsEndpoint?: string;
|
|
572
582
|
uploadsDir?: string;
|
|
573
583
|
sessionFactory?: PiSessionFactory;
|
|
584
|
+
sessionMode?: "sdk" | "oneshot";
|
|
574
585
|
});
|
|
575
586
|
/**
|
|
576
587
|
* The configuration of the session that was built for a conversation, for tests to inspect.
|
|
@@ -598,6 +609,14 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
598
609
|
};
|
|
599
610
|
thinkingLevel?: string;
|
|
600
611
|
} | undefined;
|
|
612
|
+
/**
|
|
613
|
+
* Which path a CONVERSATION on this runtime takes.
|
|
614
|
+
*
|
|
615
|
+
* Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
|
|
616
|
+
* the version number said 0.1.95 while every conversation was still spawning a process per turn,
|
|
617
|
+
* and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
|
|
618
|
+
*/
|
|
619
|
+
get conversationPath(): "sdk" | "oneshot";
|
|
601
620
|
/**
|
|
602
621
|
* Whether the SDK-backed session layer can be used at all.
|
|
603
622
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -303,6 +303,16 @@ export class PiRpcRuntime {
|
|
|
303
303
|
warnedMissingExtension = false;
|
|
304
304
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
305
305
|
warnedMissingSdk = false;
|
|
306
|
+
/**
|
|
307
|
+
* How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
|
|
308
|
+
*
|
|
309
|
+
* is the slower one, and it exists for the test suite: those suites drive a fake Pi that
|
|
310
|
+
* speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
|
|
311
|
+
* it. It is an explicit request rather than something inferred from which Pi happens to be named,
|
|
312
|
+
* because inferring it is what made the Pi extension start conversations on the slow path without
|
|
313
|
+
* saying why.
|
|
314
|
+
*/
|
|
315
|
+
sessionMode;
|
|
306
316
|
/**
|
|
307
317
|
* Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
|
|
308
318
|
* per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
|
|
@@ -364,6 +374,8 @@ export class PiRpcRuntime {
|
|
|
364
374
|
this.pi = resolvePiCommand({ command });
|
|
365
375
|
if (!this.pi.found)
|
|
366
376
|
console.warn(`PiRpcRuntime: ${this.pi.reason}`);
|
|
377
|
+
this.sessionMode = this.options.sessionMode
|
|
378
|
+
?? (process.env.SUPER_PI_SESSION_MODE === "oneshot" ? "oneshot" : "sdk");
|
|
367
379
|
this.sessionPool = new PiSessionPool(sessionDir, repositoryRootFrom(), undefined, (conversationId) => {
|
|
368
380
|
// A session's credential dies with the session. The session is what HELD the token - its
|
|
369
381
|
// extension read it out of the environment when it was loaded - so a session that is gone must
|
|
@@ -391,6 +403,16 @@ export class PiRpcRuntime {
|
|
|
391
403
|
builtSessionConfig(conversationId) {
|
|
392
404
|
return this.builtSessions.get(conversationId);
|
|
393
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Which path a CONVERSATION on this runtime takes.
|
|
408
|
+
*
|
|
409
|
+
* Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
|
|
410
|
+
* the version number said 0.1.95 while every conversation was still spawning a process per turn,
|
|
411
|
+
* and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
|
|
412
|
+
*/
|
|
413
|
+
get conversationPath() {
|
|
414
|
+
return this.sessionMode;
|
|
415
|
+
}
|
|
394
416
|
/**
|
|
395
417
|
* Whether the SDK-backed session layer can be used at all.
|
|
396
418
|
*
|
|
@@ -919,18 +941,26 @@ export class PiRpcRuntime {
|
|
|
919
941
|
yield* this.answerOneShot(input);
|
|
920
942
|
return;
|
|
921
943
|
}
|
|
922
|
-
//
|
|
923
|
-
//
|
|
944
|
+
// WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
|
|
945
|
+
//
|
|
946
|
+
// The session path does not use a Pi COMMAND at all: it imports Pi as a library, and the library it
|
|
947
|
+
// imports is the one installed next to Super Pi. A named command is a PROGRAM, and the session path
|
|
948
|
+
// cannot run a program as itself.
|
|
949
|
+
//
|
|
950
|
+
// So the two are different questions, and they are asked of different variables:
|
|
924
951
|
//
|
|
925
|
-
// `
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
952
|
+
// - `SUPER_PI_HOST_PI` - the Pi that is hosting Super Pi, set by the Pi extension. It steers the
|
|
953
|
+
// ONE-SHOT work (the sub-agents spawn a Pi) and nothing else. It does NOT redirect conversations.
|
|
954
|
+
// - `SUPER_PI_SESSION_MODE=oneshot` - the explicit request to run conversations by spawning a Pi
|
|
955
|
+
// instead of by using the SDK. It is slower, and it is here for the test suite, which drives a fake
|
|
956
|
+
// Pi that speaks the RPC protocol - a program, not a library.
|
|
929
957
|
//
|
|
930
|
-
//
|
|
931
|
-
// the
|
|
932
|
-
//
|
|
933
|
-
|
|
958
|
+
// THE BUG THIS FIXES. The extension used to set `SUPER_PI_PI_COMMAND`, and an explicitly named Pi
|
|
959
|
+
// used to mean the one-shot path - so every conversation started from inside Pi spawned a process for
|
|
960
|
+
// every turn. That is the exact architecture this refactor replaced, and it was still running for
|
|
961
|
+
// anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
|
|
962
|
+
// 0.1.84.
|
|
963
|
+
if (this.sessionMode === "oneshot") {
|
|
934
964
|
yield* this.answerOneShot(input);
|
|
935
965
|
return;
|
|
936
966
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"bin",
|
|
20
20
|
"scripts/restart-server.mjs",
|
|
21
21
|
"README.md",
|
|
22
|
-
"assets/knowledge-model"
|
|
22
|
+
"assets/knowledge-model",
|
|
23
|
+
"scripts/patch-brace-expansion.mjs"
|
|
23
24
|
],
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=22.19.0"
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
"start": "node dist/server/index.js",
|
|
36
37
|
"restart:server": "node scripts/restart-server.mjs",
|
|
37
38
|
"smoke": "npm run build && node scripts/smoke.mjs",
|
|
38
|
-
"check": "node scripts/run-check.mjs"
|
|
39
|
+
"check": "node scripts/run-check.mjs",
|
|
40
|
+
"postinstall": "node scripts/patch-brace-expansion.mjs"
|
|
39
41
|
},
|
|
40
42
|
"check:logic": [
|
|
41
43
|
"scripts/stream-checkpoint.test.mjs",
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Repairs one vulnerable transitive dependency that Super Pi cannot fix from the outside.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. Pi's package ships an `npm-shrinkwrap.json`, and a shrinkwrap pins a dependency's
|
|
4
|
+
// whole subtree. Pi's shrinkwrap pins `brace-expansion@5.0.9`, and npm honours a shrinkwrap over
|
|
5
|
+
// everything else - so neither `overrides` (in either the flat or the nested form) nor `npm audit fix`
|
|
6
|
+
// can move it. `npm audit` therefore reports "1 high severity vulnerability" to every person who
|
|
7
|
+
// installs Super Pi, and the finding is real: brace-expansion 5.0.9 is quadratic-time on `{a,b}` and
|
|
8
|
+
// recurses without bound on nested brace groups, so a pattern that reaches it can burn the CPU or the
|
|
9
|
+
// stack.
|
|
10
|
+
//
|
|
11
|
+
// THE REACHABILITY, so this is not a reflex. brace-expansion is reached through `minimatch`, and in
|
|
12
|
+
// Pi's tree `minimatch` is used by the package manager - to match the paths of installed extensions
|
|
13
|
+
// and skills. Those names come from files on disk, so a hostile workspace is enough to feed a pattern
|
|
14
|
+
// in. It is a denial of service, not a privilege escalation, but it is reachable by opening a
|
|
15
|
+
// repository, and a workspace is exactly what Super Pi is pointed at.
|
|
16
|
+
//
|
|
17
|
+
// THE FIX. `brace-expansion@5.0.12` is the patched release, and it has the same file layout as
|
|
18
|
+
// 5.0.9 (both are `tshy` builds with the same `dist/commonjs` + `dist/esm` shape), so replacing the
|
|
19
|
+
// installed copy is a file-level swap with no re-linking and no API change.
|
|
20
|
+
//
|
|
21
|
+
// WHY IT IS A POSTINSTALL AND NOT A DEPENDENCY. A dependency cannot express "a different version of
|
|
22
|
+
// my dependency's dependency", because the shrinkwrap is the whole point of a shrinkwrap. The only
|
|
23
|
+
// place left is the install itself.
|
|
24
|
+
//
|
|
25
|
+
// WHAT IT WILL NOT DO. It never fails an install. A patched copy that cannot be fetched leaves the
|
|
26
|
+
// tree as it was, and the script says so in one line. An install that cannot finish because of a
|
|
27
|
+
// security patch is a worse outcome than an install that finishes with a known advisory, so the script
|
|
28
|
+
// only ever warns.
|
|
29
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync, cpSync } from "node:fs";
|
|
30
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
|
|
35
|
+
/** The patched release. 5.0.9 is what Pi's shrinkwrap pins; 5.0.12 carries the fix. */
|
|
36
|
+
const PATCHED_VERSION = "5.0.12";
|
|
37
|
+
/** The first fixed version. Below this, the advisory applies. */
|
|
38
|
+
const VULNERABLE_BELOW = "5.0.12";
|
|
39
|
+
|
|
40
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const repositoryRoot = resolve(here, "..");
|
|
42
|
+
|
|
43
|
+
/** The lockfile-relative path of a directory, in the form a lockfile's keys take. */
|
|
44
|
+
const lockKeyOf = (directory) => relative(repositoryRoot, directory).split(/[\\/]/).filter(Boolean).join("/");
|
|
45
|
+
|
|
46
|
+
/** Where Pi's package keeps its own `brace-expansion`, if it keeps one. */
|
|
47
|
+
function findVulnerableCopies() {
|
|
48
|
+
const found = [];
|
|
49
|
+
const candidates = [
|
|
50
|
+
join(repositoryRoot, "node_modules", "@earendil-works", "pi-coding-agent", "node_modules", "brace-expansion"),
|
|
51
|
+
join(repositoryRoot, "node_modules", "brace-expansion"),
|
|
52
|
+
];
|
|
53
|
+
for (const directory of candidates) {
|
|
54
|
+
const manifest = join(directory, "package.json");
|
|
55
|
+
if (!existsSync(manifest)) continue;
|
|
56
|
+
let version = "";
|
|
57
|
+
try { version = JSON.parse(readFileSync(manifest, "utf8")).version ?? ""; } catch { continue; }
|
|
58
|
+
if (!version) continue;
|
|
59
|
+
if (compareVersions(version, VULNERABLE_BELOW) >= 0) continue;
|
|
60
|
+
found.push({ directory, version, lockKey: lockKeyOf(directory) });
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Numeric version comparison that does not care about a leading `v` or a prerelease tag. */
|
|
66
|
+
function compareVersions(a, b) {
|
|
67
|
+
const parts = (value) => String(value).replace(/^v/, "").split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
68
|
+
const left = parts(a);
|
|
69
|
+
const right = parts(b);
|
|
70
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
71
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
72
|
+
if (difference !== 0) return difference < 0 ? -1 : 1;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replaces one installed copy with the patched release.
|
|
79
|
+
*
|
|
80
|
+
* The patched copy is fetched with `npm install` into a throwaway directory - npm's own
|
|
81
|
+
* fetch-and-verify path - rather than `npm pack` plus `tar`, which needs a `tar` on PATH whose flags
|
|
82
|
+
* differ between platforms. It is laid out exactly the way npm would have laid it out.
|
|
83
|
+
*/
|
|
84
|
+
function patch(directory) {
|
|
85
|
+
const scratch = join(tmpdir(), `super-pi-brace-${process.pid}-${Date.now()}`);
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(scratch, { recursive: true });
|
|
88
|
+
const stage = join(scratch, "stage");
|
|
89
|
+
mkdirSync(stage, { recursive: true });
|
|
90
|
+
writeFileSync(join(stage, "package.json"), JSON.stringify({ name: "super-pi-brace-stage", version: "0.0.0", private: true }));
|
|
91
|
+
execFileSync("npm", ["install", `brace-expansion@${PATCHED_VERSION}`, "--no-audit", "--no-fund", "--silent", "--omit=dev"], {
|
|
92
|
+
cwd: stage,
|
|
93
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
94
|
+
shell: process.platform === "win32",
|
|
95
|
+
});
|
|
96
|
+
const unpacked = join(stage, "node_modules", "brace-expansion");
|
|
97
|
+
if (!existsSync(join(unpacked, "package.json"))) throw new Error("the staged install produced no package");
|
|
98
|
+
// The old copy is moved aside rather than deleted first, so a failure mid-swap cannot leave the
|
|
99
|
+
// tree with half a package in it.
|
|
100
|
+
const previous = `${directory}.super-pi-backup`;
|
|
101
|
+
rmSync(previous, { recursive: true, force: true });
|
|
102
|
+
renameSync(directory, previous);
|
|
103
|
+
try {
|
|
104
|
+
cpSync(unpacked, directory, { recursive: true });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// The swap failed halfway: put the old copy back rather than leaving the tree broken.
|
|
107
|
+
rmSync(directory, { recursive: true, force: true });
|
|
108
|
+
renameSync(previous, directory);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
rmSync(previous, { recursive: true, force: true });
|
|
112
|
+
return true;
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The integrity hash npm recorded for the patched version, so the lockfile still describes a tree npm
|
|
120
|
+
* can verify. It comes from the registry metadata npm itself trusts.
|
|
121
|
+
*/
|
|
122
|
+
function readIntegrity() {
|
|
123
|
+
try {
|
|
124
|
+
const output = execFileSync("npm", ["view", `brace-expansion@${PATCHED_VERSION}`, "dist.integrity", "--silent"], {
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
shell: process.platform === "win32",
|
|
127
|
+
encoding: "utf8",
|
|
128
|
+
});
|
|
129
|
+
return output.trim() || undefined;
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rewrites the lockfile's entry for a patched package.
|
|
137
|
+
*
|
|
138
|
+
* WITHOUT THIS THE PATCH IS INVISIBLE. `npm audit` reads the LOCKFILE, not the files on disk - it
|
|
139
|
+
* compares the resolved versions against the advisory database. So a tree that is genuinely patched
|
|
140
|
+
* still reports "1 high severity vulnerability", and the person who installed Super Pi is left looking
|
|
141
|
+
* at a warning that no amount of reinstalling will clear.
|
|
142
|
+
*/
|
|
143
|
+
function updateLockfile(lockKey, version, integrity) {
|
|
144
|
+
const lockPath = join(repositoryRoot, "package-lock.json");
|
|
145
|
+
if (!existsSync(lockPath)) return false;
|
|
146
|
+
let lock;
|
|
147
|
+
try { lock = JSON.parse(readFileSync(lockPath, "utf8")); }
|
|
148
|
+
catch { return false; }
|
|
149
|
+
const entry = lock.packages?.[lockKey];
|
|
150
|
+
if (!entry || entry.version === version) return false;
|
|
151
|
+
entry.version = version;
|
|
152
|
+
if (integrity) entry.integrity = integrity;
|
|
153
|
+
if (entry.resolved) entry.resolved = entry.resolved.replace(/-\/[^/]*\.tgz$/, `-/brace-expansion-${version}.tgz`);
|
|
154
|
+
writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const copies = findVulnerableCopies();
|
|
159
|
+
if (copies.length === 0) {
|
|
160
|
+
console.log("[super-pi] brace-expansion is not a vulnerable version here; nothing to patch.");
|
|
161
|
+
} else {
|
|
162
|
+
let patched = 0;
|
|
163
|
+
for (const copy of copies) {
|
|
164
|
+
try {
|
|
165
|
+
if (!patch(copy.directory)) continue;
|
|
166
|
+
patched += 1;
|
|
167
|
+
console.log(`[super-pi] patched brace-expansion ${copy.version} -> ${PATCHED_VERSION} (${copy.directory})`);
|
|
168
|
+
// And the lockfile, so `npm audit` stops reporting a tree that is no longer vulnerable.
|
|
169
|
+
if (updateLockfile(copy.lockKey, PATCHED_VERSION, readIntegrity())) {
|
|
170
|
+
console.log("[super-pi] the lockfile now records the patched version, so `npm audit` stops reporting it.");
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// NEVER fail the install over this. An install that cannot finish is worse than an install that
|
|
174
|
+
// finishes with a known advisory, and the advisory is reported by `npm audit` either way.
|
|
175
|
+
console.warn(`[super-pi] could not patch brace-expansion in ${copy.directory}: ${error instanceof Error ? error.message : String(error)}. Run \`npm audit fix\` or reinstall to try again.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (patched === 0) console.warn("[super-pi] brace-expansion is still at a vulnerable version; `npm audit` will keep reporting it. This is Pi's own pinned dependency tree.");
|
|
179
|
+
}
|