agentworth 0.1.24 → 0.1.25
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/README.md +12 -0
- package/lib/resolver.js +539 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,17 @@ The launcher searches for the native binary in the following priority order:
|
|
|
82
82
|
4. **`CARGO_TARGET_DIR`**: Custom cargo target output directory if set.
|
|
83
83
|
5. **User Cargo Bin**: `~/.cargo/bin/agentworth`.
|
|
84
84
|
6. **System `PATH`**: Any `agentworth` executable in your `PATH`.
|
|
85
|
+
7. **Local cache**: `~/.agentworth/bin/v<version>/`, populated by an on-demand download the first time a version isn't found anywhere above.
|
|
86
|
+
|
|
87
|
+
### On-demand download
|
|
88
|
+
|
|
89
|
+
When no source above has the binary, the launcher downloads the matching release archive into
|
|
90
|
+
`~/.agentworth/bin/v<version>/`. That download is checksum-verified against the release's
|
|
91
|
+
published `.sha256` before extraction, extracted into a temporary directory and only moved into
|
|
92
|
+
place once complete (so the version directory is always either fully installed or absent, never
|
|
93
|
+
half-extracted), and guarded by a lock file so multiple concurrent first runs share one download
|
|
94
|
+
instead of racing each other. `agentworth hook` never triggers this download -- a hook only uses
|
|
95
|
+
a binary that's already present, so it can fail fast and quietly instead of blocking on a fetch.
|
|
85
96
|
|
|
86
97
|
---
|
|
87
98
|
|
|
@@ -102,6 +113,7 @@ You can also install the native AgentWorth binary directly:
|
|
|
102
113
|
| :--- | :--- |
|
|
103
114
|
| `AGENTWORTH_BIN` | Path to a specific `agentworth` binary executable. |
|
|
104
115
|
| `CARGO_TARGET_DIR` | Custom Cargo target directory to search for build outputs. |
|
|
116
|
+
| `AGENTWORTH_RELEASE_BASE_URL` | Overrides the GitHub Releases base URL the on-demand downloader fetches archives from. Defaults to `https://github.com/unfoundbox-crew/agentworth/releases/download`; only for testing or an internal release mirror. |
|
|
105
117
|
|
|
106
118
|
---
|
|
107
119
|
|
package/lib/resolver.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import http from 'node:http';
|
|
4
5
|
import https from 'node:https';
|
|
5
6
|
import zlib from 'node:zlib';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
6
8
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* https everywhere in production; the test suite points the resolver at a local
|
|
13
|
+
* `http://127.0.0.1:<port>` fixture server instead of the network (no mocking of `https.get`
|
|
14
|
+
* itself), so both downloadFile and downloadText pick their transport off the URL's own
|
|
15
|
+
* protocol rather than assuming https.
|
|
16
|
+
*/
|
|
17
|
+
function httpGet(url, opts, cb) {
|
|
18
|
+
const client = url.startsWith('http://') ? http : https;
|
|
19
|
+
return client.get(url, opts, cb);
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
23
|
const __dirname = path.dirname(__filename);
|
|
11
24
|
|
|
@@ -78,11 +91,24 @@ export function downloadLine(done, total, { cols = 80, unicode = true, lamp = 'o
|
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
/** `~/.agentworth/bin/v0.1.16` rather than the expanded home, so the line fits 80 columns. */
|
|
81
|
-
function
|
|
82
|
-
const home = os.homedir();
|
|
94
|
+
function tildeFor(p, home) {
|
|
83
95
|
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
function tilde(p) {
|
|
99
|
+
return tildeFor(p, os.homedir());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Strips every occurrence of `home` out of `str` (replaced with `~`). Used on error text that
|
|
104
|
+
* can embed a full command line or a tool's own stderr -- both can carry the cache dir's full
|
|
105
|
+
* path, and a copy-pasted error message should not carry the reporter's home directory.
|
|
106
|
+
*/
|
|
107
|
+
function redactHome(str, home) {
|
|
108
|
+
if (!home) return String(str);
|
|
109
|
+
return String(str).split(home).join('~');
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
/**
|
|
87
113
|
* Returns the normalized platform key (e.g. darwin-arm64, linux-x64, win32-x64).
|
|
88
114
|
*
|
|
@@ -241,19 +267,48 @@ export function findCargoTargetBinary(startDir, binName = getBinaryName()) {
|
|
|
241
267
|
* @param {number} [redirects=5]
|
|
242
268
|
* @returns {Promise<void>}
|
|
243
269
|
*/
|
|
244
|
-
|
|
270
|
+
/** No socket activity for this long -- headers or body -- and the download is presumed stalled. */
|
|
271
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
272
|
+
|
|
273
|
+
export function downloadFile(
|
|
274
|
+
url,
|
|
275
|
+
destPath,
|
|
276
|
+
redirects = 5,
|
|
277
|
+
onProgress = null,
|
|
278
|
+
timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
279
|
+
) {
|
|
245
280
|
return new Promise((resolve, reject) => {
|
|
246
281
|
if (redirects < 0) {
|
|
247
282
|
return reject(new Error('Too many redirects while downloading binary.'));
|
|
248
283
|
}
|
|
249
284
|
|
|
250
|
-
|
|
285
|
+
// A connection that stalls (accepted, headers sent, then nothing) used to hang until the
|
|
286
|
+
// caller's own machinery noticed -- for downloadAndExtractBinary that meant never touching
|
|
287
|
+
// the lock file, so a live holder stuck on a dead connection got its lock stolen 15
|
|
288
|
+
// minutes later and landed in the exact confirm-read race this same fix closes elsewhere.
|
|
289
|
+
// An idle socket timeout means a stall fails fast and releases the lock long before that.
|
|
290
|
+
let settled = false;
|
|
291
|
+
const finishResolve = () => {
|
|
292
|
+
if (settled) return;
|
|
293
|
+
settled = true;
|
|
294
|
+
resolve();
|
|
295
|
+
};
|
|
296
|
+
const finishReject = (err) => {
|
|
297
|
+
if (settled) return;
|
|
298
|
+
settled = true;
|
|
299
|
+
reject(err);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const request = httpGet(url, { headers: { 'User-Agent': 'agentworth-npm-resolver' } }, (res) => {
|
|
251
303
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
252
|
-
return downloadFile(res.headers.location, destPath, redirects - 1, onProgress).then(
|
|
304
|
+
return downloadFile(res.headers.location, destPath, redirects - 1, onProgress, timeoutMs).then(
|
|
305
|
+
finishResolve,
|
|
306
|
+
finishReject,
|
|
307
|
+
);
|
|
253
308
|
}
|
|
254
309
|
|
|
255
310
|
if (res.statusCode !== 200) {
|
|
256
|
-
return
|
|
311
|
+
return finishReject(new Error(`Failed to download binary: HTTP ${res.statusCode} from ${url}`));
|
|
257
312
|
}
|
|
258
313
|
|
|
259
314
|
// The asset is ~23 MB and the release CDN is often slow, so silence here reads as a
|
|
@@ -273,20 +328,251 @@ export function downloadFile(url, destPath, redirects = 5, onProgress = null) {
|
|
|
273
328
|
|
|
274
329
|
fileStream.on('finish', () => {
|
|
275
330
|
if (onProgress) onProgress(received, total || received);
|
|
276
|
-
fileStream.close(
|
|
331
|
+
fileStream.close(finishResolve);
|
|
277
332
|
});
|
|
278
333
|
|
|
279
334
|
fileStream.on('error', (err) => {
|
|
280
|
-
fs.unlink(destPath, () =>
|
|
335
|
+
fs.unlink(destPath, () => finishReject(err));
|
|
281
336
|
});
|
|
282
337
|
});
|
|
283
338
|
|
|
284
339
|
request.on('error', (err) => {
|
|
285
|
-
|
|
340
|
+
finishReject(err);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Fires on idle -- no bytes sent or received -- for `timeoutMs`, whether that idle time is
|
|
344
|
+
// before headers arrive or mid-body. destroy(err) aborts the socket and emits 'error' on
|
|
345
|
+
// the request above with this same error, which is what actually rejects the promise.
|
|
346
|
+
request.setTimeout(timeoutMs, () => {
|
|
347
|
+
request.destroy(new Error(`Download stalled (no data for ${timeoutMs}ms): ${url}`));
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The GitHub Releases base URL archives are fetched from. Overridable with
|
|
355
|
+
* `AGENTWORTH_RELEASE_BASE_URL` (env, or `options.releaseBaseUrl`) so tests -- and anyone
|
|
356
|
+
* mirroring releases internally -- can point the launcher at a different host. The default
|
|
357
|
+
* itself never changes.
|
|
358
|
+
*/
|
|
359
|
+
const DEFAULT_RELEASE_BASE_URL = 'https://github.com/unfoundbox-crew/agentworth/releases/download';
|
|
360
|
+
|
|
361
|
+
function releaseBaseUrl(options = {}) {
|
|
362
|
+
const fromOptions = options.releaseBaseUrl;
|
|
363
|
+
const fromEnv =
|
|
364
|
+
(options.env && options.env.AGENTWORTH_RELEASE_BASE_URL) || process.env.AGENTWORTH_RELEASE_BASE_URL;
|
|
365
|
+
const base = fromOptions || fromEnv || DEFAULT_RELEASE_BASE_URL;
|
|
366
|
+
return base.replace(/\/+$/, '');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sleep(ms) {
|
|
370
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Downloads a small text file (redirect-following, like downloadFile) and returns its body.
|
|
375
|
+
* Used for the `.sha256` sidecar, which is a handful of bytes -- no progress reporting needed.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} url
|
|
378
|
+
* @param {number} [redirects=5]
|
|
379
|
+
* @returns {Promise<string>}
|
|
380
|
+
*/
|
|
381
|
+
export function downloadText(url, redirects = 5) {
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
if (redirects < 0) {
|
|
384
|
+
return reject(new Error('Too many redirects while downloading checksum file.'));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const request = httpGet(url, { headers: { 'User-Agent': 'agentworth-npm-resolver' } }, (res) => {
|
|
388
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
389
|
+
res.resume();
|
|
390
|
+
return downloadText(res.headers.location, redirects - 1).then(resolve, reject);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (res.statusCode !== 200) {
|
|
394
|
+
res.resume();
|
|
395
|
+
return reject(new Error(`Failed to download ${url}: HTTP ${res.statusCode}`));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let data = '';
|
|
399
|
+
res.setEncoding('utf8');
|
|
400
|
+
res.on('data', (chunk) => {
|
|
401
|
+
data += chunk;
|
|
402
|
+
});
|
|
403
|
+
res.on('end', () => resolve(data));
|
|
404
|
+
res.on('error', reject);
|
|
286
405
|
});
|
|
406
|
+
|
|
407
|
+
request.on('error', reject);
|
|
287
408
|
});
|
|
288
409
|
}
|
|
289
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Streaming sha256 of a file on disk -- never loads the whole archive into memory.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} filePath
|
|
415
|
+
* @returns {Promise<string>} lowercase hex digest
|
|
416
|
+
*/
|
|
417
|
+
export function sha256File(filePath) {
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
const hash = crypto.createHash('sha256');
|
|
420
|
+
const stream = fs.createReadStream(filePath);
|
|
421
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
422
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
423
|
+
stream.on('error', reject);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Pulls the hex digest out of a `sha256sum`-style checksum file (`<hex> <filename>`, the
|
|
429
|
+
* format both `shasum -a 256` and `sha256sum` emit -- see release.yml). Only the first
|
|
430
|
+
* non-blank line is read; a sidecar always describes exactly one archive.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} text
|
|
433
|
+
* @param {string} archiveName - used only to make a parse failure's error message useful
|
|
434
|
+
* @returns {string} lowercase hex digest
|
|
435
|
+
*/
|
|
436
|
+
export function parseSha256Line(text, archiveName) {
|
|
437
|
+
const line = String(text || '')
|
|
438
|
+
.split('\n')
|
|
439
|
+
.map((l) => l.trim())
|
|
440
|
+
.find((l) => l.length > 0);
|
|
441
|
+
const match = line && line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
|
|
442
|
+
if (!match) {
|
|
443
|
+
throw new Error(`Could not parse checksum file for ${archiveName}: ${JSON.stringify(text).slice(0, 200)}`);
|
|
444
|
+
}
|
|
445
|
+
// The `sha256sum`/`shasum -a 256` line format is `<hex> <filename>` -- a sidecar that
|
|
446
|
+
// parses but names a different file is not evidence for *this* archive's integrity (a stale
|
|
447
|
+
// cached sidecar, a mirror serving the wrong pairing). Compare basenames: some tools emit a
|
|
448
|
+
// bare filename, others the full path they hashed.
|
|
449
|
+
const namedFile = path.basename(match[2].trim());
|
|
450
|
+
if (namedFile !== archiveName) {
|
|
451
|
+
throw new Error(`Checksum file names "${namedFile}" but expected "${archiveName}"`);
|
|
452
|
+
}
|
|
453
|
+
return match[1].toLowerCase();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The three native binaries the release tarball ships (apps/cli/Cargo.toml's [[bin]] targets). */
|
|
457
|
+
function threeBinaryNames(platform) {
|
|
458
|
+
const suffix = platform === 'win32' ? '.exe' : '';
|
|
459
|
+
return ['agentworth', 'archie', 'agwt'].map((n) => `${n}${suffix}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The completeness marker written only after a checksum-verified install finishes -- contains
|
|
464
|
+
* the archive's own sha256, one newline. Presence and size can't tell a truncated binary from
|
|
465
|
+
* a good one when both happen to land at the same byte count (#173's real broken file was mode
|
|
466
|
+
* 755 and the exact size of the good one), so the marker, not the binaries themselves, is what
|
|
467
|
+
* every completeness gate actually trusts.
|
|
468
|
+
*/
|
|
469
|
+
function markerPath(dir) {
|
|
470
|
+
return path.join(dir, '.installed');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** True when every one of the three native binaries is executable in `dir` AND the completeness marker is present. */
|
|
474
|
+
function installIsComplete(dir, platform) {
|
|
475
|
+
return threeBinaryNames(platform).every((name) => isExecutable(path.join(dir, name))) && fs.existsSync(markerPath(dir));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Removes any `.v<version>.tmp-<pid>` leftover in `binDir` whose pid is no longer alive -- a
|
|
480
|
+
* holder killed mid-extraction leaves its temp dir behind forever otherwise, since only the
|
|
481
|
+
* *next* holder's own pid-named dir was ever cleaned up. Only the current lock holder calls
|
|
482
|
+
* this, and only dead-pid dirs (never our own, never a live one) are touched.
|
|
483
|
+
*/
|
|
484
|
+
function sweepDeadTmpExtractDirs(binDir, version) {
|
|
485
|
+
const prefix = `.v${version}.tmp-`;
|
|
486
|
+
let entries;
|
|
487
|
+
try {
|
|
488
|
+
entries = fs.readdirSync(binDir);
|
|
489
|
+
} catch {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (const name of entries) {
|
|
493
|
+
if (!name.startsWith(prefix)) continue;
|
|
494
|
+
const pid = Number(name.slice(prefix.length));
|
|
495
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
|
|
496
|
+
if (isPidAlive(pid)) continue;
|
|
497
|
+
try {
|
|
498
|
+
fs.rmSync(path.join(binDir, name), { recursive: true, force: true });
|
|
499
|
+
} catch {}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The pid a lock file names, or null if it can't be read or doesn't look like a pid. */
|
|
504
|
+
export function readLockPid(lockFile) {
|
|
505
|
+
let pidText;
|
|
506
|
+
try {
|
|
507
|
+
pidText = fs.readFileSync(lockFile, 'utf8').trim();
|
|
508
|
+
} catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const pid = Number(pidText);
|
|
512
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** True when `pid` (from a lock or a tmp-dir name) is still a live process. */
|
|
516
|
+
function isPidAlive(pid) {
|
|
517
|
+
if (!pid) return false;
|
|
518
|
+
try {
|
|
519
|
+
process.kill(pid, 0);
|
|
520
|
+
return true;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// ESRCH: no such process -- dead, stale. EPERM: it exists but we can't signal it (a
|
|
523
|
+
// different user) -- still alive, don't take it over. Anything else: be conservative.
|
|
524
|
+
return err && err.code === 'EPERM';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* True when the process named by the lock file's pid is still alive. A lock whose holder is
|
|
530
|
+
* gone is stale immediately, regardless of how fresh its mtime looks -- the pid is written but
|
|
531
|
+
* was never read before this, which is why a killed holder used to block every waiter for the
|
|
532
|
+
* full 15-minute staleness window.
|
|
533
|
+
*/
|
|
534
|
+
function isLockHolderAlive(lockFile) {
|
|
535
|
+
return isPidAlive(readLockPid(lockFile));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Refreshes the lock file's mtime so a slow but live download never ages past LOCK_STALE_MS. */
|
|
539
|
+
function touchLockFile(lockFile) {
|
|
540
|
+
try {
|
|
541
|
+
const now = new Date();
|
|
542
|
+
fs.utimesSync(lockFile, now, now);
|
|
543
|
+
} catch {}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Attempts to become the confirmed lock holder for `lockFile`. Winning the exclusive `wx`
|
|
548
|
+
* create is necessary but not sufficient: two waiters can both decide the same lock is stale,
|
|
549
|
+
* both unlink it, and both win a `wx` in sequence (W1 creates and writes its pid; W2, still
|
|
550
|
+
* mid-takeover from the *same* staleness read, unlinks W1's fresh lock and creates its own).
|
|
551
|
+
* So after writing our pid we re-read the file back -- if it no longer names us, someone else
|
|
552
|
+
* has since taken over and we must fall back to the wait loop rather than act as holder too.
|
|
553
|
+
*
|
|
554
|
+
* `afterWrite` is a test-only seam: it runs synchronously right after the pid is written and
|
|
555
|
+
* the fd closed, but before the confirming re-read, so a test can deterministically inject a
|
|
556
|
+
* competing takeover into that exact window instead of relying on real scheduling luck.
|
|
557
|
+
*
|
|
558
|
+
* @returns {boolean} true only if this call is the confirmed sole holder
|
|
559
|
+
*/
|
|
560
|
+
function tryAcquireLock(lockFile, afterWrite) {
|
|
561
|
+
try {
|
|
562
|
+
const fd = fs.openSync(lockFile, 'wx');
|
|
563
|
+
fs.writeSync(fd, String(process.pid));
|
|
564
|
+
fs.closeSync(fd);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
if (err.code === 'EEXIST') return false;
|
|
567
|
+
throw err;
|
|
568
|
+
}
|
|
569
|
+
if (afterWrite) afterWrite();
|
|
570
|
+
return readLockPid(lockFile) === process.pid;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A lock older than this by mtime, OR whose holder pid is no longer alive, is stale and may be taken over. */
|
|
574
|
+
const LOCK_STALE_MS = 15 * 60 * 1000;
|
|
575
|
+
const LOCK_POLL_MS = 500;
|
|
290
576
|
|
|
291
577
|
/**
|
|
292
578
|
* True when the binary at `binPath` reports `expected`. Used to stop a stale
|
|
@@ -430,6 +716,10 @@ export async function downloadAndExtractBinary(options = {}) {
|
|
|
430
716
|
const version = options.version || getPackageVersion();
|
|
431
717
|
const targetTriple = getTargetTriple(platform, arch);
|
|
432
718
|
const binName = getBinaryName(platform, options.invokedAs);
|
|
719
|
+
// Used to redact the user's home directory out of any error text -- resolved once, the same
|
|
720
|
+
// value getCacheDir used to build cacheDir, so redaction always matches what's actually
|
|
721
|
+
// embedded in a path.
|
|
722
|
+
const effectiveHomeDir = options.homeDir || os.homedir();
|
|
433
723
|
|
|
434
724
|
if (!targetTriple) {
|
|
435
725
|
throw new Error(`Unsupported platform/architecture: ${platform}-${arch}`);
|
|
@@ -438,57 +728,229 @@ export async function downloadAndExtractBinary(options = {}) {
|
|
|
438
728
|
const cacheDir = getCacheDir(version, options.homeDir);
|
|
439
729
|
const cachedBinary = path.join(cacheDir, binName);
|
|
440
730
|
|
|
441
|
-
|
|
731
|
+
// Presence and mode bits alone were the whole bug: #173's truncated binary was mode 755 and
|
|
732
|
+
// the exact byte size of the good one, so `isExecutable` said yes. `installIsComplete` also
|
|
733
|
+
// requires the completeness marker, written only after a checksum-verified install -- a
|
|
734
|
+
// truncated-but-executable leftover from before this fix will not have one.
|
|
735
|
+
if (installIsComplete(cacheDir, platform) && isExecutable(cachedBinary)) {
|
|
442
736
|
return cachedBinary;
|
|
443
737
|
}
|
|
444
738
|
|
|
445
|
-
|
|
739
|
+
// The version directory itself (~/.agentworth/bin) -- one level up from the versioned
|
|
740
|
+
// cacheDir. The lock and the atomic-install temp dir both live here, never inside
|
|
741
|
+
// cacheDir, so they never get mistaken for part of the extracted install.
|
|
742
|
+
const binDir = path.dirname(cacheDir);
|
|
743
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
744
|
+
const lockFile = path.join(binDir, `v${version}.lock`);
|
|
745
|
+
|
|
746
|
+
// One download per version: everyone racing to resolve this version either becomes the
|
|
747
|
+
// downloader (holds the lock file) or a waiter (polls until the lock is gone, then reuses
|
|
748
|
+
// whatever the downloader produced). This is what stops the hook-storm from #173 -- with
|
|
749
|
+
// several sessions open, every one of them used to start its own download of the same
|
|
750
|
+
// archive at once.
|
|
751
|
+
for (;;) {
|
|
752
|
+
if (installIsComplete(cacheDir, platform) && isExecutable(cachedBinary)) {
|
|
753
|
+
return cachedBinary;
|
|
754
|
+
}
|
|
446
755
|
|
|
447
|
-
|
|
448
|
-
|
|
756
|
+
const haveLock = tryAcquireLock(lockFile, options.__testAfterLockWrite);
|
|
757
|
+
if (haveLock) break;
|
|
449
758
|
|
|
450
|
-
|
|
451
|
-
|
|
759
|
+
// Stale if the mtime is old (the 15-minute backstop) OR the holder's own pid is dead --
|
|
760
|
+
// the pid check catches a killed holder immediately instead of leaving every waiter
|
|
761
|
+
// blocked for the full window. A live holder touches its lock's mtime on every download
|
|
762
|
+
// tick, so a slow-but-alive download is never mistaken for abandoned by the age check.
|
|
763
|
+
let stat = null;
|
|
764
|
+
try {
|
|
765
|
+
stat = fs.statSync(lockFile);
|
|
766
|
+
} catch {
|
|
767
|
+
// The lock vanished between the failed open and this stat -- another waiter's stale
|
|
768
|
+
// takeover, or the holder finishing. Loop straight back to the top and try again.
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const staleByAge = Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
|
772
|
+
const holderGone = !isLockHolderAlive(lockFile);
|
|
773
|
+
if (staleByAge || holderGone) {
|
|
774
|
+
try {
|
|
775
|
+
fs.unlinkSync(lockFile);
|
|
776
|
+
} catch {}
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
452
779
|
|
|
453
|
-
|
|
454
|
-
console.error(brandLine('*', 'resolving', `v${version} ${targetTriple}`));
|
|
780
|
+
await sleep(LOCK_POLL_MS);
|
|
455
781
|
}
|
|
456
782
|
|
|
457
783
|
try {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
784
|
+
// Re-check now that we hold the lock: another process could have finished (and left a
|
|
785
|
+
// complete, marker-bearing install) in the window between our last check and acquiring
|
|
786
|
+
// it. Only the lock holder ever reads this and only the lock holder ever touches cacheDir
|
|
787
|
+
// below -- waiters above never rm or rename it themselves, they just wait and re-check.
|
|
788
|
+
if (installIsComplete(cacheDir, platform) && isExecutable(cachedBinary)) {
|
|
789
|
+
return cachedBinary;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// While holding the lock: a previous holder killed mid-extraction leaves its own
|
|
793
|
+
// pid-named temp dir behind forever otherwise -- nothing but the *next* holder's cleanup
|
|
794
|
+
// of its own dir ever touched these.
|
|
795
|
+
sweepDeadTmpExtractDirs(binDir, version);
|
|
796
|
+
|
|
797
|
+
const archiveName = `agentworth-v${version}-${targetTriple}.tar.gz`;
|
|
798
|
+
const url = `${releaseBaseUrl(options)}/v${version}/${archiveName}`;
|
|
799
|
+
|
|
800
|
+
// Extract into a temp directory next to (not inside) the final version directory, and
|
|
801
|
+
// only rename it into place once all three native binaries plus the completeness marker
|
|
802
|
+
// are confirmed present. A version directory is then always either complete or absent --
|
|
803
|
+
// never the half-extracted state that left #173's truncated binary answering for every
|
|
804
|
+
// later invocation with exit 137. The downloaded archive and its `.part` file live inside
|
|
805
|
+
// this same temp directory too, never inside cacheDir -- cacheDir only ever holds a
|
|
806
|
+
// complete install, nothing transient.
|
|
807
|
+
const tmpExtractDir = path.join(binDir, `.v${version}.tmp-${process.pid}`);
|
|
808
|
+
if (fs.existsSync(tmpExtractDir)) {
|
|
809
|
+
fs.rmSync(tmpExtractDir, { recursive: true, force: true });
|
|
810
|
+
}
|
|
811
|
+
fs.mkdirSync(tmpExtractDir, { recursive: true });
|
|
812
|
+
|
|
813
|
+
const archivePath = path.join(tmpExtractDir, archiveName);
|
|
814
|
+
const partPath = `${archivePath}.part`;
|
|
462
815
|
|
|
463
|
-
// No --force-local here: it was a Windows-only GNU tar workaround, and Windows
|
|
464
|
-
// support was dropped in 8b837c3. BSD tar (macOS's default) doesn't recognize
|
|
465
|
-
// the flag and fails every extraction with it present -- v0.1.11 shipped this
|
|
466
|
-
// exact break. Keep it off; there's no platform left where it does anything.
|
|
467
|
-
try {
|
|
468
|
-
execFileSync('tar', ['-xzf', archivePath, '-C', cacheDir]);
|
|
469
|
-
} catch (err) {
|
|
470
|
-
throw new Error(`Failed to extract ${archiveName}: ${err.message}`);
|
|
471
|
-
} finally {
|
|
472
816
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
817
|
+
const progress = options.silent ? null : startDownloadProgress();
|
|
818
|
+
if (progress) {
|
|
819
|
+
console.error(brandLine('*', 'resolving', `v${version} ${targetTriple}`));
|
|
475
820
|
}
|
|
476
|
-
} catch {}
|
|
477
|
-
}
|
|
478
821
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
822
|
+
// Download to a `.part` file and verify against the published `.sha256` sidecar before
|
|
823
|
+
// the archive name is ever used for real -- a truncated or corrupted download must never
|
|
824
|
+
// reach `tar`. One retry on mismatch or truncation; if that also fails, say so with the
|
|
825
|
+
// archive name, both hash prefixes, and the recovery command.
|
|
826
|
+
const MAX_ATTEMPTS = 2;
|
|
827
|
+
let lastErr = null;
|
|
828
|
+
let verified = false;
|
|
829
|
+
let verifiedSha256 = null;
|
|
830
|
+
try {
|
|
831
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS && !verified; attempt += 1) {
|
|
832
|
+
try {
|
|
833
|
+
try {
|
|
834
|
+
if (fs.existsSync(partPath)) fs.unlinkSync(partPath);
|
|
835
|
+
} catch {}
|
|
836
|
+
touchLockFile(lockFile);
|
|
837
|
+
const onTick = (done, total) => {
|
|
838
|
+
touchLockFile(lockFile);
|
|
839
|
+
if (progress) progress.tick(done, total);
|
|
840
|
+
};
|
|
841
|
+
await downloadFile(url, partPath, 5, onTick, options.downloadTimeoutMs);
|
|
842
|
+
const sha256Text = await downloadText(`${url}.sha256`);
|
|
843
|
+
const expected = parseSha256Line(sha256Text, archiveName);
|
|
844
|
+
const actual = await sha256File(partPath);
|
|
845
|
+
if (actual !== expected) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`checksum mismatch: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
verified = true;
|
|
851
|
+
verifiedSha256 = expected;
|
|
852
|
+
} catch (err) {
|
|
853
|
+
lastErr = err;
|
|
854
|
+
try {
|
|
855
|
+
if (fs.existsSync(partPath)) fs.unlinkSync(partPath);
|
|
856
|
+
} catch {}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} finally {
|
|
860
|
+
if (progress) progress.done();
|
|
861
|
+
}
|
|
482
862
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
863
|
+
if (!verified) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`Failed to download and verify ${archiveName}: ${lastErr ? lastErr.message : 'unknown error'}\n` +
|
|
866
|
+
`Recovery: rm -rf ${tildeFor(cacheDir, effectiveHomeDir)}, then rerun.`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
486
869
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
870
|
+
fs.renameSync(partPath, archivePath);
|
|
871
|
+
|
|
872
|
+
// No --force-local here: it was a Windows-only GNU tar workaround, and Windows
|
|
873
|
+
// support was dropped in 8b837c3. BSD tar (macOS's default) doesn't recognize
|
|
874
|
+
// the flag and fails every extraction with it present -- v0.1.11 shipped this
|
|
875
|
+
// exact break. Keep it off; there's no platform left where it does anything.
|
|
876
|
+
try {
|
|
877
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', tmpExtractDir]);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
// err.message (execFileSync's "Command failed: tar ... <stderr>") embeds the full
|
|
880
|
+
// archivePath and tmpExtractDir, both under the user's home -- redact before it ever
|
|
881
|
+
// reaches a terminal or a pasted bug report.
|
|
882
|
+
throw new Error(`Failed to extract ${archiveName}: ${redactHome(err.message, effectiveHomeDir)}`);
|
|
883
|
+
} finally {
|
|
884
|
+
try {
|
|
885
|
+
if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath);
|
|
886
|
+
} catch {}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (platform !== 'win32') {
|
|
890
|
+
for (const name of threeBinaryNames(platform)) {
|
|
891
|
+
const p = path.join(tmpExtractDir, name);
|
|
892
|
+
if (fs.existsSync(p)) {
|
|
893
|
+
try {
|
|
894
|
+
fs.chmodSync(p, 0o755);
|
|
895
|
+
} catch {}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Written only now, after tar succeeded and the binaries are chmod'd -- this file, not
|
|
901
|
+
// the binaries' own presence or size, is what every completeness check actually trusts.
|
|
902
|
+
try {
|
|
903
|
+
fs.writeFileSync(markerPath(tmpExtractDir), `${verifiedSha256}\n`);
|
|
904
|
+
} catch (err) {
|
|
905
|
+
throw new Error(`Failed to write install marker for ${archiveName}: ${redactHome(err.message, effectiveHomeDir)}`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (!installIsComplete(tmpExtractDir, platform)) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
`Extracted archive ${archiveName} is missing one or more of the expected binaries ` +
|
|
911
|
+
`(${threeBinaryNames(platform).join(', ')}).`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Only the lock holder reaches here, and it re-checked completeness above right after
|
|
916
|
+
// acquiring the lock -- so this final check is only about a leftover from a run that
|
|
917
|
+
// somehow finished outside the lock (shouldn't happen, but a complete leftover is not an
|
|
918
|
+
// error). Waiters never execute this branch at all.
|
|
919
|
+
if (!installIsComplete(cacheDir, platform)) {
|
|
920
|
+
if (fs.existsSync(cacheDir)) {
|
|
921
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
922
|
+
}
|
|
923
|
+
fs.renameSync(tmpExtractDir, cacheDir);
|
|
924
|
+
}
|
|
925
|
+
} finally {
|
|
926
|
+
try {
|
|
927
|
+
if (fs.existsSync(tmpExtractDir)) {
|
|
928
|
+
fs.rmSync(tmpExtractDir, { recursive: true, force: true });
|
|
929
|
+
}
|
|
930
|
+
} catch {}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (!installIsComplete(cacheDir, platform) || !isExecutable(cachedBinary)) {
|
|
934
|
+
throw new Error(`Downloaded binary is not executable: ${tildeFor(cachedBinary, effectiveHomeDir)}`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (!options.silent) {
|
|
938
|
+
console.error(brandLine('*', 'installed', `${binName} in ${tildeFor(cacheDir, effectiveHomeDir)}`));
|
|
939
|
+
}
|
|
490
940
|
|
|
491
|
-
|
|
941
|
+
return cachedBinary;
|
|
942
|
+
} finally {
|
|
943
|
+
if (options.__testBeforeUnlock) options.__testBeforeUnlock();
|
|
944
|
+
// Only release the lock if it still names us. If it doesn't, some other process has
|
|
945
|
+
// since taken it over (the same confirm-read race tryAcquireLock guards against, just on
|
|
946
|
+
// the way out instead of the way in) -- unlinking it here would free a lock we no longer
|
|
947
|
+
// own out from under whoever now holds it.
|
|
948
|
+
try {
|
|
949
|
+
if (readLockPid(lockFile) === process.pid) {
|
|
950
|
+
fs.unlinkSync(lockFile);
|
|
951
|
+
}
|
|
952
|
+
} catch {}
|
|
953
|
+
}
|
|
492
954
|
}
|
|
493
955
|
|
|
494
956
|
/**
|
|
@@ -685,11 +1147,14 @@ export function resolveBinary(options = {}) {
|
|
|
685
1147
|
};
|
|
686
1148
|
}
|
|
687
1149
|
|
|
688
|
-
// 8. User local cache (~/.agentworth/bin/v{version}/) -- versioned by construction,
|
|
689
|
-
//
|
|
1150
|
+
// 8. User local cache (~/.agentworth/bin/v{version}/) -- versioned by construction, but
|
|
1151
|
+
// not immune to a truncated-yet-executable leftover (#173: same byte size, mode 755, just
|
|
1152
|
+
// corrupt). installIsComplete also requires the completeness marker downloadAndExtractBinary
|
|
1153
|
+
// only writes after a checksum-verified install, so a broken leftover from before this fix
|
|
1154
|
+
// is correctly treated as not found here rather than served.
|
|
690
1155
|
const cacheDir = getCacheDir(expectedVersion, homeDir);
|
|
691
1156
|
const cachedBin = path.join(cacheDir, binName);
|
|
692
|
-
if (isExecutable(cachedBin)) {
|
|
1157
|
+
if (isExecutable(cachedBin) && installIsComplete(cacheDir, platform)) {
|
|
693
1158
|
return {
|
|
694
1159
|
found: true,
|
|
695
1160
|
path: cachedBin,
|
|
@@ -753,21 +1218,35 @@ export function buildChildEnv(baseEnv, npmVersion) {
|
|
|
753
1218
|
* @returns {number} Exit code
|
|
754
1219
|
*/
|
|
755
1220
|
export function run(argv = process.argv.slice(2), options = {}) {
|
|
1221
|
+
// `archie hook` runs on every tool call in every open agent session (see #173's
|
|
1222
|
+
// follow-up: 1,000+ concurrent downloads from hooks alone, before the binary was even
|
|
1223
|
+
// installed once). A hook has to exit fast and cannot be the thing that kicks off a
|
|
1224
|
+
// 26 MB download -- it resolves from whatever is already present and nothing else.
|
|
1225
|
+
const isHookInvocation = Array.isArray(argv) && argv[0] === 'hook';
|
|
1226
|
+
|
|
756
1227
|
const resolvedArgs = resolveArguments(argv);
|
|
757
1228
|
let binaryResult = resolveBinary(options);
|
|
758
1229
|
|
|
759
1230
|
// If binary not found on clean machine, attempt on-demand download from GitHub Release
|
|
760
|
-
if ((!binaryResult.found || !binaryResult.path) && options.autoDownload !== false) {
|
|
1231
|
+
if ((!binaryResult.found || !binaryResult.path) && options.autoDownload !== false && !isHookInvocation) {
|
|
761
1232
|
try {
|
|
762
1233
|
const resolverModuleUrl = new URL('./resolver.js', import.meta.url).href;
|
|
763
1234
|
const syncDownloadScript = `
|
|
764
1235
|
import { downloadAndExtractBinary } from '${resolverModuleUrl}';
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1236
|
+
try {
|
|
1237
|
+
await downloadAndExtractBinary({
|
|
1238
|
+
platform: ${JSON.stringify(options.platform || process.platform)},
|
|
1239
|
+
arch: ${JSON.stringify(options.arch || process.arch)},
|
|
1240
|
+
homeDir: ${JSON.stringify(options.homeDir || (options.env && options.env.HOME) || '')},
|
|
1241
|
+
invokedAs: ${JSON.stringify(options.invokedAs || '')}
|
|
1242
|
+
});
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
// A bare throw here prints a full stack trace of the eval wrapper, which is noise
|
|
1245
|
+
// on top of the actual cause -- the message alone (checksum mismatch, extraction
|
|
1246
|
+
// failure, recovery step) is what a user reading this needs.
|
|
1247
|
+
console.error(err && err.message ? err.message : String(err));
|
|
1248
|
+
process.exitCode = 1;
|
|
1249
|
+
}
|
|
771
1250
|
`;
|
|
772
1251
|
const dlResult = spawnSync(process.execPath, ['--input-type=module', '-e', syncDownloadScript], {
|
|
773
1252
|
stdio: 'inherit',
|
|
@@ -783,6 +1262,12 @@ export function run(argv = process.argv.slice(2), options = {}) {
|
|
|
783
1262
|
}
|
|
784
1263
|
|
|
785
1264
|
if (!binaryResult.found || !binaryResult.path) {
|
|
1265
|
+
if (isHookInvocation) {
|
|
1266
|
+
// Fail fast and quiet: no download, nothing on stdout (a hook's stdout can be read as
|
|
1267
|
+
// the hook's own output by the caller), one short line on stderr for anyone looking.
|
|
1268
|
+
console.error(brandLine(' ', 'skip', 'no native binary yet, hook exiting'));
|
|
1269
|
+
return 0;
|
|
1270
|
+
}
|
|
786
1271
|
const message = formatMissingBinaryMessage(getPlatformKey(options.platform, options.arch));
|
|
787
1272
|
console.error(message);
|
|
788
1273
|
return 1;
|