iterate-skill-installer 3.4.1 → 3.4.4
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/cli.js +4 -1
- package/lib/installer.js +343 -19
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -56,7 +56,10 @@ async function run() {
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
const code = await main(options);
|
|
59
|
-
|
|
59
|
+
// Normalize: a null/non-numeric code (main should never produce one, but a
|
|
60
|
+
// signal-killed child upstream does) must surface as a failure, never as
|
|
61
|
+
// the success exit 0 that `process.exit(null)` would yield.
|
|
62
|
+
process.exit(typeof code === 'number' ? code : 1);
|
|
60
63
|
} catch (err) {
|
|
61
64
|
console.error(`\nUnexpected error: ${err.message}`);
|
|
62
65
|
process.exit(1);
|
package/lib/installer.js
CHANGED
|
@@ -36,12 +36,25 @@ const readline = require('node:readline');
|
|
|
36
36
|
|
|
37
37
|
const GITHUB_OWNER = 'jingzhao-l';
|
|
38
38
|
const GITHUB_REPO = 'iterate-skill';
|
|
39
|
-
|
|
39
|
+
// NOTE: deliberately the *release list*, not `/releases/latest`.
|
|
40
|
+
// `/releases/latest` resolves to "newest non-prerelease by published_at" for the
|
|
41
|
+
// whole repository, and this repository also publishes releases for the
|
|
42
|
+
// harness/plugin sub-projects, which carry no iterate-skill.tar.gz. When such a
|
|
43
|
+
// release is the most recently published one, `/releases/latest` returns it and
|
|
44
|
+
// every install aborts with "Release is missing the iterate-skill.tar.gz asset."
|
|
45
|
+
// Listing releases and picking the newest one that actually carries both assets
|
|
46
|
+
// is immune to what any other release in the repo was published last.
|
|
47
|
+
const RELEASES_API_URL =
|
|
48
|
+
`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=100`;
|
|
40
49
|
|
|
41
50
|
// Release asset filenames — named constants so the magic strings are not
|
|
42
51
|
// scattered through the download / verify pipeline.
|
|
43
52
|
const TARBALL_ASSET_NAME = 'iterate-skill.tar.gz';
|
|
44
53
|
const CHECKSUMS_ASSET_NAME = 'SHA256SUMS.txt';
|
|
54
|
+
// Detached ASCII-armored GPG signature over the tarball, published alongside
|
|
55
|
+
// it in the release. When a local `gpg` binary is available the installer
|
|
56
|
+
// verifies authenticity (not just integrity) against the embedded signing key.
|
|
57
|
+
const TARBALL_SIGNATURE_ASSET_NAME = 'iterate-skill.tar.gz.asc';
|
|
45
58
|
|
|
46
59
|
// Hard cap on a single curl download: a misbehaving CDN or a corrupt release
|
|
47
60
|
// must never hang the installer forever.
|
|
@@ -73,6 +86,69 @@ let CURL_SUPPORTS_FAIL_WITH_BODY = null; // lazily probed once
|
|
|
73
86
|
// and the published npm version always stay in sync.
|
|
74
87
|
const VERSION = require('../package.json').version;
|
|
75
88
|
|
|
89
|
+
// ASCII-armored GPG public key of the iterate-skill signing identity
|
|
90
|
+
// (uid: jingzhao-l (sign-github) <ET_lin@outlook.com>, key
|
|
91
|
+
// 0929EA31DF4F7429F63FC53189D88B1D043A1298). The release tarball `.asc`
|
|
92
|
+
// signature is verified against THIS key. It must stay in sync with the
|
|
93
|
+
// GPG_PRIVATE_KEY repo secret used by `.github/workflows/release.yml` to sign
|
|
94
|
+
// the release artifacts — if the release is re-keyed, this constant must be
|
|
95
|
+
// updated in the same release.
|
|
96
|
+
const ITERATE_SIGNING_PUBLIC_KEY = [
|
|
97
|
+
'-----BEGIN PGP PUBLIC KEY BLOCK-----',
|
|
98
|
+
'',
|
|
99
|
+
'mQINBGq45VQBEACdjAoLYyfgPpHjvscmGqxlSsBkcBvSAoGHdCI0p2Rn5cBDaPie',
|
|
100
|
+
'oPU17VmUiK4FBZf8FcaX0L+EeMRO4Bcj5NgoFaSgQPK0YarvoPssClNiWf71hDlg',
|
|
101
|
+
'QmC5IlwM4WuVUeKi3+YoPmRSf0sYHzSYM7vEIoCFzEilYi4iEK/NMihNSktlUsQx',
|
|
102
|
+
'jhIaXtnVJi+7GkO+dhckKmIHhcR76dUfIAsS/R0RzzH4ZXfuKi+B94mfCntURpM4',
|
|
103
|
+
'G+NrxZx7Xv5UDpv9XsrmiWKzNpT+Th9GbNQREjrT1mmKbMEOmD/PWTqxNycJfgdW',
|
|
104
|
+
'hWA++1oassOib3jd44+z5f7FKpp//C+SK8V7vuxNI0jRM/VYrbyrfON30hHwtbFM',
|
|
105
|
+
'0J/quDsAUzlOBNzNVPAyvGsOuSEULFtjaJ2q+JYjF+ZKDPy+lyY8V9sbYrqCCUb9',
|
|
106
|
+
'v3wPn4tDvat30Q0A0rheMZPTMO7tRNSzFOd25H0w3ZF3F5Np8D/aX9VHMkA12xTm',
|
|
107
|
+
'l7gpb1OIEJ1vdQl0twiF6SDz5jsteHfdUXha6CtM7tv7IZ4MIZi/qPkvv4zOKmsE',
|
|
108
|
+
'utLUs1alD671Eez2sQow8NO5IXfd7bX2d34kU5JiM1tF9qhBxOZlpzd/Vm8FZS4V',
|
|
109
|
+
'URF6Y7myW4ildqBeMzLt0to8WjHnHoV4v9rh5581qWEqvNIJ4/R3lKRhxwARAQAB',
|
|
110
|
+
'tC1qaW5nemhhby1sIChzaWduLWdpdGh1YikgPEVUX2xpbkBvdXRsb29rLmNvbT6J',
|
|
111
|
+
'Am0EEwEIAFcWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
|
|
112
|
+
'YW51MiwyLjUrMS4xMiwwLDMCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AA',
|
|
113
|
+
'CgkQidiLHQQ6EpharQ//VhcNiug3cHsgvb/tTqWp1CQV8heSfqoKrW51RPhcGAHW',
|
|
114
|
+
'VMHpbPRO0wBKKE5mybyGAWhGDhh5mZt1MxnBN3lC7RsWBLEaXyJAqW4UPjR5LN8Q',
|
|
115
|
+
'scapkCzFwrF5lisELdqKqkd/ACKR8h6U/fBf0eKE+TMDSrXZ/LkRcFRJErfsC7rx',
|
|
116
|
+
'hy1WQnzQBT2+86HmfW9rrw5RSyCp8MZ0TJhYr0ZdgB4zvLwvVYQCnlRaskkLjGrh',
|
|
117
|
+
'6vUHAjDCUwoDFEpecadCJg34cOEAMRjnTt6Q0t8SnVHDH9PLq1MwGON2VzuSp5rY',
|
|
118
|
+
'rthT3+VRzbzGpBu4wl4/GJiWJMfGTusEu8Ver6MTwHx9pFBDtH3cawIB5BT1RrXk',
|
|
119
|
+
'cYdhtjzJ61RXIrSCeD8yoxEgDOj73Ll6oQ4+fJ+EpOc+SvP9FREeQ4k/uc8MpwtV',
|
|
120
|
+
'YyD/6EPJu1lLMAzgd2Xm2ljokTRhm/Blft9Y0OEEWzsoDGv+jr3Jb3Dgw62OuL6B',
|
|
121
|
+
'pLiZ5XNCYBHYhQhtleGnSpJtD9ooi1UUTbVZftunzYGKafMCgc9nnzPIGVtlzX+d',
|
|
122
|
+
'K10CtPOX7ylS+lKukaIOSStGGSl3I2Fd66yb3ujIH6n/KAKLfMmmy48pxB3+t6WW',
|
|
123
|
+
'StdD7QEASWIyW2wTrq7RyDwmzWMSFtgPzCOWFmcQfFykkvvQEqxMeyCrcPY6ZIy5',
|
|
124
|
+
'Ag0EarjlVAEQAOjPGVDb8zGIc7XQelHhjyd8yLCVpNBWwYLmaSLfI+EQsfVVDJqT',
|
|
125
|
+
'VAAeO82woHELPun06lbJRW59eH8BkVgzGhNkb5vKhrdvmZydYElC1NuRB9ag6/k/',
|
|
126
|
+
'0IaLwedKZscy1k3oG2LqsayzUO3L2d8BxO8zdLEmIl7FqtTdsYwj6DDRgZdA4Aj0',
|
|
127
|
+
'VoUXOgWaR+7qA9GHnnucrE5n0zhrTd7F3mtZErWr6Edo/V9EHQ1PszsQTVH2artr',
|
|
128
|
+
'lYJWjSsAv/ajEvAjaZ1mJoDvz/UzUk7hCCPeNcpy41SpDb3uey38qqxLYOGVgEeD',
|
|
129
|
+
'7JTrhC51VNHj2CCxSgyrlvED+resJtgWnE65Sa8g9cGAVpXlOBRQrPZGMHS+BcAg',
|
|
130
|
+
'9lQExMGrWt76ZPgT5Gygzj09oGx1q6/IyATphit4TblFGl2z1JDnlUQqfz/aCmZN',
|
|
131
|
+
'Vg+fKBPQ93dLLGPKpmY022X3abJ197VaQ0WWB2cA8pcrcfJ8GY7Lm8xjm6nY/cER',
|
|
132
|
+
'RQl72dE1NJsPGrp3Ad/s7fAJuEdR8UmMUPLDQRpiNRTcK6RC2AaRD7wpdyG/1csF',
|
|
133
|
+
'P30IIY97aVSjD5nnkrHxNQKZ17yPef+bFIoJ7OS+WhLZKcr7P0DfgJoTbKc6GK6M',
|
|
134
|
+
'ScQm5N7lRJ9Mfzu4R6576hDrgb3fmgflmpKOIVrZ6GkTGDvQ/FE16QdrABEBAAGJ',
|
|
135
|
+
'AlIEGAEIADwWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
|
|
136
|
+
'YW51MiwyLjUrMS4xMiwwLDMCGwwACgkQidiLHQQ6Epi4aw//Qu00vxGtvRb+VQl9',
|
|
137
|
+
'lMZLwIP2AgB0lAgKAqYeK6jZh/15GAKJqRh0u2jdgqXj2Sfm79X7Qwn7wAuFUAmx',
|
|
138
|
+
'D1eegOtdAnEP6O8DUtZWWmy2TSRIqjfTcGXlZ12WHiOwwdG5VOUZERWi/rPj0zTs',
|
|
139
|
+
'5V1H4qyPOqgrFx9nNvavzo3zeJVpwYuuFkT2Ne0cZLXGglCQ6MJtuK0Qwk6iYsvy',
|
|
140
|
+
'p3eZ1YCKmAi1v0UFojtFHqJEAsc3PnZb+48veE9b2whrL9DIIkNrrFlfFC1cjm7T',
|
|
141
|
+
'wuRQuH6aYyrRoZiLxgwkW5xmc1Biitw7bMIX6eqYVn8hb1lQUKhTL8aZ2xQa6IsJ',
|
|
142
|
+
'RHIJFYeEIEUtIl/GKQ3MeHQlJrXsfnZ1e8MHgwgMw3o4Nq4xww3Ch0pddYhBskmV',
|
|
143
|
+
'/QUaOHVqmuur9dnRvx9L+FGbzHEjvYDr0MkSe30hUhyBIg1uOLd2elATB/wg33Ow',
|
|
144
|
+
'vcdqgewqpYdSg6g6KZYl6NhmWWNEMgX8KITUXFoTiV5CrSsrptBPJWsyIq+CuseL',
|
|
145
|
+
'CKFdMHrkzbjFLGfdiPqykwttwHBAEk01aWArDP65gXRXmxGzDHVkA7Px1hdo/kMo',
|
|
146
|
+
'Ouw6bEGpHtx7UJJMSMA9ywbTrOyaG4xzVDa7ixUslFtgxts1R/eLoC4I11grxE50',
|
|
147
|
+
'YVXa4IEQs7aBxKO+n+T2AvUYiKQ=',
|
|
148
|
+
'=DYXk',
|
|
149
|
+
'-----END PGP PUBLIC KEY BLOCK-----',
|
|
150
|
+
].join('\n');
|
|
151
|
+
|
|
76
152
|
const ITERATE_BANNER = [
|
|
77
153
|
'██╗████████╗███████╗██████╗ █████╗ ████████╗███████╗',
|
|
78
154
|
'██║╚══██╔══╝██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝',
|
|
@@ -262,6 +338,40 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
|
|
|
262
338
|
} else {
|
|
263
339
|
await runCommand('curl', args);
|
|
264
340
|
}
|
|
341
|
+
// `--max-filesize` is best-effort by curl's own documentation: it is only
|
|
342
|
+
// enforced when the server advertises a Content-Length, so a response sent
|
|
343
|
+
// with chunked transfer encoding streams to disk unbounded. Re-stat after the
|
|
344
|
+
// fact so the cap holds regardless of how the body was framed, and so a
|
|
345
|
+
// truncated-then-huge body cannot be hashed or extracted.
|
|
346
|
+
enforceDownloadedSize(destPath, maxBytes);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Fail closed when a completed download exceeds ``maxBytes``.
|
|
351
|
+
*
|
|
352
|
+
* The oversized file is removed so a later retry cannot silently reuse it.
|
|
353
|
+
* @param {string} destPath
|
|
354
|
+
* @param {number} maxBytes
|
|
355
|
+
*/
|
|
356
|
+
function enforceDownloadedSize(destPath, maxBytes) {
|
|
357
|
+
let size;
|
|
358
|
+
try {
|
|
359
|
+
size = fs.statSync(destPath).size;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// curl already reported a missing output file as a failure; nothing to
|
|
362
|
+
// check here, and re-reporting would mask the real error.
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (size > maxBytes) {
|
|
366
|
+
try {
|
|
367
|
+
fs.unlinkSync(destPath);
|
|
368
|
+
} catch {
|
|
369
|
+
// Best effort: the size violation below is what the user must act on.
|
|
370
|
+
}
|
|
371
|
+
throw new InstallerError(
|
|
372
|
+
`Downloaded file is ${size} bytes, above the ${maxBytes} byte safety cap — refusing to proceed.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
265
375
|
}
|
|
266
376
|
|
|
267
377
|
function sha256File(filePath) {
|
|
@@ -274,6 +384,52 @@ function sha256File(filePath) {
|
|
|
274
384
|
});
|
|
275
385
|
}
|
|
276
386
|
|
|
387
|
+
// Best-effort GPG authenticity verification of a downloaded artifact against
|
|
388
|
+
// the embedded signing key. Returns one of:
|
|
389
|
+
// 'verified' — `gpg` present and the detached signature is valid.
|
|
390
|
+
// 'invalid' — `gpg` present, but the signature did not verify (key
|
|
391
|
+
// mismatch or tampered signature). Reported loudly, never
|
|
392
|
+
// silently ignored.
|
|
393
|
+
// 'unavailable' — no `gpg` binary, or the public key could not be imported.
|
|
394
|
+
// The caller treats this as non-blocking because SHA256
|
|
395
|
+
// already guarantees integrity; GPG adds authenticity as a
|
|
396
|
+
// defense-in-depth layer, not the only gate.
|
|
397
|
+
// Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
|
|
398
|
+
// gpg-agent are never touched or modified.
|
|
399
|
+
function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
|
|
400
|
+
const gpgHome = path.join(tmpDir, 'gnupg');
|
|
401
|
+
fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
|
|
402
|
+
const keyPath = path.join(tmpDir, 'iterate-skill-pubkey.asc');
|
|
403
|
+
fs.writeFileSync(keyPath, publicKeyArmor, 'utf8');
|
|
404
|
+
|
|
405
|
+
const importResult = spawnSync(
|
|
406
|
+
'gpg',
|
|
407
|
+
['--homedir', gpgHome, '--batch', '--yes', '--quiet', '--no-autostart', '--import', keyPath],
|
|
408
|
+
{ encoding: 'utf8' }
|
|
409
|
+
);
|
|
410
|
+
if (importResult.error) {
|
|
411
|
+
const detail = importResult.error.code === 'ENOENT' ? 'gpg binary not found' : importResult.error.message;
|
|
412
|
+
return { status: 'unavailable', detail };
|
|
413
|
+
}
|
|
414
|
+
if (importResult.status !== 0) {
|
|
415
|
+
return { status: 'invalid', detail: (importResult.stderr || '').trim() || 'failed to import the signing key' };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const verifyResult = spawnSync(
|
|
419
|
+
'gpg',
|
|
420
|
+
['--homedir', gpgHome, '--batch', '--quiet', '--no-autostart', '--verify', signaturePath, artifactPath],
|
|
421
|
+
{ encoding: 'utf8' }
|
|
422
|
+
);
|
|
423
|
+
if (verifyResult.error) {
|
|
424
|
+
return { status: 'unavailable', detail: `gpg launch failed: ${verifyResult.error.message}` };
|
|
425
|
+
}
|
|
426
|
+
// gpg exit 0 = good signature; 1 = bad signature; 2 = fatal (bad key usage).
|
|
427
|
+
if (verifyResult.status === 0) {
|
|
428
|
+
return { status: 'verified', detail: (verifyResult.stderr || '').trim() };
|
|
429
|
+
}
|
|
430
|
+
return { status: 'invalid', detail: (verifyResult.stderr || '').trim() || `gpg exited ${verifyResult.status}` };
|
|
431
|
+
}
|
|
432
|
+
|
|
277
433
|
function parseChecksums(text) {
|
|
278
434
|
const map = new Map();
|
|
279
435
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -308,21 +464,69 @@ function parseChecksums(text) {
|
|
|
308
464
|
return map;
|
|
309
465
|
}
|
|
310
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Bounded in-memory output buffer: retains only the last ``cap`` characters.
|
|
469
|
+
* Used by ``runCommand`` so a chatty child (a verbose build listing, pip log
|
|
470
|
+
* flood, etc.) can never drive the installer's memory usage unbounded.
|
|
471
|
+
*/
|
|
472
|
+
class TailBuffer {
|
|
473
|
+
constructor(cap) {
|
|
474
|
+
this.cap = cap;
|
|
475
|
+
this.text = '';
|
|
476
|
+
}
|
|
477
|
+
push(chunk) {
|
|
478
|
+
if (!chunk) return;
|
|
479
|
+
if (this.text.length + chunk.length > this.cap) {
|
|
480
|
+
this.text = (this.text + chunk).slice(-this.cap);
|
|
481
|
+
} else {
|
|
482
|
+
this.text += chunk;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
toString() {
|
|
486
|
+
return this.text;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Default ceiling for a single child command: 10 minutes then SIGKILL. Long
|
|
491
|
+
// operations in this file (pip/pipx installs, venv creation) can legitimately
|
|
492
|
+
// take minutes on slow networks, but a wedged child must never hang the
|
|
493
|
+
// installer forever.
|
|
494
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
495
|
+
// Hard cap on retained stdout/stderr per command (memory bound).
|
|
496
|
+
const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
|
|
497
|
+
|
|
311
498
|
function runCommand(bin, args, options = {}) {
|
|
499
|
+
const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
|
312
500
|
return new Promise((resolve, reject) => {
|
|
313
|
-
const child = spawn(bin, args, { stdio: 'pipe', ...
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
child.stdout?.on('data', (data) =>
|
|
317
|
-
child.stderr?.on('data', (data) =>
|
|
501
|
+
const child = spawn(bin, args, { stdio: 'pipe', ...spawnOptions });
|
|
502
|
+
const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
503
|
+
const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
504
|
+
child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
|
|
505
|
+
child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
|
|
506
|
+
let killed = false;
|
|
507
|
+
const timer =
|
|
508
|
+
timeout > 0
|
|
509
|
+
? setTimeout(() => {
|
|
510
|
+
killed = true;
|
|
511
|
+
child.kill('SIGKILL');
|
|
512
|
+
}, timeout)
|
|
513
|
+
: null;
|
|
318
514
|
child.on('close', (code) => {
|
|
515
|
+
if (timer) clearTimeout(timer);
|
|
516
|
+
if (killed) {
|
|
517
|
+
reject(new InstallerError(`${bin} timed out after ${timeout} ms`));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
319
520
|
if (code === 0) {
|
|
320
|
-
resolve(
|
|
521
|
+
resolve(stdoutTail.toString());
|
|
321
522
|
} else {
|
|
322
|
-
reject(new InstallerError(`${bin} exited with ${code}: ${
|
|
523
|
+
reject(new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
|
|
323
524
|
}
|
|
324
525
|
});
|
|
325
|
-
child.on('error',
|
|
526
|
+
child.on('error', (err) => {
|
|
527
|
+
if (timer) clearTimeout(timer);
|
|
528
|
+
reject(err);
|
|
529
|
+
});
|
|
326
530
|
});
|
|
327
531
|
}
|
|
328
532
|
|
|
@@ -379,6 +583,20 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
379
583
|
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
380
584
|
}
|
|
381
585
|
|
|
586
|
+
// 1a. Reject absolute-path entries outright before any other accounting.
|
|
587
|
+
// A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
|
|
588
|
+
// top-level-strip logic into destDir, but accepting its name as a "safe"
|
|
589
|
+
// component invites ambiguity between tar implementations about what the
|
|
590
|
+
// first component really is — never claim a release archive can contain
|
|
591
|
+
// absolute paths, so fail closed instead.
|
|
592
|
+
for (const entry of entries) {
|
|
593
|
+
if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
|
|
594
|
+
throw new InstallerError(
|
|
595
|
+
`Refusing to extract tarball: entry "${entry}" uses an absolute path.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
382
600
|
// 1b. Reject any symbolic or hard link entry before touching the tree.
|
|
383
601
|
// The verbose listing's first character is the entry type for both GNU tar
|
|
384
602
|
// and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
|
|
@@ -425,19 +643,48 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
425
643
|
}
|
|
426
644
|
|
|
427
645
|
function runPythonInstall(pythonBin, installScript, args, options = {}) {
|
|
646
|
+
// A wedged pip/skill install must never hang the whole installer forever.
|
|
647
|
+
// On timeout we SIGKILL the child's *process group*: spawn in its own
|
|
648
|
+
// session (detached) so grandchildren holding the pipes are reaped too,
|
|
649
|
+
// mirroring the Python updater's killpg hardening.
|
|
650
|
+
const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
|
651
|
+
const detached = process.platform !== 'win32';
|
|
428
652
|
return new Promise((resolve, reject) => {
|
|
429
653
|
const child = spawn(pythonBin, [installScript, 'install', ...args], {
|
|
430
654
|
stdio: 'inherit',
|
|
431
655
|
env: { ...process.env, FORCE_COLOR: '1' },
|
|
432
|
-
|
|
656
|
+
detached,
|
|
657
|
+
...spawnOptions,
|
|
433
658
|
});
|
|
659
|
+
const timer =
|
|
660
|
+
timeout > 0
|
|
661
|
+
? setTimeout(() => {
|
|
662
|
+
try {
|
|
663
|
+
if (child.pid === undefined) {
|
|
664
|
+
child.kill('SIGKILL');
|
|
665
|
+
} else if (detached) {
|
|
666
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid = whole group
|
|
667
|
+
} else {
|
|
668
|
+
child.kill('SIGKILL');
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
// Process already gone — nothing to reap.
|
|
672
|
+
}
|
|
673
|
+
}, timeout)
|
|
674
|
+
: null;
|
|
434
675
|
child.on('close', (code) => {
|
|
676
|
+
if (timer) clearTimeout(timer);
|
|
435
677
|
// Resolve with the exit code (rather than rejecting) so the caller can
|
|
436
678
|
// distinguish "real failure" from "user cancelled the selection" and
|
|
437
|
-
// avoid reporting a false success.
|
|
438
|
-
|
|
679
|
+
// avoid reporting a false success. A signal-killed child reports a null
|
|
680
|
+
// code, which the shell would otherwise read as success; normalize to a
|
|
681
|
+
// non-zero failure.
|
|
682
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
683
|
+
});
|
|
684
|
+
child.on('error', (err) => {
|
|
685
|
+
if (timer) clearTimeout(timer);
|
|
686
|
+
reject(err);
|
|
439
687
|
});
|
|
440
|
-
child.on('error', reject);
|
|
441
688
|
});
|
|
442
689
|
}
|
|
443
690
|
|
|
@@ -735,15 +982,39 @@ async function main(options = {}) {
|
|
|
735
982
|
]);
|
|
736
983
|
|
|
737
984
|
info('Fetching latest release from GitHub...');
|
|
738
|
-
let
|
|
985
|
+
let releases;
|
|
739
986
|
try {
|
|
740
|
-
|
|
987
|
+
releases = await fetchJson(RELEASES_API_URL, token);
|
|
741
988
|
} catch (err) {
|
|
742
989
|
error(`Could not fetch release info: ${err.message}`);
|
|
743
990
|
hint('You can set GITHUB_TOKEN for higher API rate limits.');
|
|
744
991
|
return 1;
|
|
745
992
|
}
|
|
746
993
|
|
|
994
|
+
// The list endpoint returns releases newest-published first. Select the first
|
|
995
|
+
// that is a stable release carrying BOTH assets we need, so a release from
|
|
996
|
+
// another sub-project (or one whose assets are still uploading) is skipped
|
|
997
|
+
// rather than mistaken for the latest skill release.
|
|
998
|
+
const release = Array.isArray(releases)
|
|
999
|
+
? releases.find(
|
|
1000
|
+
(r) =>
|
|
1001
|
+
r &&
|
|
1002
|
+
!r.draft &&
|
|
1003
|
+
!r.prerelease &&
|
|
1004
|
+
Array.isArray(r.assets) &&
|
|
1005
|
+
r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
|
|
1006
|
+
r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)
|
|
1007
|
+
)
|
|
1008
|
+
: releases;
|
|
1009
|
+
|
|
1010
|
+
if (!release) {
|
|
1011
|
+
error(
|
|
1012
|
+
`No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
|
|
1013
|
+
);
|
|
1014
|
+
hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
|
|
1015
|
+
return 1;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
747
1018
|
const tag = release.tag_name;
|
|
748
1019
|
if (!tag) {
|
|
749
1020
|
error('Latest release has no tag.');
|
|
@@ -753,6 +1024,9 @@ async function main(options = {}) {
|
|
|
753
1024
|
|
|
754
1025
|
const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
|
|
755
1026
|
const checksumAsset = release.assets?.find((a) => a.name === CHECKSUMS_ASSET_NAME);
|
|
1027
|
+
// Optional `.asc` signature; its absence only downgrades authenticity to
|
|
1028
|
+
// integrity (see the best-effort verify below), never fails the install.
|
|
1029
|
+
const signatureAsset = release.assets?.find((a) => a.name === TARBALL_SIGNATURE_ASSET_NAME);
|
|
756
1030
|
|
|
757
1031
|
if (!tarballAsset) {
|
|
758
1032
|
error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
|
|
@@ -769,12 +1043,23 @@ async function main(options = {}) {
|
|
|
769
1043
|
|
|
770
1044
|
try {
|
|
771
1045
|
info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
|
|
772
|
-
|
|
1046
|
+
try {
|
|
1047
|
+
await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
error(`Could not download release tarball: ${err.message}`);
|
|
1050
|
+
hint(`A 403/404 usually means the GitHub rate limit or an expired asset URL; retry with GITHUB_TOKEN set.`);
|
|
1051
|
+
return 1;
|
|
1052
|
+
}
|
|
773
1053
|
|
|
774
1054
|
info('Downloading checksums...');
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1055
|
+
try {
|
|
1056
|
+
await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
|
|
1057
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
1058
|
+
});
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
error(`Could not download ${CHECKSUMS_ASSET_NAME}: ${err.message}`);
|
|
1061
|
+
return 1;
|
|
1062
|
+
}
|
|
778
1063
|
|
|
779
1064
|
info('Verifying checksum...');
|
|
780
1065
|
const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
|
|
@@ -804,6 +1089,38 @@ async function main(options = {}) {
|
|
|
804
1089
|
`\x1b[32m✓\x1b[0m SHA256 checksum verified`,
|
|
805
1090
|
]);
|
|
806
1091
|
|
|
1092
|
+
// GPG authenticity check (best-effort, defense-in-depth). The release also
|
|
1093
|
+
// publishes a detached ASCII-armored signature over the tarball. When a
|
|
1094
|
+
// `gpg` binary is present we verify it against the embedded signing key: a
|
|
1095
|
+
// *valid* signature upgrades the release from "integrity-guaranteed" to
|
|
1096
|
+
// "authenticity-guaranteed". If gpg is unavailable we degrade to a warning
|
|
1097
|
+
// (SHA256 already guards integrity); if gpg IS present but the signature
|
|
1098
|
+
// fails, we still install but report it loudly — a failed signature here is
|
|
1099
|
+
// almost always a key-rotation lag, never a hidden fault.
|
|
1100
|
+
if (signatureAsset) {
|
|
1101
|
+
const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
|
|
1102
|
+
try {
|
|
1103
|
+
await downloadFile(signatureAsset.browser_download_url, signaturePath, token, {
|
|
1104
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
1105
|
+
});
|
|
1106
|
+
const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
|
|
1107
|
+
if (sig.status === 'verified') {
|
|
1108
|
+
success('GPG signature verified.');
|
|
1109
|
+
} else if (sig.status === 'invalid') {
|
|
1110
|
+
warning(
|
|
1111
|
+
`GPG signature could NOT be verified (${sig.detail}).\n` +
|
|
1112
|
+
' SHA256 integrity still passed; re-run after the release key is rotated if this persists.'
|
|
1113
|
+
);
|
|
1114
|
+
} else {
|
|
1115
|
+
warning(
|
|
1116
|
+
`gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
807
1124
|
info('Extracting release...');
|
|
808
1125
|
const sourceDir = path.join(tmpDir, 'source');
|
|
809
1126
|
fs.mkdirSync(sourceDir, { recursive: true });
|
|
@@ -883,10 +1200,17 @@ module.exports = {
|
|
|
883
1200
|
resolveInstallMode,
|
|
884
1201
|
askYesNo,
|
|
885
1202
|
parseChecksums,
|
|
1203
|
+
enforceDownloadedSize,
|
|
886
1204
|
extractTarball,
|
|
887
1205
|
rejectLinkEntries,
|
|
888
1206
|
isGithubApiUrl,
|
|
889
1207
|
normalizeToken,
|
|
890
1208
|
buildAuthFlags,
|
|
891
1209
|
supportsCurlFailWithBody,
|
|
1210
|
+
// Executed under test for the timeout/null-exit-code hardening; retaining
|
|
1211
|
+
// the exports also keeps them statically reachable for QA/debugging.
|
|
1212
|
+
runCommand,
|
|
1213
|
+
runPythonInstall,
|
|
1214
|
+
TailBuffer,
|
|
1215
|
+
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
892
1216
|
};
|