iterate-skill-installer 3.4.2 → 3.5.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/lib/installer.js +335 -17
- package/package.json +1 -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/)) {
|
|
@@ -336,40 +492,90 @@ class TailBuffer {
|
|
|
336
492
|
// take minutes on slow networks, but a wedged child must never hang the
|
|
337
493
|
// installer forever.
|
|
338
494
|
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
495
|
+
// Grace period between the SIGKILL and giving up on 'close'. A grandchild that
|
|
496
|
+
// inherited our stdio pipe keeps that pipe open, so Node withholds 'close'
|
|
497
|
+
// even though the leader is already dead — without this the promise would
|
|
498
|
+
// never settle and the installer would hang despite the timeout having fired.
|
|
499
|
+
const POST_KILL_SETTLE_MS = 5 * 1000;
|
|
339
500
|
// Hard cap on retained stdout/stderr per command (memory bound).
|
|
340
501
|
const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
|
|
341
502
|
|
|
503
|
+
// True only on POSIX: Windows has no process groups keyed by negative pid and
|
|
504
|
+
// `detached` there means "new console window", not "new process group".
|
|
505
|
+
const IS_POSIX = process.platform !== 'win32';
|
|
506
|
+
|
|
507
|
+
function killProcessTree(child) {
|
|
508
|
+
// The child was spawned detached (POSIX), so it is its own process-group
|
|
509
|
+
// leader and its negative pid addresses the WHOLE group. A bare
|
|
510
|
+
// `child.kill()` only signals the immediate process, so a `pip` that had
|
|
511
|
+
// already spawned a compiler/download helper outlived the kill and kept
|
|
512
|
+
// running (and holding our stdio pipe) after the installer "timed out".
|
|
513
|
+
if (IS_POSIX && child.pid) {
|
|
514
|
+
try {
|
|
515
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
516
|
+
return;
|
|
517
|
+
} catch (err) {
|
|
518
|
+
// ESRCH: the group is already gone. EPERM: fall through to the leader.
|
|
519
|
+
if (err && err.code === 'ESRCH') return;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
child.kill('SIGKILL');
|
|
524
|
+
} catch {
|
|
525
|
+
// Already exited — nothing left to signal.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
342
529
|
function runCommand(bin, args, options = {}) {
|
|
343
530
|
const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
|
344
531
|
return new Promise((resolve, reject) => {
|
|
345
|
-
|
|
532
|
+
// `detached` on POSIX puts the child in its own process group so the
|
|
533
|
+
// timeout can kill the entire tree, not just the leader. It does not
|
|
534
|
+
// change how we await the child (we never unref it).
|
|
535
|
+
const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
|
|
346
536
|
const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
347
537
|
const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
348
538
|
child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
|
|
349
539
|
child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
|
|
350
540
|
let killed = false;
|
|
541
|
+
let settled = false;
|
|
542
|
+
let killFallback = null;
|
|
543
|
+
const settle = (fn, value) => {
|
|
544
|
+
if (settled) return;
|
|
545
|
+
settled = true;
|
|
546
|
+
fn(value);
|
|
547
|
+
};
|
|
351
548
|
const timer =
|
|
352
549
|
timeout > 0
|
|
353
550
|
? setTimeout(() => {
|
|
354
551
|
killed = true;
|
|
355
|
-
child
|
|
552
|
+
killProcessTree(child);
|
|
553
|
+
// The leader may be dead while a grandchild still holds our stdio
|
|
554
|
+
// pipe open, in which case 'close' never arrives. Guarantee the
|
|
555
|
+
// promise settles either way.
|
|
556
|
+
killFallback = setTimeout(() => {
|
|
557
|
+
settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
|
|
558
|
+
}, POST_KILL_SETTLE_MS);
|
|
356
559
|
}, timeout)
|
|
357
560
|
: null;
|
|
358
|
-
|
|
561
|
+
const finish = (code) => {
|
|
359
562
|
if (timer) clearTimeout(timer);
|
|
563
|
+
if (killFallback) clearTimeout(killFallback);
|
|
360
564
|
if (killed) {
|
|
361
|
-
reject
|
|
565
|
+
settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
|
|
362
566
|
return;
|
|
363
567
|
}
|
|
364
568
|
if (code === 0) {
|
|
365
|
-
resolve
|
|
569
|
+
settle(resolve, stdoutTail.toString());
|
|
366
570
|
} else {
|
|
367
|
-
reject
|
|
571
|
+
settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
|
|
368
572
|
}
|
|
369
|
-
}
|
|
573
|
+
};
|
|
574
|
+
child.on('close', finish);
|
|
370
575
|
child.on('error', (err) => {
|
|
371
576
|
if (timer) clearTimeout(timer);
|
|
372
|
-
|
|
577
|
+
if (killFallback) clearTimeout(killFallback);
|
|
578
|
+
settle(reject, err);
|
|
373
579
|
});
|
|
374
580
|
});
|
|
375
581
|
}
|
|
@@ -427,6 +633,20 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
427
633
|
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
428
634
|
}
|
|
429
635
|
|
|
636
|
+
// 1a. Reject absolute-path entries outright before any other accounting.
|
|
637
|
+
// A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
|
|
638
|
+
// top-level-strip logic into destDir, but accepting its name as a "safe"
|
|
639
|
+
// component invites ambiguity between tar implementations about what the
|
|
640
|
+
// first component really is — never claim a release archive can contain
|
|
641
|
+
// absolute paths, so fail closed instead.
|
|
642
|
+
for (const entry of entries) {
|
|
643
|
+
if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
|
|
644
|
+
throw new InstallerError(
|
|
645
|
+
`Refusing to extract tarball: entry "${entry}" uses an absolute path.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
430
650
|
// 1b. Reject any symbolic or hard link entry before touching the tree.
|
|
431
651
|
// The verbose listing's first character is the entry type for both GNU tar
|
|
432
652
|
// and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
|
|
@@ -473,13 +693,37 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
473
693
|
}
|
|
474
694
|
|
|
475
695
|
function runPythonInstall(pythonBin, installScript, args, options = {}) {
|
|
696
|
+
// A wedged pip/skill install must never hang the whole installer forever.
|
|
697
|
+
// On timeout we SIGKILL the child's *process group*: spawn in its own
|
|
698
|
+
// session (detached) so grandchildren holding the pipes are reaped too,
|
|
699
|
+
// mirroring the Python updater's killpg hardening.
|
|
700
|
+
const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
|
701
|
+
const detached = process.platform !== 'win32';
|
|
476
702
|
return new Promise((resolve, reject) => {
|
|
477
703
|
const child = spawn(pythonBin, [installScript, 'install', ...args], {
|
|
478
704
|
stdio: 'inherit',
|
|
479
705
|
env: { ...process.env, FORCE_COLOR: '1' },
|
|
480
|
-
|
|
706
|
+
detached,
|
|
707
|
+
...spawnOptions,
|
|
481
708
|
});
|
|
709
|
+
const timer =
|
|
710
|
+
timeout > 0
|
|
711
|
+
? setTimeout(() => {
|
|
712
|
+
try {
|
|
713
|
+
if (child.pid === undefined) {
|
|
714
|
+
child.kill('SIGKILL');
|
|
715
|
+
} else if (detached) {
|
|
716
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid = whole group
|
|
717
|
+
} else {
|
|
718
|
+
child.kill('SIGKILL');
|
|
719
|
+
}
|
|
720
|
+
} catch {
|
|
721
|
+
// Process already gone — nothing to reap.
|
|
722
|
+
}
|
|
723
|
+
}, timeout)
|
|
724
|
+
: null;
|
|
482
725
|
child.on('close', (code) => {
|
|
726
|
+
if (timer) clearTimeout(timer);
|
|
483
727
|
// Resolve with the exit code (rather than rejecting) so the caller can
|
|
484
728
|
// distinguish "real failure" from "user cancelled the selection" and
|
|
485
729
|
// avoid reporting a false success. A signal-killed child reports a null
|
|
@@ -487,7 +731,10 @@ function runPythonInstall(pythonBin, installScript, args, options = {}) {
|
|
|
487
731
|
// non-zero failure.
|
|
488
732
|
resolve(typeof code === 'number' ? code : 1);
|
|
489
733
|
});
|
|
490
|
-
child.on('error',
|
|
734
|
+
child.on('error', (err) => {
|
|
735
|
+
if (timer) clearTimeout(timer);
|
|
736
|
+
reject(err);
|
|
737
|
+
});
|
|
491
738
|
});
|
|
492
739
|
}
|
|
493
740
|
|
|
@@ -785,15 +1032,39 @@ async function main(options = {}) {
|
|
|
785
1032
|
]);
|
|
786
1033
|
|
|
787
1034
|
info('Fetching latest release from GitHub...');
|
|
788
|
-
let
|
|
1035
|
+
let releases;
|
|
789
1036
|
try {
|
|
790
|
-
|
|
1037
|
+
releases = await fetchJson(RELEASES_API_URL, token);
|
|
791
1038
|
} catch (err) {
|
|
792
1039
|
error(`Could not fetch release info: ${err.message}`);
|
|
793
1040
|
hint('You can set GITHUB_TOKEN for higher API rate limits.');
|
|
794
1041
|
return 1;
|
|
795
1042
|
}
|
|
796
1043
|
|
|
1044
|
+
// The list endpoint returns releases newest-published first. Select the first
|
|
1045
|
+
// that is a stable release carrying BOTH assets we need, so a release from
|
|
1046
|
+
// another sub-project (or one whose assets are still uploading) is skipped
|
|
1047
|
+
// rather than mistaken for the latest skill release.
|
|
1048
|
+
const release = Array.isArray(releases)
|
|
1049
|
+
? releases.find(
|
|
1050
|
+
(r) =>
|
|
1051
|
+
r &&
|
|
1052
|
+
!r.draft &&
|
|
1053
|
+
!r.prerelease &&
|
|
1054
|
+
Array.isArray(r.assets) &&
|
|
1055
|
+
r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
|
|
1056
|
+
r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)
|
|
1057
|
+
)
|
|
1058
|
+
: releases;
|
|
1059
|
+
|
|
1060
|
+
if (!release) {
|
|
1061
|
+
error(
|
|
1062
|
+
`No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
|
|
1063
|
+
);
|
|
1064
|
+
hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
|
|
1065
|
+
return 1;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
797
1068
|
const tag = release.tag_name;
|
|
798
1069
|
if (!tag) {
|
|
799
1070
|
error('Latest release has no tag.');
|
|
@@ -803,6 +1074,9 @@ async function main(options = {}) {
|
|
|
803
1074
|
|
|
804
1075
|
const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
|
|
805
1076
|
const checksumAsset = release.assets?.find((a) => a.name === CHECKSUMS_ASSET_NAME);
|
|
1077
|
+
// Optional `.asc` signature; its absence only downgrades authenticity to
|
|
1078
|
+
// integrity (see the best-effort verify below), never fails the install.
|
|
1079
|
+
const signatureAsset = release.assets?.find((a) => a.name === TARBALL_SIGNATURE_ASSET_NAME);
|
|
806
1080
|
|
|
807
1081
|
if (!tarballAsset) {
|
|
808
1082
|
error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
|
|
@@ -819,12 +1093,23 @@ async function main(options = {}) {
|
|
|
819
1093
|
|
|
820
1094
|
try {
|
|
821
1095
|
info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
|
|
822
|
-
|
|
1096
|
+
try {
|
|
1097
|
+
await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
error(`Could not download release tarball: ${err.message}`);
|
|
1100
|
+
hint(`A 403/404 usually means the GitHub rate limit or an expired asset URL; retry with GITHUB_TOKEN set.`);
|
|
1101
|
+
return 1;
|
|
1102
|
+
}
|
|
823
1103
|
|
|
824
1104
|
info('Downloading checksums...');
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1105
|
+
try {
|
|
1106
|
+
await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
|
|
1107
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
1108
|
+
});
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
error(`Could not download ${CHECKSUMS_ASSET_NAME}: ${err.message}`);
|
|
1111
|
+
return 1;
|
|
1112
|
+
}
|
|
828
1113
|
|
|
829
1114
|
info('Verifying checksum...');
|
|
830
1115
|
const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
|
|
@@ -854,6 +1139,38 @@ async function main(options = {}) {
|
|
|
854
1139
|
`\x1b[32m✓\x1b[0m SHA256 checksum verified`,
|
|
855
1140
|
]);
|
|
856
1141
|
|
|
1142
|
+
// GPG authenticity check (best-effort, defense-in-depth). The release also
|
|
1143
|
+
// publishes a detached ASCII-armored signature over the tarball. When a
|
|
1144
|
+
// `gpg` binary is present we verify it against the embedded signing key: a
|
|
1145
|
+
// *valid* signature upgrades the release from "integrity-guaranteed" to
|
|
1146
|
+
// "authenticity-guaranteed". If gpg is unavailable we degrade to a warning
|
|
1147
|
+
// (SHA256 already guards integrity); if gpg IS present but the signature
|
|
1148
|
+
// fails, we still install but report it loudly — a failed signature here is
|
|
1149
|
+
// almost always a key-rotation lag, never a hidden fault.
|
|
1150
|
+
if (signatureAsset) {
|
|
1151
|
+
const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
|
|
1152
|
+
try {
|
|
1153
|
+
await downloadFile(signatureAsset.browser_download_url, signaturePath, token, {
|
|
1154
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
1155
|
+
});
|
|
1156
|
+
const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
|
|
1157
|
+
if (sig.status === 'verified') {
|
|
1158
|
+
success('GPG signature verified.');
|
|
1159
|
+
} else if (sig.status === 'invalid') {
|
|
1160
|
+
warning(
|
|
1161
|
+
`GPG signature could NOT be verified (${sig.detail}).\n` +
|
|
1162
|
+
' SHA256 integrity still passed; re-run after the release key is rotated if this persists.'
|
|
1163
|
+
);
|
|
1164
|
+
} else {
|
|
1165
|
+
warning(
|
|
1166
|
+
`gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
857
1174
|
info('Extracting release...');
|
|
858
1175
|
const sourceDir = path.join(tmpDir, 'source');
|
|
859
1176
|
fs.mkdirSync(sourceDir, { recursive: true });
|
|
@@ -933,6 +1250,7 @@ module.exports = {
|
|
|
933
1250
|
resolveInstallMode,
|
|
934
1251
|
askYesNo,
|
|
935
1252
|
parseChecksums,
|
|
1253
|
+
enforceDownloadedSize,
|
|
936
1254
|
extractTarball,
|
|
937
1255
|
rejectLinkEntries,
|
|
938
1256
|
isGithubApiUrl,
|