@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.
@@ -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
- // 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.
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 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.
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 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.
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
- * Replaces one installed copy with the patched release.
61
+ * Reads a gzipped tar into a map of path -> contents.
79
62
  *
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.
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 patch(directory) {
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 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");
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
- cpSync(unpacked, directory, { recursive: true });
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 the lockfile still describes a tree npm
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
- * Rewrites the lockfile's entry for a patched package.
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
- * 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.
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 updateLockfile(lockKey, version, integrity) {
144
- const lockPath = join(repositoryRoot, "package-lock.json");
145
- if (!existsSync(lockPath)) return false;
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
- 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 {
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.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.");
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
- // 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.`);
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
- 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.");
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
  }