@mmerterden/multi-agent-toolkit-mcp 3.14.0 → 3.15.0
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/CHANGELOG.md +192 -18
- package/README.md +78 -7
- package/README.tr.md +102 -7
- package/index.js +520 -160
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- package/tools/spawn-collect/index.js +141 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Opt-in egress and read policy.
|
|
2
|
+
//
|
|
3
|
+
// The web family opens whatever URL it is handed, and the context family
|
|
4
|
+
// indexes whatever file it is handed. On a developer's own machine that is the
|
|
5
|
+
// point. On a host that feeds the server instructions it did not write (a
|
|
6
|
+
// crawled page, a ticket, a PR description), the same reach turns into a way to
|
|
7
|
+
// read the cloud metadata endpoint, a service on localhost or the office
|
|
8
|
+
// network, or ~/.ssh.
|
|
9
|
+
//
|
|
10
|
+
// MCP_TOOLKIT_URL_POLICY=strict narrows it. Unset, every function here answers
|
|
11
|
+
// "allowed" without looking, so the default behaviour is exactly what it was.
|
|
12
|
+
//
|
|
13
|
+
// URL policy anything that is not http(s) is refused, and so is any host
|
|
14
|
+
// that is, or resolves to, a loopback, unspecified, link-local,
|
|
15
|
+
// private (RFC1918, CGNAT 100.64/10, IPv6 ULA fc00::/7, the
|
|
16
|
+
// deprecated IPv6 site-local fec0::/10),
|
|
17
|
+
// 0.0.0.0/8, IETF protocol (192.0.0/24), benchmarking
|
|
18
|
+
// (198.18/15), multicast or reserved (224/4, 240/4, ff00::/8)
|
|
19
|
+
// address, or a cloud metadata endpoint. An IPv6 address that
|
|
20
|
+
// carries an IPv4 one (mapped, compatible, translated, NAT64,
|
|
21
|
+
// 6to4, Teredo) is judged by the IPv4 address it carries.
|
|
22
|
+
// MCP_TOOLKIT_URL_ALLOW (comma-separated hosts, IPs and CIDRs)
|
|
23
|
+
// names exceptions: a listed host name, a literal IP inside a
|
|
24
|
+
// listed range, or a name whose every refused answer falls
|
|
25
|
+
// inside a listed range is let through. It never widens the
|
|
26
|
+
// scheme check, and it has no effect without strict.
|
|
27
|
+
// Index policy context_index, context_get and context_search refuse paths
|
|
28
|
+
// under the deny list. Strict brings a default list of
|
|
29
|
+
// credential stores. MCP_TOOLKIT_INDEX_DENY (comma-separated)
|
|
30
|
+
// adds to that list and applies with or without strict; a value
|
|
31
|
+
// that starts with "!" replaces the defaults instead, and one
|
|
32
|
+
// that leaves nothing denied is reported on stderr.
|
|
33
|
+
//
|
|
34
|
+
// The server applies the URL check to every request the browser context makes
|
|
35
|
+
// (navigations, subresources, popups, WebSockets), and again to the URL a
|
|
36
|
+
// navigation landed on. A DNS answer can still change between the check and
|
|
37
|
+
// the browser's own lookup.
|
|
38
|
+
|
|
39
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
40
|
+
import { isIP } from "node:net";
|
|
41
|
+
import { realpathSync } from "node:fs";
|
|
42
|
+
import { homedir } from "node:os";
|
|
43
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_INDEX_DENY = [
|
|
46
|
+
"~/.ssh", "~/.aws", "~/.gnupg", "~/Library/Keychains",
|
|
47
|
+
"~/.netrc", "~/.git-credentials", "~/.config/gh", "~/.docker", "~/.kube", "~/.npmrc",
|
|
48
|
+
"~/.config/gcloud", "~/.azure", "~/.pypirc", "~/.cargo/credentials", "~/.cargo/credentials.toml",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Hostnames that answer with instance credentials on the major clouds. The
|
|
52
|
+
// addresses behind them (169.254.169.254, fd00:ec2::254, 100.100.100.200) are
|
|
53
|
+
// refused by address as well, for a caller that uses the IP directly.
|
|
54
|
+
const METADATA_HOSTS = new Set([
|
|
55
|
+
"metadata",
|
|
56
|
+
"metadata.google.internal",
|
|
57
|
+
"metadata.goog",
|
|
58
|
+
"instance-data",
|
|
59
|
+
"instance-data.ec2.internal",
|
|
60
|
+
]);
|
|
61
|
+
const METADATA_ADDRESSES = new Set(["100.100.100.200"]);
|
|
62
|
+
|
|
63
|
+
// Anything other than "strict" or "off" is a typo in a security setting, so it
|
|
64
|
+
// is reported and treated as strict rather than silently ignored.
|
|
65
|
+
export function urlPolicy(env = process.env, warn = (m) => process.stderr.write(m)) {
|
|
66
|
+
const raw = String(env.MCP_TOOLKIT_URL_POLICY ?? "").trim().toLowerCase();
|
|
67
|
+
if (!raw || raw === "off") return "off";
|
|
68
|
+
if (raw !== "strict") warn(`multi-agent-toolkit: unknown MCP_TOOLKIT_URL_POLICY value "${raw}", applying strict\n`);
|
|
69
|
+
return "strict";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ipv6Groups(address) {
|
|
73
|
+
let a = address.toLowerCase().split("%")[0];
|
|
74
|
+
const dotted = a.match(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
75
|
+
if (dotted) {
|
|
76
|
+
const [p0, p1, p2, p3] = dotted.slice(1).map(Number);
|
|
77
|
+
a = `${a.slice(0, -dotted[0].length)}${((p0 << 8) | p1).toString(16)}:${((p2 << 8) | p3).toString(16)}`;
|
|
78
|
+
}
|
|
79
|
+
const [head, tail] = a.split("::");
|
|
80
|
+
const h = head ? head.split(":") : [];
|
|
81
|
+
if (tail === undefined) return h.map((g) => parseInt(g, 16));
|
|
82
|
+
const t = tail ? tail.split(":") : [];
|
|
83
|
+
return [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill("0"), ...t].map((g) => parseInt(g || "0", 16));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// IPv4-mapped IPv6 (::ffff:a.b.c.d) is judged as the IPv4 address it carries.
|
|
87
|
+
function normalizeIp(address) {
|
|
88
|
+
const ip = String(address).replace(/^\[|\]$/g, "");
|
|
89
|
+
const kind = isIP(ip);
|
|
90
|
+
if (kind === 4) return { kind: 4, parts: ip.split(".").map(Number) };
|
|
91
|
+
if (kind === 6) {
|
|
92
|
+
const g = ipv6Groups(ip);
|
|
93
|
+
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) {
|
|
94
|
+
return { kind: 4, parts: [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255] };
|
|
95
|
+
}
|
|
96
|
+
return { kind: 6, parts: g };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function blockedV4([a, b, c]) {
|
|
102
|
+
return a === 0 || a === 10 || a === 127
|
|
103
|
+
|| (a === 169 && b === 254)
|
|
104
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
105
|
+
|| (a === 192 && b === 168)
|
|
106
|
+
|| (a === 192 && b === 0 && c === 0)
|
|
107
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
108
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
109
|
+
|| a >= 224;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const v4From = (hi, lo) => [hi >> 8, hi & 255, lo >> 8, lo & 255];
|
|
113
|
+
|
|
114
|
+
// IPv6 forms that carry an IPv4 address a relay or translator will deliver to:
|
|
115
|
+
// NAT64 64:ff9b::/96, IPv4-compatible ::a.b.c.d, IPv4-translated
|
|
116
|
+
// ::ffff:0:a.b.c.d, 6to4 2002::/16 and Teredo 2001::/32 (server in bits 32-63,
|
|
117
|
+
// client inverted in the last 32).
|
|
118
|
+
function embeddedV4(g) {
|
|
119
|
+
const out = [];
|
|
120
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) out.push(v4From(g[6], g[7]));
|
|
121
|
+
if (g.slice(0, 6).every((x) => x === 0)) out.push(v4From(g[6], g[7]));
|
|
122
|
+
if (g.slice(0, 4).every((x) => x === 0) && g[4] === 0xffff && g[5] === 0) out.push(v4From(g[6], g[7]));
|
|
123
|
+
if (g[0] === 0x2002) out.push(v4From(g[1], g[2]));
|
|
124
|
+
if (g[0] === 0x2001 && g[1] === 0) out.push(v4From(g[2], g[3]), v4From(g[6] ^ 0xffff, g[7] ^ 0xffff));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isBlockedAddress(address) {
|
|
129
|
+
const n = normalizeIp(address);
|
|
130
|
+
if (!n) return false;
|
|
131
|
+
if (n.kind === 4) return blockedV4(n.parts) || METADATA_ADDRESSES.has(n.parts.join("."));
|
|
132
|
+
const g = n.parts;
|
|
133
|
+
if (g.slice(0, 7).every((x) => x === 0) && (g[7] === 0 || g[7] === 1)) return true;
|
|
134
|
+
if ((g[0] & 0xffc0) === 0xfe80) return true;
|
|
135
|
+
if ((g[0] & 0xffc0) === 0xfec0) return true;
|
|
136
|
+
if ((g[0] & 0xfe00) === 0xfc00) return true;
|
|
137
|
+
if ((g[0] & 0xff00) === 0xff00) return true;
|
|
138
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true;
|
|
139
|
+
return embeddedV4(g).some((v4) => blockedV4(v4) || METADATA_ADDRESSES.has(v4.join(".")));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function bitsOf(n) {
|
|
143
|
+
const width = n.kind === 4 ? 8 : 16;
|
|
144
|
+
return n.parts.map((p) => p.toString(2).padStart(width, "0")).join("");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function inCidr(address, cidr) {
|
|
148
|
+
const n = normalizeIp(address);
|
|
149
|
+
if (!n || n.kind !== cidr.kind) return false;
|
|
150
|
+
return bitsOf(n).slice(0, cidr.prefix) === cidr.bits;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Entries are host names (matched exactly, case-insensitively), IP literals,
|
|
154
|
+
// or CIDRs. A malformed entry is reported and dropped: a typo in an allow list
|
|
155
|
+
// must narrow it, never turn into "allow everything".
|
|
156
|
+
export function urlAllowList(env = process.env, warn = (m) => process.stderr.write(m)) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const raw of String(env.MCP_TOOLKIT_URL_ALLOW ?? "").split(",")) {
|
|
159
|
+
const entry = raw.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
160
|
+
if (!entry) continue;
|
|
161
|
+
const slash = entry.indexOf("/");
|
|
162
|
+
const ipPart = slash >= 0 ? entry.slice(0, slash).replace(/^\[|\]$/g, "") : entry;
|
|
163
|
+
const n = normalizeIp(ipPart);
|
|
164
|
+
if (slash < 0 && !n) {
|
|
165
|
+
if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(entry)) out.push({ host: entry });
|
|
166
|
+
else warn(`multi-agent-toolkit: ignoring MCP_TOOLKIT_URL_ALLOW entry "${raw.trim()}" (not a host, IP or CIDR)\n`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const max = isIP(ipPart) === 4 ? 32 : 128;
|
|
170
|
+
const prefixText = slash >= 0 ? entry.slice(slash + 1) : String(max);
|
|
171
|
+
const prefix = /^\d{1,3}$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
172
|
+
// A mapped IPv6 literal normalises to IPv4, so its prefix is re-based.
|
|
173
|
+
const effective = n && n.kind === 4 && max === 128 ? prefix - 96 : prefix;
|
|
174
|
+
if (!n || !(prefix >= 0 && prefix <= max) || effective < 0) {
|
|
175
|
+
warn(`multi-agent-toolkit: ignoring MCP_TOOLKIT_URL_ALLOW entry "${raw.trim()}" (not a valid CIDR)\n`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push({ kind: n.kind, prefix: effective, bits: bitsOf(n).slice(0, effective) });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hostAllowed = (host, allow) => allow.some((e) => e.host === host);
|
|
184
|
+
const addressAllowed = (address, allow) => allow.some((e) => e.kind && inCidr(address, e));
|
|
185
|
+
|
|
186
|
+
// null when the URL may be opened, otherwise the reason it may not.
|
|
187
|
+
export async function urlPolicyError(rawUrl, { policy = "off", lookup = dnsLookup, allow = [] } = {}) {
|
|
188
|
+
if (policy !== "strict") return null;
|
|
189
|
+
const refuse = (why) => `URL policy strict refuses ${rawUrl}: ${why}`;
|
|
190
|
+
let url;
|
|
191
|
+
try {
|
|
192
|
+
url = new URL(String(rawUrl));
|
|
193
|
+
} catch {
|
|
194
|
+
return refuse("not a valid URL");
|
|
195
|
+
}
|
|
196
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return refuse(`${url.protocol} is not http or https`);
|
|
197
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
198
|
+
if (hostAllowed(host, allow)) return null;
|
|
199
|
+
if (isIP(host)) {
|
|
200
|
+
if (addressAllowed(host, allow)) return null;
|
|
201
|
+
return isBlockedAddress(host) ? refuse("loopback, private, link-local or metadata address") : null;
|
|
202
|
+
}
|
|
203
|
+
if (host === "localhost" || host.endsWith(".localhost")) return refuse("loopback host");
|
|
204
|
+
if (METADATA_HOSTS.has(host)) return refuse("cloud metadata host");
|
|
205
|
+
let answers;
|
|
206
|
+
try {
|
|
207
|
+
answers = await lookup(host, { all: true });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return refuse(`could not resolve ${host} (${e.code || e.message}), so a local address cannot be ruled out`);
|
|
210
|
+
}
|
|
211
|
+
const bad = answers.find((a) => isBlockedAddress(a.address) && !addressAllowed(a.address, allow));
|
|
212
|
+
return bad ? refuse(`${host} resolves to ${bad.address}`) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function expandHome(p, home) {
|
|
216
|
+
if (p === "~") return home;
|
|
217
|
+
if (p.startsWith("~/")) return join(home, p.slice(2));
|
|
218
|
+
return p;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Symlinks are resolved on both sides, so a link that points into a denied
|
|
222
|
+
// directory is judged by where it points. A path that does not exist yet is
|
|
223
|
+
// resolved through its nearest existing ancestor, so /var and /private/var (or
|
|
224
|
+
// a symlinked parent) cannot make the same place look like two.
|
|
225
|
+
function real(p) {
|
|
226
|
+
const abs = resolve(p);
|
|
227
|
+
try {
|
|
228
|
+
return realpathSync.native(abs);
|
|
229
|
+
} catch {
|
|
230
|
+
const parent = dirname(abs);
|
|
231
|
+
return parent === abs ? abs : join(real(parent), basename(abs));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// APFS and HFS+ are case-insensitive by default, so on darwin ~/.SSH/id_rsa is
|
|
236
|
+
// ~/.ssh/id_rsa. realpath does not always return the on-disk spelling, so the
|
|
237
|
+
// comparison itself ignores case there.
|
|
238
|
+
const foldCase = (p, platform) => (platform === "darwin" ? p.toLowerCase() : p);
|
|
239
|
+
|
|
240
|
+
export function indexDenyList({ env = process.env, policy = "off", home = homedir(), warn = (m) => process.stderr.write(m) } = {}) {
|
|
241
|
+
const raw = String(env.MCP_TOOLKIT_INDEX_DENY ?? "").trim();
|
|
242
|
+
const replace = raw.startsWith("!");
|
|
243
|
+
const own = (replace ? raw.slice(1) : raw).split(",");
|
|
244
|
+
const entries = (replace ? own : raw ? [...DEFAULT_INDEX_DENY, ...own] : policy === "strict" ? DEFAULT_INDEX_DENY : [])
|
|
245
|
+
.map((e) => e.trim())
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
if (replace && !entries.length) {
|
|
248
|
+
const dropped = policy === "strict" ? ", including the credential stores the strict policy denies by default" : "";
|
|
249
|
+
warn(`multi-agent-toolkit: MCP_TOOLKIT_INDEX_DENY="${raw}" empties the index deny list; nothing is refused${dropped}\n`);
|
|
250
|
+
}
|
|
251
|
+
return entries.map((e) => real(resolve(expandHome(e, home))));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function indexDenied(path, denyList, platform = process.platform) {
|
|
255
|
+
if (!denyList.length) return null;
|
|
256
|
+
const target = foldCase(real(resolve(String(path))), platform);
|
|
257
|
+
const root = denyList.find((raw) => {
|
|
258
|
+
const d = foldCase(raw, platform);
|
|
259
|
+
return target === d || target.startsWith(d.endsWith(sep) ? d : d + sep);
|
|
260
|
+
});
|
|
261
|
+
return root ? `path denied by the index policy (${root}): ${path}` : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Browser launch options under the strict policy. Chromium sends loopback
|
|
265
|
+
// traffic around a proxy unless told otherwise, and loopback is exactly what
|
|
266
|
+
// strict refuses. WebRTC opens UDP sockets that no proxy or route sees, so
|
|
267
|
+
// Chromium is held to proxied transports and Firefox has peer connections off.
|
|
268
|
+
export function strictLaunchOptions(engineName, proxyUrl) {
|
|
269
|
+
if (engineName === "chromium") {
|
|
270
|
+
return {
|
|
271
|
+
headless: true,
|
|
272
|
+
proxy: { server: proxyUrl, bypass: "<-loopback>" },
|
|
273
|
+
args: [
|
|
274
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
275
|
+
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (engineName === "firefox") {
|
|
280
|
+
return { headless: true, proxy: { server: proxyUrl }, firefoxUserPrefs: { "media.peerconnection.enabled": false } };
|
|
281
|
+
}
|
|
282
|
+
return { headless: true, proxy: { server: proxyUrl } };
|
|
283
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// spawn-based collector for the long-runners that also want incremental output
|
|
2
|
+
// (progress heartbeats read the last line). Interleaves stdout+stderr the way a
|
|
3
|
+
// terminal would, honors an AbortSignal, and never rejects.
|
|
4
|
+
//
|
|
5
|
+
// Output is capped at the 64MB the execSync it replaced enforced via maxBuffer:
|
|
6
|
+
// unbounded `output += chunk` on a verbose xcodebuild can exceed V8's max
|
|
7
|
+
// string length, and that throw fires inside a stream 'data' handler - outside
|
|
8
|
+
// the CallTool try/catch - killing the whole stdio server. The oldest chunks
|
|
9
|
+
// are dropped; errors and test verdicts land at the tail of a build log.
|
|
10
|
+
//
|
|
11
|
+
// A timeout or an abort sends SIGTERM, and SIGKILL after KILL_GRACE_MS if the
|
|
12
|
+
// child is still there. A child that traps or ignores SIGTERM would otherwise
|
|
13
|
+
// hold the call open forever, and the timeout would bound nothing.
|
|
14
|
+
//
|
|
15
|
+
// The command runs in its own process group and the signal goes to the group.
|
|
16
|
+
// `/bin/sh -c` forks: a pipeline or a background job is a grandchild that a
|
|
17
|
+
// signal to the shell never reaches, and it keeps the output pipe open, so the
|
|
18
|
+
// call would wait for it however long it runs. Once the shell has exited, for
|
|
19
|
+
// whatever reason, the result is returned after EXIT_GRACE_MS even if the pipe
|
|
20
|
+
// has not closed: a background job the command started keeps it open, and
|
|
21
|
+
// waiting for it would turn a clean exit into a timeout. The timeout stops
|
|
22
|
+
// counting at exit, so timedOut means the timer fired while the command was
|
|
23
|
+
// still running. Whatever is left in the group once the result is returned is
|
|
24
|
+
// sent SIGTERM, then SIGKILL after the grace period, so no background job
|
|
25
|
+
// outlives the call that started it.
|
|
26
|
+
//
|
|
27
|
+
// Both pipes decode as UTF-8 with their own decoder, so a multibyte character
|
|
28
|
+
// split across two chunks arrives whole.
|
|
29
|
+
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
export const SPAWN_OUTPUT_CAP = 64 * 1024 * 1024;
|
|
33
|
+
export const KILL_GRACE_MS = 5000;
|
|
34
|
+
export const EXIT_GRACE_MS = 250;
|
|
35
|
+
|
|
36
|
+
// Process groups still running, so a server shutting down can take them with
|
|
37
|
+
// it: a detached group does not receive the terminal's SIGINT.
|
|
38
|
+
const liveGroups = new Set();
|
|
39
|
+
|
|
40
|
+
function groupAlive(child) {
|
|
41
|
+
if (!child.pid) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(-child.pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function signalGroup(child, sig) {
|
|
51
|
+
if (!child.pid) return;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(-child.pid, sig);
|
|
54
|
+
} catch {
|
|
55
|
+
try { child.kill(sig); } catch { /* already gone */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function killAllSpawned(sig = "SIGKILL") {
|
|
60
|
+
for (const child of liveGroups) signalGroup(child, sig);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function spawnCollect(cmd, { timeout = 600000, signal, env, cwd, onLine, killGraceMs = KILL_GRACE_MS } = {}) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const child = spawn("/bin/sh", ["-c", cmd], { env, cwd, detached: true });
|
|
66
|
+
liveGroups.add(child);
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
let truncated = false;
|
|
70
|
+
let timedOut = false;
|
|
71
|
+
let aborted = false;
|
|
72
|
+
let settled = false;
|
|
73
|
+
let killTimer = null;
|
|
74
|
+
let exitTimer = null;
|
|
75
|
+
let exited = false;
|
|
76
|
+
const stop = () => {
|
|
77
|
+
signalGroup(child, "SIGTERM");
|
|
78
|
+
// Not cleared when the result is returned: a grandchild that ignores
|
|
79
|
+
// SIGTERM can outlive the shell, and still gets SIGKILL on schedule.
|
|
80
|
+
killTimer ??= setTimeout(() => { signalGroup(child, "SIGKILL"); liveGroups.delete(child); }, killGraceMs);
|
|
81
|
+
killTimer.unref?.();
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, timeout);
|
|
84
|
+
const onAbort = () => { aborted = true; stop(); };
|
|
85
|
+
if (signal) {
|
|
86
|
+
if (signal.aborted) onAbort();
|
|
87
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
}
|
|
89
|
+
const finish = (result) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
clearTimeout(exitTimer);
|
|
94
|
+
if (exited && !killTimer && groupAlive(child)) stop();
|
|
95
|
+
if (!killTimer) liveGroups.delete(child);
|
|
96
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
97
|
+
resolve(result);
|
|
98
|
+
};
|
|
99
|
+
const collect = (chunk) => {
|
|
100
|
+
const s = chunk;
|
|
101
|
+
chunks.push(s);
|
|
102
|
+
size += s.length;
|
|
103
|
+
while (size > SPAWN_OUTPUT_CAP && chunks.length > 1) {
|
|
104
|
+
size -= chunks[0].length;
|
|
105
|
+
chunks.shift();
|
|
106
|
+
truncated = true;
|
|
107
|
+
}
|
|
108
|
+
if (size > SPAWN_OUTPUT_CAP) {
|
|
109
|
+
chunks[0] = chunks[0].slice(size - SPAWN_OUTPUT_CAP);
|
|
110
|
+
size = SPAWN_OUTPUT_CAP;
|
|
111
|
+
truncated = true;
|
|
112
|
+
}
|
|
113
|
+
if (onLine) {
|
|
114
|
+
const lines = s.split("\n");
|
|
115
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
116
|
+
const line = lines[i].trim();
|
|
117
|
+
if (line) { onLine(line); break; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
child.stdout.setEncoding("utf8");
|
|
122
|
+
child.stderr.setEncoding("utf8");
|
|
123
|
+
child.stdout.on("data", collect);
|
|
124
|
+
child.stderr.on("data", collect);
|
|
125
|
+
child.on("error", (e) => {
|
|
126
|
+
finish({ code: 127, output: `${chunks.join("")}\n${e.message}`, truncated, timedOut, aborted });
|
|
127
|
+
});
|
|
128
|
+
child.on("exit", (code) => {
|
|
129
|
+
exited = true;
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
exitTimer = setTimeout(() => {
|
|
132
|
+
child.stdout.destroy();
|
|
133
|
+
child.stderr.destroy();
|
|
134
|
+
finish({ code: code ?? 1, output: chunks.join(""), truncated, timedOut, aborted });
|
|
135
|
+
}, EXIT_GRACE_MS);
|
|
136
|
+
});
|
|
137
|
+
child.on("close", (code) => {
|
|
138
|
+
finish({ code: code ?? 1, output: chunks.join(""), truncated, timedOut, aborted });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|