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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/lib/resolver.js +539 -54
  3. 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 tilde(p) {
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
- export function downloadFile(url, destPath, redirects = 5, onProgress = null) {
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
- const request = https.get(url, { headers: { 'User-Agent': 'agentworth-npm-resolver' } }, (res) => {
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(resolve, reject);
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 reject(new Error(`Failed to download binary: HTTP ${res.statusCode} from ${url}`));
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(resolve);
331
+ fileStream.close(finishResolve);
277
332
  });
278
333
 
279
334
  fileStream.on('error', (err) => {
280
- fs.unlink(destPath, () => reject(err));
335
+ fs.unlink(destPath, () => finishReject(err));
281
336
  });
282
337
  });
283
338
 
284
339
  request.on('error', (err) => {
285
- reject(err);
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
- if (isExecutable(cachedBinary)) {
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
- fs.mkdirSync(cacheDir, { recursive: true });
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
- const archiveName = `agentworth-v${version}-${targetTriple}.tar.gz`;
448
- const url = `https://github.com/unfoundbox-crew/agentworth/releases/download/v${version}/${archiveName}`;
756
+ const haveLock = tryAcquireLock(lockFile, options.__testAfterLockWrite);
757
+ if (haveLock) break;
449
758
 
450
- const archivePath = path.join(cacheDir, archiveName);
451
- const progress = options.silent ? null : startDownloadProgress();
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
- if (progress) {
454
- console.error(brandLine('*', 'resolving', `v${version} ${targetTriple}`));
780
+ await sleep(LOCK_POLL_MS);
455
781
  }
456
782
 
457
783
  try {
458
- await downloadFile(url, archivePath, 5, progress ? progress.tick : null);
459
- } finally {
460
- if (progress) progress.done();
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
- if (fs.existsSync(archivePath)) {
474
- fs.unlinkSync(archivePath);
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
- if (process.platform !== 'win32') {
480
- fs.chmodSync(cachedBinary, 0o755);
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
- if (!isExecutable(cachedBinary)) {
484
- throw new Error(`Downloaded binary is not executable: ${cachedBinary}`);
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
- if (!options.silent) {
488
- console.error(brandLine('*', 'installed', `${binName} in ${tilde(cacheDir)}`));
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
- return cachedBinary;
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
- // nothing to check.
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
- await downloadAndExtractBinary({
766
- platform: ${JSON.stringify(options.platform || process.platform)},
767
- arch: ${JSON.stringify(options.arch || process.arch)},
768
- homeDir: ${JSON.stringify(options.homeDir || (options.env && options.env.HOME) || '')},
769
- invokedAs: ${JSON.stringify(options.invokedAs || '')}
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentworth",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Discover, normalize, and understand AI-agent histories locally.",
5
5
  "type": "module",
6
6
  "main": "./lib/resolver.js",