@evandu/super-pi 0.1.96 → 0.1.98
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/server/index.js +44 -0
- package/dist/server/model-filter.js +5 -0
- package/dist/server/pi-command.d.ts +2 -1
- package/dist/server/pi-command.js +7 -2
- package/dist/server/pi-session-pool.d.ts +12 -0
- package/dist/server/pi-session-pool.js +54 -2
- package/dist/server/pi-session.d.ts +102 -6
- package/dist/server/pi-session.js +121 -5
- package/dist/server/runtime.d.ts +65 -3
- package/dist/server/runtime.js +201 -18
- package/package.json +2 -3
- package/scripts/patch-brace-expansion.mjs +155 -74
|
@@ -14,21 +14,26 @@
|
|
|
14
14
|
// in. It is a denial of service, not a privilege escalation, but it is reachable by opening a
|
|
15
15
|
// repository, and a workspace is exactly what Super Pi is pointed at.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// WHY IT RUNS AT STARTUP AND NOT AT INSTALL. It was a postinstall script, and that was a mistake
|
|
18
|
+
// with teeth: a postinstall that fails fails the INSTALL, and on 2026-10-21 it did exactly that on
|
|
19
|
+
// somebody else's machine - the script was not in the extracted tree, node reported
|
|
20
|
+
// MODULE_NOT_FOUND, and npm refused to finish installing Super Pi at all. An install must not be
|
|
21
|
+
// breakable by a security patch. So the patch runs when the server starts instead: by then the tree
|
|
22
|
+
// is known to be there, the server has already booted, and a failure is a warning rather than a
|
|
23
|
+
// failed install.
|
|
20
24
|
//
|
|
21
|
-
// WHY IT
|
|
22
|
-
//
|
|
23
|
-
//
|
|
25
|
+
// WHY IT DOES NOT RUN A NESTED `npm install`. The first version staged the patched package with
|
|
26
|
+
// `npm install` in a throwaway directory, which is a full dependency resolution - thirty seconds and
|
|
27
|
+
// a network round trip, inside an install. `npm pack` fetches one tarball and nothing else, and the
|
|
28
|
+
// tarball is read with node's own zlib, so the whole thing needs no network beyond one registry
|
|
29
|
+
// fetch and no tooling beyond node.
|
|
24
30
|
//
|
|
25
|
-
// WHAT IT WILL NOT DO. It never fails
|
|
26
|
-
//
|
|
27
|
-
// security patch is a worse outcome than an install that finishes with a known advisory, so the script
|
|
28
|
-
// only ever warns.
|
|
31
|
+
// WHAT IT WILL NOT DO. It never fails anything. A patched copy that cannot be fetched leaves the tree
|
|
32
|
+
// as it was, and the script says so in one line.
|
|
29
33
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync, cpSync } from "node:fs";
|
|
30
34
|
import { dirname, join, relative, resolve } from "node:path";
|
|
31
35
|
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { gunzipSync } from "node:zlib";
|
|
32
37
|
import { execFileSync } from "node:child_process";
|
|
33
38
|
import { tmpdir } from "node:os";
|
|
34
39
|
|
|
@@ -40,28 +45,6 @@ const VULNERABLE_BELOW = "5.0.12";
|
|
|
40
45
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
46
|
const repositoryRoot = resolve(here, "..");
|
|
42
47
|
|
|
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
48
|
/** Numeric version comparison that does not care about a leading `v` or a prerelease tag. */
|
|
66
49
|
function compareVersions(a, b) {
|
|
67
50
|
const parts = (value) => String(value).replace(/^v/, "").split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
@@ -75,35 +58,78 @@ function compareVersions(a, b) {
|
|
|
75
58
|
}
|
|
76
59
|
|
|
77
60
|
/**
|
|
78
|
-
*
|
|
61
|
+
* Reads a gzipped tar into a map of path -> contents.
|
|
79
62
|
*
|
|
80
|
-
* The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
63
|
+
* The tar format is simple enough to read here: 512-byte blocks, a name in the first hundred bytes, a
|
|
64
|
+
* size in the next twelve (octal), and the payload after the header. Long names arrive as their own
|
|
65
|
+
* entry with the type flag `L`, and the real name follows in the payload.
|
|
83
66
|
*/
|
|
84
|
-
function
|
|
67
|
+
function readTarGz(buffer) {
|
|
68
|
+
const files = new Map();
|
|
69
|
+
const tar = gunzipSync(buffer);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
let longName = "";
|
|
72
|
+
while (offset + 512 <= tar.length) {
|
|
73
|
+
const header = tar.subarray(offset, offset + 512);
|
|
74
|
+
offset += 512;
|
|
75
|
+
// Two consecutive zero blocks end the archive.
|
|
76
|
+
if (header.every((byte) => byte === 0)) break;
|
|
77
|
+
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
78
|
+
const sizeText = header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim();
|
|
79
|
+
const size = sizeText ? Number.parseInt(sizeText, 8) : 0;
|
|
80
|
+
const type = String.fromCharCode(header[156]);
|
|
81
|
+
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
|
|
82
|
+
const payload = tar.subarray(offset, offset + size);
|
|
83
|
+
offset += Math.ceil(size / 512) * 512;
|
|
84
|
+
if (type === "L") { longName = payload.toString("utf8").replace(/\0.*$/, ""); continue; }
|
|
85
|
+
if (type !== "0" && type !== "\0" && type !== "") continue;
|
|
86
|
+
const full = longName || (prefix ? `${prefix}/${name}` : name);
|
|
87
|
+
longName = "";
|
|
88
|
+
files.set(full, payload);
|
|
89
|
+
}
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetches the patched package and writes it over the vulnerable copy.
|
|
95
|
+
*
|
|
96
|
+
* One registry fetch, and the tarball is read here rather than by an external `tar` - which is not the
|
|
97
|
+
* same tar on every platform, and whose flags differ.
|
|
98
|
+
*/
|
|
99
|
+
async function patch(directory) {
|
|
85
100
|
const scratch = join(tmpdir(), `super-pi-brace-${process.pid}-${Date.now()}`);
|
|
86
101
|
try {
|
|
87
102
|
mkdirSync(scratch, { recursive: true });
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
execFileSync("npm", ["
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
const tarball = join(scratch, "brace.tgz");
|
|
104
|
+
// `npm pack` prints the filename it wrote; the URL is asked for directly so that nothing has to be
|
|
105
|
+
// parsed out of npm's own output format.
|
|
106
|
+
const url = execFileSync("npm", ["view", `brace-expansion@${PATCHED_VERSION}`, "dist.tarball", "--silent"], {
|
|
107
|
+
stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", shell: process.platform === "win32",
|
|
108
|
+
}).trim();
|
|
109
|
+
if (!url.startsWith("http")) throw new Error(`npm did not report a tarball URL (${url})`);
|
|
110
|
+
const response = await fetch(url);
|
|
111
|
+
if (!response.ok) throw new Error(`fetching the tarball failed with HTTP ${response.status}`);
|
|
112
|
+
writeFileSync(tarball, Buffer.from(await response.arrayBuffer()));
|
|
113
|
+
const entries = readTarGz(readFileSync(tarball));
|
|
114
|
+
const wanted = new Map();
|
|
115
|
+
for (const [path, contents] of entries) {
|
|
116
|
+
const relative = path.replace(/^package\//, "");
|
|
117
|
+
if (!relative || relative.endsWith("/")) continue;
|
|
118
|
+
wanted.set(relative, contents);
|
|
119
|
+
}
|
|
120
|
+
if (!wanted.has("package.json")) throw new Error("the tarball had no package.json");
|
|
98
121
|
// The old copy is moved aside rather than deleted first, so a failure mid-swap cannot leave the
|
|
99
122
|
// tree with half a package in it.
|
|
100
123
|
const previous = `${directory}.super-pi-backup`;
|
|
101
124
|
rmSync(previous, { recursive: true, force: true });
|
|
102
125
|
renameSync(directory, previous);
|
|
103
126
|
try {
|
|
104
|
-
|
|
127
|
+
for (const [relative, contents] of wanted) {
|
|
128
|
+
const target = join(directory, relative);
|
|
129
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
130
|
+
writeFileSync(target, contents);
|
|
131
|
+
}
|
|
105
132
|
} catch (error) {
|
|
106
|
-
// The swap failed halfway: put the old copy back rather than leaving the tree broken.
|
|
107
133
|
rmSync(directory, { recursive: true, force: true });
|
|
108
134
|
renameSync(previous, directory);
|
|
109
135
|
throw error;
|
|
@@ -116,15 +142,13 @@ function patch(directory) {
|
|
|
116
142
|
}
|
|
117
143
|
|
|
118
144
|
/**
|
|
119
|
-
* The integrity hash npm recorded for the patched version, so
|
|
145
|
+
* The integrity hash npm recorded for the patched version, so a lockfile still describes a tree npm
|
|
120
146
|
* can verify. It comes from the registry metadata npm itself trusts.
|
|
121
147
|
*/
|
|
122
148
|
function readIntegrity() {
|
|
123
149
|
try {
|
|
124
150
|
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",
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", encoding: "utf8",
|
|
128
152
|
});
|
|
129
153
|
return output.trim() || undefined;
|
|
130
154
|
} catch {
|
|
@@ -133,16 +157,43 @@ function readIntegrity() {
|
|
|
133
157
|
}
|
|
134
158
|
|
|
135
159
|
/**
|
|
136
|
-
*
|
|
160
|
+
* The lockfiles that describe the tree Super Pi is installed in.
|
|
161
|
+
*
|
|
162
|
+
* NOT Super Pi's own `package-lock.json`. A dependency is not recorded in its dependent's lockfile, so
|
|
163
|
+
* the entry for `brace-expansion` lives in the lockfile of whatever npm tree Super Pi is IN - which is
|
|
164
|
+
* a different file depending on how Super Pi was installed:
|
|
165
|
+
*
|
|
166
|
+
* - as a Pi extension: `~/.pi/agent/npm/package-lock.json` (Pi's own npm root)
|
|
167
|
+
* - as a global npm install: `<prefix>/node_modules/.package-lock.json` (npm's hidden lockfile)
|
|
137
168
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* at a warning that no amount of reinstalling will clear.
|
|
169
|
+
* `npm audit` reads whichever one describes the tree, so both are worth updating: a tree that is
|
|
170
|
+
* genuinely patched but whose lockfile still says 5.0.9 keeps reporting the vulnerability, and the
|
|
171
|
+
* person who installed Super Pi is left looking at a warning that no amount of reinstalling clears.
|
|
142
172
|
*/
|
|
143
|
-
function
|
|
144
|
-
const
|
|
145
|
-
|
|
173
|
+
function findDescribingLockfiles() {
|
|
174
|
+
const found = [];
|
|
175
|
+
// Super Pi's root is `<npmRoot>/node_modules/@evandu/super-pi`, so the npm root is three levels up.
|
|
176
|
+
let dir = repositoryRoot;
|
|
177
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
178
|
+
const candidate = join(dir, "package-lock.json");
|
|
179
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
180
|
+
const hidden = join(dir, "node_modules", ".package-lock.json");
|
|
181
|
+
if (existsSync(hidden)) found.push(hidden);
|
|
182
|
+
const parent = resolve(dir, "..");
|
|
183
|
+
if (parent === dir) break;
|
|
184
|
+
dir = parent;
|
|
185
|
+
}
|
|
186
|
+
return [...new Set(found)];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rewrites a lockfile's entry for the patched package.
|
|
191
|
+
*
|
|
192
|
+
* The KEY is the path of the vulnerable copy relative to the lockfile's own tree, which is why the
|
|
193
|
+
* lockfile has to be found first: the same file is `node_modules/...` in one tree and a deeper path in
|
|
194
|
+
* another.
|
|
195
|
+
*/
|
|
196
|
+
function updateLockfile(lockPath, lockKey, version, integrity) {
|
|
146
197
|
let lock;
|
|
147
198
|
try { lock = JSON.parse(readFileSync(lockPath, "utf8")); }
|
|
148
199
|
catch { return false; }
|
|
@@ -155,25 +206,55 @@ function updateLockfile(lockKey, version, integrity) {
|
|
|
155
206
|
return true;
|
|
156
207
|
}
|
|
157
208
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
/** Where a vulnerable copy might be, relative to Super Pi's own tree. */
|
|
210
|
+
function findVulnerableCopies() {
|
|
211
|
+
const found = [];
|
|
212
|
+
const candidates = [
|
|
213
|
+
join(repositoryRoot, "node_modules", "@earendil-works", "pi-coding-agent", "node_modules", "brace-expansion"),
|
|
214
|
+
join(repositoryRoot, "node_modules", "brace-expansion"),
|
|
215
|
+
];
|
|
216
|
+
for (const directory of candidates) {
|
|
217
|
+
const manifest = join(directory, "package.json");
|
|
218
|
+
if (!existsSync(manifest)) continue;
|
|
219
|
+
let version = "";
|
|
220
|
+
try { version = JSON.parse(readFileSync(manifest, "utf8")).version ?? ""; } catch { continue; }
|
|
221
|
+
if (!version) continue;
|
|
222
|
+
if (compareVersions(version, VULNERABLE_BELOW) >= 0) continue;
|
|
223
|
+
found.push({ directory, version, lockKey: lockKeyOf(directory) });
|
|
224
|
+
}
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Applies the patch, and reports what it did. Never throws: this runs beside a server that is starting,
|
|
230
|
+
* and a security patch is not a reason to fail a boot.
|
|
231
|
+
*/
|
|
232
|
+
export async function patchBraceExpansion() {
|
|
233
|
+
const copies = findVulnerableCopies();
|
|
234
|
+
if (copies.length === 0) return { patched: 0 };
|
|
235
|
+
const lockfiles = findDescribingLockfiles();
|
|
236
|
+
const integrity = readIntegrity();
|
|
162
237
|
let patched = 0;
|
|
163
238
|
for (const copy of copies) {
|
|
164
239
|
try {
|
|
165
|
-
if (!patch(copy.directory)) continue;
|
|
240
|
+
if (!await patch(copy.directory)) continue;
|
|
166
241
|
patched += 1;
|
|
167
|
-
console.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
242
|
+
console.warn(`[super-pi] patched brace-expansion ${copy.version} -> ${PATCHED_VERSION} (${copy.directory})`);
|
|
243
|
+
for (const lockPath of lockfiles) {
|
|
244
|
+
// The key is the copy's path relative to the lockfile's own tree.
|
|
245
|
+
const lockKey = relative(dirname(lockPath), copy.directory).split(/[\\/]/).filter(Boolean).join("/");
|
|
246
|
+
if (updateLockfile(lockPath, lockKey, PATCHED_VERSION, integrity)) {
|
|
247
|
+
console.warn(`[super-pi] ${lockPath} now records the patched version, so \`npm audit\` stops reporting it.`);
|
|
248
|
+
}
|
|
171
249
|
}
|
|
172
250
|
} catch (error) {
|
|
173
|
-
|
|
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.`);
|
|
251
|
+
console.warn(`[super-pi] could not patch brace-expansion in ${copy.directory}: ${error instanceof Error ? error.message : String(error)}. It is Pi's own pinned dependency tree; \`npm audit\` will keep reporting it.`);
|
|
176
252
|
}
|
|
177
253
|
}
|
|
178
|
-
|
|
254
|
+
return { patched };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Run directly (`node scripts/patch-brace-expansion.mjs`) rather than imported.
|
|
258
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
259
|
+
await patchBraceExpansion();
|
|
179
260
|
}
|