@evandu/super-pi 0.1.97 → 0.1.99
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 +21 -1
- 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 +5 -4
- package/scripts/diagnose.mjs +217 -0
- package/scripts/patch-brace-expansion.mjs +155 -74
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// `super_pi diagnose` — where the time goes when a message is slow.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS AND WHY IT IS A COMMAND. The complaint that produced it was "it is slow, take this
|
|
4
|
+
// many seconds, here are three of them" — and answering it from the developer's machine was wrong
|
|
5
|
+
// twice, because the developer machine is fast and a wall clock does not transfer. So the measurement
|
|
6
|
+
// has to run where the problem is, which means it has to ship with the product.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT PRINTS, AND WHY THAT SHAPE. Three spans, because they belong to DIFFERENT OWNERS and
|
|
9
|
+
// conflating them is how two wrong root causes got written down:
|
|
10
|
+
//
|
|
11
|
+
// 1. picker data -> Super Pi (the catalog read)
|
|
12
|
+
// 2. send -> waiting -> Super Pi (session build + first prompt)
|
|
13
|
+
// 3. waiting -> text -> the MODEL (not ours, and it grows with the conversation)
|
|
14
|
+
//
|
|
15
|
+
// And under each, the STRUCTURE — how many Pi processes were started, how many sessions were built,
|
|
16
|
+
// how many were reused. Structure is the part that survives the difference between a fast machine and a
|
|
17
|
+
// slow one; seconds are not. If the process count is zero and a span is still long, the time is not in
|
|
18
|
+
// Super Pi, and this says so rather than leaving it to be guessed at.
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
|
|
25
|
+
/** The Node version Pi requires. Below it, module loading and jiti transpiling are measurably slower. */
|
|
26
|
+
const PI_NODE_REQUIREMENT = "22.19.0";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compares two dotted versions.
|
|
30
|
+
*
|
|
31
|
+
* `localeCompare` would do this lexically and get "22.9.0" > "22.19.0" wrong - 9 sorts after 19 as
|
|
32
|
+
* characters - and a person whose Node is 22.9 would be told they satisfy a requirement they do not.
|
|
33
|
+
*/
|
|
34
|
+
function atLeast(actual, required) {
|
|
35
|
+
const left = String(actual).split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
36
|
+
const right = String(required).split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
37
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
38
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
39
|
+
if (difference !== 0) return difference >= 0;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs the diagnosis against a throwaway server and returns what it measured.
|
|
46
|
+
*
|
|
47
|
+
* A THROWAWAY SERVER, not the running one: it starts its own on a private port with its own data
|
|
48
|
+
* directory, so this never disturbs a session someone is in the middle of, and never writes into
|
|
49
|
+
* their real conversations. It also means the numbers describe a COLD machine's first conversation,
|
|
50
|
+
* which is the case being complained about.
|
|
51
|
+
*/
|
|
52
|
+
export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {} } = {}) {
|
|
53
|
+
const dataDir = mkdtempSync(join(tmpdir(), "super-pi-diagnose-"));
|
|
54
|
+
const port = 35000 + Math.floor(Math.random() * 4000);
|
|
55
|
+
const server = spawn(process.execPath, [join(repositoryRoot, "dist/server/index.js")], {
|
|
56
|
+
cwd: repositoryRoot,
|
|
57
|
+
env: {
|
|
58
|
+
...process.env,
|
|
59
|
+
SUPER_PI_PORT: String(port),
|
|
60
|
+
SUPER_PI_HOST: "127.0.0.1",
|
|
61
|
+
SUPER_PI_RUNTIME: "pi",
|
|
62
|
+
SUPER_PI_DATA_DIR: dataDir,
|
|
63
|
+
SUPER_PI_PERSISTENCE: "json",
|
|
64
|
+
// The latency trace is what turns "it was slow" into "this step was slow".
|
|
65
|
+
SUPER_PI_TRACE_LATENCY: "1",
|
|
66
|
+
},
|
|
67
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
let log = "";
|
|
70
|
+
server.stdout.on("data", (chunk) => { log += chunk.toString(); });
|
|
71
|
+
server.stderr.on("data", (chunk) => { log += chunk.toString(); });
|
|
72
|
+
const stop = () => { try { server.kill(); } catch { /* already gone */ } };
|
|
73
|
+
|
|
74
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
75
|
+
const result = {
|
|
76
|
+
node: process.versions.node,
|
|
77
|
+
nodeOk: atLeast(process.versions.node, PI_NODE_REQUIREMENT),
|
|
78
|
+
required: PI_NODE_REQUIREMENT,
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
onProgress("starting a throwaway server...");
|
|
82
|
+
const bootStart = Date.now();
|
|
83
|
+
let healthy = false;
|
|
84
|
+
for (let attempt = 0; attempt < 600; attempt += 1) {
|
|
85
|
+
try { if ((await fetch(`http://127.0.0.1:${port}/api/health`)).ok) { healthy = true; break; } } catch { /* not up yet */ }
|
|
86
|
+
await sleep(50);
|
|
87
|
+
}
|
|
88
|
+
result.bootMs = Date.now() - bootStart;
|
|
89
|
+
if (!healthy) {
|
|
90
|
+
result.error = "the server did not become healthy";
|
|
91
|
+
result.log = log.slice(-2000);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { WebSocket } = await import("ws");
|
|
96
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
|
97
|
+
await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", reject); });
|
|
98
|
+
const events = [];
|
|
99
|
+
socket.on("message", (raw) => { try { events.push(JSON.parse(raw.toString())); } catch { /* not json */ } });
|
|
100
|
+
const send = (command) => socket.send(JSON.stringify(command));
|
|
101
|
+
const waitFor = async (predicate, label, timeoutMs = 240000) => {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const hit = events.find(predicate);
|
|
105
|
+
if (hit) return hit;
|
|
106
|
+
await sleep(15);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
onProgress("creating a conversation...");
|
|
112
|
+
send({ type: "conversation.create", workspaceId: "workspace_default" });
|
|
113
|
+
await waitFor((e) => e.type === "state" && e.state.conversations.length > 0, "a conversation");
|
|
114
|
+
const conversation = events.filter((e) => e.type === "state").at(-1).state.conversations[0];
|
|
115
|
+
|
|
116
|
+
// Span 1: the picker.
|
|
117
|
+
const pickerStart = Date.now();
|
|
118
|
+
send({ type: "catalog.fetch", conversationId: conversation.id });
|
|
119
|
+
const catalog = await waitFor((e) => e.type === "model.catalog", "the model catalog");
|
|
120
|
+
result.pickerMs = Date.now() - pickerStart;
|
|
121
|
+
result.catalogSource = catalog.catalog?.source ?? catalog.source ?? "unknown";
|
|
122
|
+
result.models = (catalog.catalog?.models ?? catalog.models ?? []).length;
|
|
123
|
+
|
|
124
|
+
// Spans 2 and 3, per turn.
|
|
125
|
+
result.turns = [];
|
|
126
|
+
for (let turn = 1; turn <= turns; turn += 1) {
|
|
127
|
+
onProgress(`sending turn ${turn} of ${turns}...`);
|
|
128
|
+
const mark = events.length;
|
|
129
|
+
const logMark = log.length;
|
|
130
|
+
const sent = Date.now();
|
|
131
|
+
send({ type: "message.send", conversationId: conversation.id, content: `第${turn}问:请简短回答。` });
|
|
132
|
+
await waitFor((e, i) => i >= mark && e.type === "state"
|
|
133
|
+
&& e.state.conversations.find((c) => c.id === conversation.id)?.status === "waiting", `turn ${turn} to report waiting`);
|
|
134
|
+
const waiting = Date.now();
|
|
135
|
+
await waitFor((e, i) => i >= mark && e.type === "message.delta" && e.conversationId === conversation.id, `turn ${turn}'s first character`);
|
|
136
|
+
const text = Date.now();
|
|
137
|
+
// Let the turn finish so the next one is a new turn rather than a queued one.
|
|
138
|
+
while (Date.now() < text + 120000) {
|
|
139
|
+
const done = events.slice(mark).some((e) => e.type === "state"
|
|
140
|
+
&& e.state.conversations.find((c) => c.id === conversation.id)?.status === "completed");
|
|
141
|
+
if (done) break;
|
|
142
|
+
await sleep(30);
|
|
143
|
+
}
|
|
144
|
+
await sleep(250);
|
|
145
|
+
const added = log.slice(logMark);
|
|
146
|
+
result.turns.push({
|
|
147
|
+
turn,
|
|
148
|
+
toWaitingMs: waiting - sent,
|
|
149
|
+
toTextMs: text - sent,
|
|
150
|
+
modelMs: text - waiting,
|
|
151
|
+
spawned: (added.match(/phase=pi\.spawned/g) ?? []).length,
|
|
152
|
+
built: (added.match(/phase=session\.build\.begin/g) ?? []).length,
|
|
153
|
+
reused: (added.match(/phase=session\.reused/g) ?? []).length,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
socket.close();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
159
|
+
result.log = log.slice(-2000);
|
|
160
|
+
} finally {
|
|
161
|
+
stop();
|
|
162
|
+
await sleep(200);
|
|
163
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Renders a diagnosis as the report a person reads. */
|
|
169
|
+
export function renderReport(result) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
const ms = (value) => (typeof value === "number" ? `${String(value).padStart(6)}ms` : " ?");
|
|
172
|
+
|
|
173
|
+
lines.push(`Super Pi 延迟诊断 — ${new Date().toISOString()}`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(` Node ${result.node} (Pi 要求 >= ${result.required}:${result.nodeOk ? "满足" : "过低"})`);
|
|
176
|
+
lines.push(` server 就绪耗时 ${ms(result.bootMs)}`);
|
|
177
|
+
|
|
178
|
+
if (result.error) {
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push(` 失败:${result.error}`);
|
|
181
|
+
if (result.log) lines.push(result.log.split("\n").map((l) => ` | ${l}`).join("\n"));
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push(" 每一段耗时 耗时 归属");
|
|
187
|
+
lines.push(` 1. picker 有数据 ${ms(result.pickerMs)} Super Pi (catalog 读取)`);
|
|
188
|
+
for (const turn of result.turns ?? []) {
|
|
189
|
+
lines.push(` 第${turn.turn}轮 发送 -> 等待中 ${ms(turn.toWaitingMs)} Super Pi (建会话 + 首个 prompt)`);
|
|
190
|
+
lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
|
|
191
|
+
}
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push(` catalog: ${result.catalogSource},${result.models} 个模型`);
|
|
194
|
+
|
|
195
|
+
const spawned = (result.turns ?? []).reduce((sum, t) => sum + t.spawned, 0);
|
|
196
|
+
const built = (result.turns ?? []).reduce((sum, t) => sum + t.built, 0);
|
|
197
|
+
const reused = (result.turns ?? []).reduce((sum, t) => sum + t.reused, 0);
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push(" 结构计数 —— 快机器和慢机器上一样的那部分:");
|
|
200
|
+
lines.push(` 启动的 Pi 进程数: ${spawned}`);
|
|
201
|
+
lines.push(` 建立的会话数: ${built}`);
|
|
202
|
+
lines.push(` 复用的会话数: ${reused}`);
|
|
203
|
+
|
|
204
|
+
lines.push("");
|
|
205
|
+
if (!result.nodeOk) {
|
|
206
|
+
lines.push(` Node 版本低于 Pi 的要求(>= ${result.required})。这一条本身就可能解释了前面几段的`);
|
|
207
|
+
lines.push(" 大部分时间:模块加载和 jiti 转译都会更慢。请先升级 Node 再跑一次——否则这份报告");
|
|
208
|
+
lines.push(" 描述的是一台 Pi 本来就不该在上面运行的机器。");
|
|
209
|
+
lines.push("");
|
|
210
|
+
}
|
|
211
|
+
lines.push(" 怎么看这份报告:");
|
|
212
|
+
lines.push(" - 「启动的 Pi 进程数」是最关键的那个数。它是 0,说明 Super Pi 正在用 Pi 的 SDK,");
|
|
213
|
+
lines.push(" 会话被复用了;如果每一轮都很大,说明会话层根本没生效。");
|
|
214
|
+
lines.push(" - 第 1、2 段是 Super Pi 自己的。如果这两段是秒级,这份报告就是该发出来的东西。");
|
|
215
|
+
lines.push(" - 第 3 段是模型,会随对话变长。Super Pi 改不了它。");
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -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
|
}
|