amicus 4.9.5 → 4.9.7

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.
@@ -469,3 +469,136 @@ setTimeout(() => { ws.close(); process.exit(0); }, 3000);
469
469
  ```
470
470
 
471
471
  **Note:** `window.sidecar` is `undefined` in the toolbar (see Known Limitations above). The toolbar communicates with the main process via `window.__amicusUpdateAction` polling, not IPC.
472
+
473
+ ---
474
+
475
+ ## The darwin `.app` bundle — what CI proves, and what it still does not
476
+
477
+ `.github/workflows/darwin-bundle.yml` drives `scripts/probe-darwin-extract.js` on
478
+ `macos-latest`. It is the only place amicus's real extract path meets a real `.app`
479
+ bundle, a real POSIX `fs.symlinkSync`, a real `fs.realpathSync` and a real dyld.
480
+
481
+ ### The filing it closes, and the way it closes it
482
+
483
+ v4.9.6 added a symlink target-escape check (`src/sidecar/zip-entry-write.js ::
484
+ writeSymlink`) that `extract-zip` does not have. The v4.9.7 filing was that it could
485
+ **reject** a real darwin layout. That is **refuted by measurement on the real bytes**,
486
+ not by this job:
487
+
488
+ | artifact | bytes | records | symlinks | dir entries |
489
+ | --- | --- | --- | --- | --- |
490
+ | `electron-v43.1.1-darwin-arm64.zip` | 122,054,683 | 585 | 14 | 310 |
491
+ | `electron-v43.1.1-darwin-x64.zip` | 123,952,132 | 585 | 14 | 310 |
492
+ | `electron-v43.1.1-linux-x64.zip` | 124,861,804 | 74 | **0** | 0 |
493
+ | `electron-v43.1.1-linux-arm64.zip` | 124,456,257 | 74 | **0** | 0 |
494
+
495
+ All 14 darwin targets are relative (`A`, `Versions/Current/Resources`, …); none carries a
496
+ `..` component; none is absolute; **0 of the 585 entry names traverse a symlinked
497
+ component**. The check is purely lexical (`path.resolve` then `path.relative`), so on that
498
+ shape it cannot fire. The extraction root is itself realpath'd
499
+ (`src/sidecar/zip-from-buffer.js :: extractZipBuffer`), which is why macOS's
500
+ `/var` -> `/private/var` does not turn every link into an escape — the one mechanism that
501
+ could have made the filing correct.
502
+
503
+ **Linux is settled outright and gets no job**: both linux artifacts hold 74 entries and
504
+ zero symlinks, so `writeSymlink` is unreachable there. The filing's "darwin/linux" narrows
505
+ to "darwin".
506
+
507
+ ### What each assertion pins, and the mutant it kills
508
+
509
+ | id | assertion | mutant it kills |
510
+ | --- | --- | --- |
511
+ | A1a | `resolveAnchor({selfElectronDir: null})` reads the `checksums.json` under test | dropping the `selfElectronDir` override, which silently re-anchors on the repo's own `node_modules` |
512
+ | A1b | `repairElectron({cacheOnly:true})` repairs **and** the gate says `verified` | any change that stops a byte-exact artifact verifying — extract and exec both stay green |
513
+ | A1c | with **no** anchor the same bytes extract but are marked `unverified` | dropping the `verdict !== 'verified'` mark, i.e. a silent degrade of the trust route |
514
+ | A2 | `resolveElectronBinary` names the real launcher and its exec bit survived | a wrong `platformExe` darwin arm, or a `path.txt` naming a different basename |
515
+ | A3a | all 14 archive symlinks are symlinks on disk with byte-identical targets | deleting the `if (symlink)` branch in `placeEntry` — links become regular files |
516
+ | A3b | the five structural framework links exist with their exact targets | writing the **resolved absolute** path instead of the archive's relative target (still runs on the runner; breaks when the tree moves) |
517
+ | A4 | parity vs the tree `@electron-internal/extract-zip` produced | a flat file mode, or a `continue` that silently drops entries — **report-only on its first cut** |
518
+ | A5 | `Electron --version` runs | the same mutant as A3a, observed through dyld: the launcher's Mach-O carries `LC_RPATH @executable_path/../Frameworks` and `LC_LOAD_DYLIB @rpath/Electron Framework.framework/Electron Framework`, so it loads the ~192 MB framework **through two of the fourteen links** |
519
+ | A6i | an escaping relative target is refused, nothing planted | dropping the `..`-prefix limbs of the three-limb test |
520
+ | A6ii | an **absolute POSIX** target is refused | dropping the `startsWith('..' + sep)` limb — on win32 `/etc/passwd` becomes `C:\etc\passwd` and a *different* limb catches it, so the Windows suite proves the wrong arithmetic |
521
+ | A6iii | SYMLINKCHAIN refused by a **real** `realpath`: 3 links made, no victim | reverting the target resolution to `path.dirname(dest)` — the lexical-dirname bug that extracts with no error at all |
522
+
523
+ A4 is deliberately **report-only, exit 0, full diff printed** on its first cut: nothing has
524
+ ever measured that the two extractors agree on directory modes under the runner's umask, so
525
+ making an unmeasured comparison a blocking gate buys a red for reasons unrelated to
526
+ symlinks. Promote it once one clean run exists. Everything else gates from day one.
527
+
528
+ A6iii is the assertion that matters most: in the jest suite the same archive is pinned by a
529
+ `realpathSync` **the test itself injects** (`tests/electron-custody.test.js`, describe
530
+ `symlinks — the darwin .app shape, which cannot be run here`). That is a rule read off the
531
+ surface its own writer wrote. On the runner the three `.` links exist on disk and the kernel
532
+ answers.
533
+
534
+ ### `npm ci` does NOT provision Electron — the job asks for it explicitly
535
+
536
+ `electron@43.1.1` ships **no install script at all**: its `package.json` has no `scripts`
537
+ field (it exposes `install.js` only as the `install-electron` bin), and `package-lock.json`
538
+ carries no `hasInstallScript` for it. So npm never fetches the ~122 MB binary, on any
539
+ platform or any install path. Measured on run `34246117877`: plain `npm ci` on ubuntu,
540
+ macos and windows alike finished in 15-38 s and left amicus's own postinstall reporting
541
+ `the Electron GUI binary is not provisioned yet` — an empty electron cache. The lockfile's
542
+ per-platform installable counts (588 / 588 / 587) match the observed `added N packages`
543
+ exactly **with `electron` included**, so the package is present and only the binary is
544
+ missing.
545
+
546
+ The job therefore runs `node node_modules/electron/install.js` in its own step, with one
547
+ retry. It deliberately does **not** use `AMICUS_PREFETCH_ELECTRON=1`, which
548
+ `scripts/postinstall.js` routes through amicus's own `repairElectron` — the A4 diff would
549
+ then compare amicus against amicus.
550
+
551
+ ### What it does not prove
552
+
553
+ - **darwin x64.** `macos-latest` is arm64. The x64 artifact was measured at an identical
554
+ shape (585 / 14 / 310), so the residual is small, but no x64 leg exists. Intel runner
555
+ labels changed during 2025 — check GitHub's current list before adding one.
556
+ - **`mas`.** Unreachable in production; no caller passes `platform: 'mas'`.
557
+ - **A case-sensitive APFS volume.** Runners default to case-insensitive; 0 case-insensitive
558
+ name collisions were measured across the 585 entries, but "low exposure" there is
559
+ inference, not measurement.
560
+ - **Future electron layouts, between bumps.** The job proves the version pinned in
561
+ `package-lock.json` at run time. A bump is caught by the `package-lock.json` path filter on
562
+ `pull_request`/`push`, not by the cron.
563
+ - **The native-rescue hatch.** `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`, `ditto` and Info-ZIP
564
+ `unzip` symlink behaviour on darwin stay unmeasured — that is the B2 lane, not this one.
565
+ - **`codesign`.** The archive carries **zero** `_CodeSignature` entries, so
566
+ `codesign --verify` on the extracted bundle would assert nothing. Only the embedded ad-hoc
567
+ Mach-O signature exists, and A5 succeeding is the only evidence it survived byte-exact
568
+ extraction. Do not add a codesign step and call it coverage.
569
+ - **Destination-failure classification on darwin** (ENOSPC, read-only `dist/`, EACCES) and
570
+ **`promoteDist` on APFS** — the job calls the promote once, on a happy path.
571
+ - **A trailing-slash symlink entry.** An entry whose *name* ends with `/` while its mode bits
572
+ say `IFLNK` is turned into a real directory before the symlink branch is reached, so the
573
+ escape check never runs. The real artifact has zero such entries, so no darwin job will
574
+ ever exercise it; it belongs in the platform-independent suite.
575
+
576
+ ### Triggers, cost, and the required-check caveat
577
+
578
+ Paths-filtered `pull_request` **and** `push: [main]` (so a bump is proven at merge time),
579
+ plus a weekly cron and `workflow_dispatch`. The cron re-proves the pinned version against the
580
+ live release asset and the current runner image — the two inputs no path filter can see — and
581
+ is the weakest trigger on purpose: a schedule GitHub delays or drops is silent.
582
+
583
+ The job owns a ~122 MB download plus two ~600 MB extractions on a 3-vCPU / 8 GB runner;
584
+ budget 4-6 minutes. It is not free, and macOS *concurrency* rather than minutes is the
585
+ binding constraint on a public repo — `ci.yml` already burns two macOS legs per push.
586
+
587
+ Because both event triggers carry a `paths:` filter, the job reports **skipped** when nothing
588
+ matches, so **it cannot be a required status check as written**. Making it required means
589
+ dropping `paths:` and moving the guard inside the job (a `git diff --name-only` early exit) —
590
+ a pattern this repo does not currently use.
591
+
592
+ ### Running it by hand on a Mac
593
+
594
+ ```bash
595
+ npm ci --foreground-scripts
596
+ rm -rf node_modules/electron/dist node_modules/electron/path.txt
597
+ node node_modules/electron/install.js # the artifact + the A4 reference tree
598
+ node scripts/probe-darwin-extract.js --preflight
599
+ node scripts/probe-darwin-extract.js
600
+ ```
601
+
602
+ The `rm -rf` is not cosmetic: `install.js` short-circuits on a populated `dist/`, and on a dev
603
+ Mac that `dist/` may well be amicus's own self-heal output — which would make A4 compare
604
+ amicus against amicus. The workflow does the same removal for the same reason.
@@ -421,18 +421,118 @@ The refused bytes are never extracted, so no Electron is installed *from them*.
421
421
  - **`npm install` (offline by design)** repeats the refusal reason in its notice and stops there — it never downloads.
422
422
  - **`amicus doctor --fix` and first GUI use** re-download the artifact with the digest pinned, so a one-off bad file self-heals and is reported as installed. Only when that retry cannot rescue it — no network, or the fresh download fails too — does `doctor` repeat the refusal reason instead of its generic "not provisioned".
423
423
 
424
- **Cause:** the bytes do not match the sha256 Electron itself publishes for that artifact (`node_modules/electron/checksums.json`). Amicus hashes a cached zip **before** extracting it, and pins the digest on the download, so bytes that *contradict* a published digest never become an Electron install. Where no published digest covers the artifact at all — an Electron package that ships no `checksums.json`, or one whose own `package.json` names a version amicus holds no entry for — there is nothing to contradict: those bytes are extracted and the outcome is marked `unverified` rather than refused, because refusing would strand every older Electron in a re-download loop. The gate is an integrity check against a digest amicus can obtain, not a promise that every artifact was vouched for.
424
+ **Cause:** the bytes do not match the sha256 Electron itself publishes for that artifact (`node_modules/electron/checksums.json`). Amicus reads the artifact **once**, into its own memory, and everything after that — the sha256, the extraction — acts on those bytes and never on the file again: one `open`, one buffer, no second look at any path. So bytes that *contradict* a published digest never become an Electron install, and nothing on disk can be swapped between the hash and the extract, because after the read there is no path in play at all. Both routes work this way, the cached artifact and the fresh download. Where no published digest covers the artifact at all — an Electron package that ships no `checksums.json`, or one whose own `package.json` names a version amicus holds no entry for — there is nothing to contradict, and nothing to pin a download to: those bytes are extracted and the outcome is marked `unverified` rather than refused, because refusing would strand every older Electron in a re-download loop. That mark is not decorative: `npm install` prints a note when it installs one, the GUI says so on the launch that provisions it, and `amicus doctor --fix` names it in its self-heal line. **The mark covers the other unverified case too** — an artifact whose sha256 *contradicts* the published one and was installed anyway because `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` is set. That is the more alarming of the two, and both provision routes mark it: the only verdict that reports a clean repair is one where amicus's own hash matched a digest it anchored. Both routes say so on stderr as they do it — a cached artifact reports `no published sha256 for …, so its bytes could not be verified`, and a download reports `… could not be pinned`, meaning the bytes were checked only against the `SHASUMS256.txt` the mirror itself served. The gate is an integrity check against a digest amicus can obtain, not a promise that every artifact was vouched for.
425
425
 
426
426
  That is what a swapped mirror or a planted cache file looks like. It is **also** what a truncated download, a failing disk, or a corporate mirror serving a *rebuilt* Electron looks like — amicus cannot tell them apart, and says so rather than guessing.
427
427
 
428
428
  **Fix:**
429
429
  - **Let it retry.** Online, amicus removes the offending cache entry — only when its filename and its resolved location both say it really is that cache entry — and downloads again with the digest pinned. A one-off truncated download heals itself.
430
430
  - **Air-gapped / hand-seeded cache:** the refused file is deleted, so re-copy the cache directory from the machine that downloaded it. A partial copy is the usual cause. If the bytes are deliberately different (below), set the variable *before* you re-copy — the next refusal would remove the fresh copy too.
431
- - **You deliberately run a rebuilt Electron:** set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` (see [configuration.md](./configuration.md#gui-and-debug)). It accepts a cached artifact that contradicts the published digest, and drops the digest pin on a download so a rebuilt artifact can be fetched from your own `ELECTRON_MIRROR` at all. It re-enables nothing else: the Electron installer's environment stays scrubbed of every `npm_config_electron_*` / `npm_package_config_electron_*` name either way.
431
+ - **You deliberately run a rebuilt Electron:** set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` (see [configuration.md](./configuration.md#gui-and-debug)). It accepts a cached artifact that contradicts the published digest, and drops the digest pin on a download so a rebuilt artifact can be fetched from your own `ELECTRON_MIRROR` at all. Everything it lets through is marked `unverified`, on both routes, and reported everywhere that mark is read. It widens **two** further things, and both are worth knowing before you set it. The first is the [native-extractor rescue](#amicus-could-not-read-this-electron-archive): with this variable set, an archive amicus's own extractor cannot read is written to a path and handed to your OS's extractor — which spends the custody property everything else on this page rests on, that amicus only ever writes bytes it hashed. The second is a residual on the environment strip. Every `npm_config_electron_*` / `npm_package_config_electron_*` name is stripped from the environment while amicus works out **where the artifact comes from**, so the zip still comes from the official `github.com/electron/electron/releases/download/…` even in a repository whose `.npmrc` names a mirror. But an unpinned download is exactly what this variable produces, and an unpinned download makes `@electron/get` fetch a `SHASUMS256.txt` of its own *after* those names are restored — so a repo-planted mirror **is** read again for that one fetch (measured on every test run by `tests/electron-env-scrub-get5-contract.test.js`). It cannot change which bytes you get, because the artifact's URL was already settled; it can only serve a checksum file that disagrees with the official artifact, which makes the download **fail**. If an unpinned download fails checksum validation on a machine whose `.npmrc` names an Electron mirror, unset the planted names. [configuration.md](./configuration.md#gui-and-debug) states the same residual with the measurement behind it.
432
432
  - **Otherwise treat it as real.** Check what `ELECTRON_MIRROR` is set to, and whether the directory you ran `npx -y amicus@latest` in is one you trust.
433
433
  - Headless runs and the full council work without the GUI in every one of these cases.
434
434
 
435
- **A second, rarer refusal:** `Electron artifact REFUSED (unsafe archive)` means entries inside the zip tried to write *outside* the destination directory. That one is terminal by design — amicus does not retry it with a different extractor, does not delete the file (it is the evidence), and `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not apply to it. Report the mirror or cache the archive came from.
435
+ **A second, rarer refusal:** `Electron artifact REFUSED (unsafe archive)` means entries inside the zip tried to write *outside* the destination directory. That one is terminal by design — amicus does not retry it with a different extractor, does not delete the file (it is the evidence), and `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not apply to it, nor does the native-extractor rescue below. Report the mirror or cache the archive came from.
436
+
437
+ ---
438
+
439
+ ## amicus could not read this Electron archive
440
+
441
+ **Symptom:** provisioning stops with
442
+
443
+ ```
444
+ [amicus] amicus could not read this Electron archive: could not read the archive: ...
445
+ [amicus] There is ONE rescue for that, and it is OFF. With
446
+ [amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 set BEFORE provisioning, amicus writes the bytes
447
+ [amicus] it hashed into a private directory inside the electron package and hands that PATH
448
+ [amicus] to a native extractor (tar / Expand-Archive / ditto / unzip) — ...
449
+ ```
450
+
451
+ **Cause:** amicus extracts the artifact **in memory**, from the buffer it hashed, so that the bytes it writes are always the bytes it verified. This message means that extractor could not read the archive at all — a truncated or malformed zip, or a shape it does not handle. The bytes are not written anywhere and no Electron is installed from them. A *cached* artifact identified this way is normally discarded, because a positively-unreadable archive is the one failure that says the cached file is worthless — but **not on the run that prints this message**, which leaves it exactly where it is. The rescue below is the reason: an offer that tells you to set a variable and provision again cannot delete the only copy that re-run could act on. Amicus discards the artifact once the rescue has actually been tried and every native extractor has failed on it too.
452
+
453
+ **Fix, in order:**
454
+
455
+ - **Online, do nothing.** Amicus downloads the artifact again with the digest pinned. A truncated download
456
+ heals itself. The exception is an archive amicus refuses outright for path traversal — that is terminal
457
+ by design and is not re-fetched, because the problem is what the archive *contains*, not that it arrived
458
+ incomplete.
459
+ - **Air-gapped, re-copy first.** Copy the cache directory again from the machine that downloaded it. A partial copy is the usual cause, and a fresh copy costs you nothing.
460
+ - **Only if you cannot obtain another copy: the native-extractor rescue.** Set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` and provision again. You do not have to have set it in advance: the run that printed the message above left the archive in place precisely so this one has something to work on. Amicus then writes the bytes it hashed to a path inside the Electron package and hands that path to your OS's own extractor (`tar` / `Expand-Archive` / `ditto` / `unzip`) — the same tools that handled the archive before amicus extracted in memory at all. **This is not a safe operation, and it is not described as one.** Between amicus writing the file and the child process opening it, anything running as your user can substitute it, and what that child extracts is promoted into `dist/` without being hashed again. Amicus prints the whole trade on stderr before it spawns anything, and the result is reported `unverified` even when the artifact's own sha256 matched. Unset the variable afterwards: it also downgrades a digest-mismatch refusal to a warning ([configuration.md](./configuration.md#gui-and-debug)).
461
+ - **What the rescue will *not* do,** whatever this variable is set to: retry an archive refused for path traversal (`REFUSED (unsafe archive)` — terminal by design), retry an extraction that *stalled* (the timeout exists to stop work, not to hand it to a child process), rescue bytes that contradict the published digest (they are known wrong), or paper over a full or unwritable disk. Each of those says something different from "this archive cannot be read", and only the last of those is a rescue amicus was given. Before it hands anything over, amicus also reads the archive's *entry names* and refuses any that would write outside the destination — because an archive can break the extractor early enough that its own traversal check never ran. It reads them in **both** tables a zip declares them in, so cutting the tail off an archive no longer hides its names, and the two tables disagreeing does not let one through. It still cannot see a symlink target, and an archive that defeats both walks still reaches the extractor — the notice printed before the spawn says which names were checked; [configuration.md](./configuration.md#gui-and-debug) states every residual and names which extractors were measured to refuse a `..` entry themselves.
462
+ - Headless runs and the full council work without the GUI throughout.
463
+
464
+ ---
465
+
466
+ ## Electron artifact NOT extracted (could not be read)
467
+
468
+ **Symptom:** provisioning stops with
469
+
470
+ ```
471
+ [amicus] Electron artifact NOT extracted: electron-v43.1.1-win32-x64.zip
472
+ [amicus] C:\Users\me\AppData\Local\electron\Cache\<sha>\electron-v43.1.1-win32-x64.zip
473
+ [amicus] could not be opened or read at all (EACCES: permission denied, open '...')
474
+ [amicus] amicus reads an artifact ONCE, into memory, and hashes and extracts THOSE
475
+ [amicus] bytes. It could not read these, so it has nothing it could vouch for and
476
+ [amicus] has extracted nothing. The file was left exactly where it is.
477
+ ```
478
+
479
+ **Cause:** amicus reads an Electron artifact exactly once, through a single file descriptor, into a
480
+ buffer of its own. The sha256 and the extraction both act on that buffer. Anything that can write the
481
+ download cache — which, on your own machine, includes anything running as you — can change the file
482
+ afterwards, and it changes nothing: the bytes amicus hashed are already the bytes it is going to
483
+ write. When the read itself cannot be completed there is nothing to vouch for, so amicus refuses
484
+ rather than extracting something it never saw whole.
485
+
486
+ The third line names which way the read failed:
487
+
488
+ | line | what happened |
489
+ |---|---|
490
+ | `could not be opened or read at all` | permissions, a broken path, a disconnected drive — the fs error is quoted |
491
+ | `is not a regular file` | the cache entry is a directory, a fifo, or a device node |
492
+ | `is empty` | a zero-byte file, usually an interrupted download |
493
+ | `is far larger than any electron artifact` | above the 1 GiB ceiling; a real artifact is ~140–160 MB |
494
+ | `ended early while amicus was reading it` | the file is shorter than it said it was |
495
+ | `changed size while amicus was reading it` | it grew under the read — what an active swap looks like |
496
+
497
+ **Fix:**
498
+ - Delete the cache entry the message names and provision again; amicus re-downloads it.
499
+ - Check the permissions on the cache root (`ELECTRON_CACHE`, or `%LOCALAPPDATA%\electron\Cache` /
500
+ `~/Library/Caches/electron` / `~/.cache/electron`).
501
+ - Headless runs and the full council work without the GUI meanwhile.
502
+
503
+ **Notes:**
504
+ - **Your cached artifact is never moved, copied, or deleted by this refusal.** The download cache is
505
+ exactly as it was, whether the repair succeeded, was refused, or was interrupted — which matters
506
+ most on an air-gapped machine whose cache was hand-seeded. Nothing is written to the temp directory
507
+ either: earlier versions staged a ~170 MB copy there, and no longer do.
508
+ - **A killed run can leave one directory behind, and the next provision sweeps it.** Extraction
509
+ happens in `<electron package>/.amicus-incoming-<hex>/`, which is promoted into `dist/` by a single
510
+ rename at the end and removed afterwards — but that removal is an in-process `finally`, and a kill
511
+ does not run it. Ctrl-C during `npm install`, a lid close or an AV kill can therefore leave one
512
+ behind, as can the sibling `.amicus-retired-<hex>` when an Electron is running off the tree being
513
+ replaced (Windows refuses to delete it). Both live inside the Electron package directory, where no
514
+ OS temp cleaner reaches them, so **amicus sweeps them itself: every provision removes any
515
+ `.amicus-incoming-*` or `.amicus-retired-*` in that directory that is more than a day old.** The
516
+ age rule is deliberate — it cannot take a tree another run may still be writing. What is still
517
+ guaranteed either way: a half-written tree is never what `dist/` contains, and a kill mid-extract
518
+ leaves the previous `dist/` exactly where it was.
519
+ - **A promote never removes a working `dist/` to make room.** If the old tree cannot be renamed out of
520
+ the way (a handle held on it, or an AV filter denying the move) and it holds a usable executable —
521
+ the one `path.txt` names, or this platform's default when `path.txt` is absent, unreadable or blank
522
+ — the repair refuses and leaves it untouched rather than deleting it with no way back. That covers
523
+ a package cross-installed for another platform through `npm_config_platform`, whose `path.txt` names
524
+ an executable this platform never looks for; through v4.9.6 the guard asked only about this
525
+ platform's default name, so such a tree was read as "not an install" and a promote that FAILED
526
+ deleted it. If it holds neither, it is not an install, and it is replaced. In the one case where the
527
+ tree was renamed away and neither the swap nor the rollback could run, the previous `dist/` is
528
+ intact at `.amicus-retired-<hex>` and the error names it — rename it back to `dist/` to restore it.
529
+ - **A related refusal**, `Refusing to provision electron: … is not a usable artifact name`, means the
530
+ `version` in the Electron package's own `package.json` is not a plausible version string. Amicus
531
+ builds the artifact filename from it and refuses to use anything that is not a plain filename, since
532
+ it would otherwise be joined into a path. Reinstall the `electron` package.
533
+ - **`Cached electron artifact … was NOT extracted (…); it was LEFT IN PLACE`** is the other half of
534
+ the same rule: the archive was fine and the *destination* was not (no space, an unwritable `dist/`,
535
+ a path too long). A cached artifact is only ever evicted when the archive itself is bad.
436
536
 
437
537
  ---
438
538
 
package/docs/usage.md CHANGED
@@ -540,7 +540,7 @@ $ amicus status demo123 --json
540
540
  "taskId": "demo123",
541
541
  "status": "complete",
542
542
  "elapsed": "5m 0s",
543
- "version": "4.9.5",
543
+ "version": "4.9.7",
544
544
  "model": "google/gemini-2.5-flash",
545
545
  "phase": "terminal"
546
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.5",
3
+ "version": "4.9.7",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -84,6 +84,7 @@
84
84
  "opencode-ai": "1.18.15",
85
85
  "tiktoken": "^1.0.0",
86
86
  "update-notifier": "^7.3.1",
87
+ "yauzl": "^2.10.0",
87
88
  "zod": "^3.0.0"
88
89
  },
89
90
  "optionalDependencies": {
@@ -42,7 +42,18 @@ const PROVISION_TIMEOUT_MS = 15000;
42
42
  * there is no cache (or the repair defers/contends), we emit a clear notice that
43
43
  * the GUI provisions on first use and that headless runs + the council already
44
44
  * work, then point at `amicus doctor --fix` (#56) — NOT a reinstall, which can
45
- * loop. A short timeout keeps a slow disk from ever hanging the install.
45
+ * loop.
46
+ *
47
+ * WHAT IS ACTUALLY BOUNDED HERE, stated precisely because the old sentence
48
+ * ("a short timeout keeps a slow disk from ever hanging the install") named a
49
+ * bound that does not exist on the default path. `PROVISION_TIMEOUT_MS` reaches
50
+ * `repairElectron` as `timeoutMs`, and `timeoutMs` becomes the DOWNLOAD budget
51
+ * (`electron-provision.js`'s `downloadMs`) — which the cache-only default path
52
+ * never uses, because it never downloads. The extract is what could hang here,
53
+ * and it is bounded by the extractor's own idle and hard caps
54
+ * (`zip-from-buffer.js`: 30 s with no progress, 240 s total), whose live timer
55
+ * handle is also what stops Node exiting 0 in the middle of a stall. Both
56
+ * bounds are real; neither is the other.
46
57
  *
47
58
  * This MUST never throw out of postinstall — the whole body (sync setup, the
48
59
  * awaited repair, and a synchronous-throw resolver) is guarded so nothing here
@@ -58,6 +69,15 @@ const PROVISION_TIMEOUT_MS = 15000;
58
69
  * @param {object} deps - { repairElectron } override for testing.
59
70
  * @returns {Promise<void>}
60
71
  */
72
+ function warnIfUnverified(result) {
73
+ if (!result || !result.unverified) { return; }
74
+ console.warn('[amicus] Note: the Electron GUI binary was installed UNVERIFIED — either no published sha256');
75
+ console.warn('[amicus] covered this artifact, so its bytes were checked only against whatever the mirror');
76
+ console.warn('[amicus] served, or its sha256 CONTRADICTED the published one and');
77
+ console.warn('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON accepted it anyway.');
78
+ console.warn('[amicus] See docs/troubleshooting.md (Electron artifact REFUSED).');
79
+ }
80
+
61
81
  async function provisionElectron(deps = {}) {
62
82
  try {
63
83
  const _repair = deps.repairElectron || repairElectron;
@@ -65,9 +85,10 @@ async function provisionElectron(deps = {}) {
65
85
  // Opt-in aggressive prewarm (#60): full fetch if needed. Non-fatal.
66
86
  if (process.env.AMICUS_PREFETCH_ELECTRON === '1') {
67
87
  console.log('[amicus] AMICUS_PREFETCH_ELECTRON=1 — prewarming the Electron GUI binary (may download)...');
68
- const forced = await _repair({ force: true });
88
+ const forced = await _repair();
69
89
  if (forced && forced.repaired) {
70
90
  console.log('[amicus] Electron GUI binary prewarmed.');
91
+ warnIfUnverified(forced);
71
92
  return;
72
93
  }
73
94
  if (forced && forced.quarantined) {
@@ -80,7 +101,7 @@ async function provisionElectron(deps = {}) {
80
101
  }
81
102
 
82
103
  const result = await _repair({ cacheOnly: true, timeoutMs: PROVISION_TIMEOUT_MS });
83
- if (result && result.repaired) { return; }
104
+ if (result && result.repaired) { warnIfUnverified(result); return; }
84
105
  // AV quarantine (electron.exe deleted right after extract) needs ACTION, not
85
106
  // a generic "provisions on first use" notice — re-extracting can never win,
86
107
  // so print the allow-list instruction verbatim instead. (No retry loop.)
@@ -10,6 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
+ const fsDefault = require('fs');
13
14
  const path = require('path');
14
15
  const os = require('os');
15
16
 
@@ -39,4 +40,43 @@ function resolveCacheRoots(env = process.env) {
39
40
  return [...new Set(roots.filter(Boolean))];
40
41
  }
41
42
 
42
- module.exports = { resolveCacheRoots, defaultCacheRoot };
43
+ /**
44
+ * Locate a previously-downloaded electron zip in the env-configurable cache
45
+ * roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
46
+ *
47
+ * MOVED here from electron-install.js (v4.9.6 F1): that file sits at the 300-line
48
+ * gate with no headroom, and the F1 staging wiring had to go somewhere. Cache
49
+ * LOOKUP belongs beside cache-root RESOLUTION anyway; electron-install.js
50
+ * re-exports it so `ei.cachedZip` stays a valid import.
51
+ *
52
+ * The `<sha>` directory names come from `readdirSync` on a directory an attacker
53
+ * may write, so the returned path is attacker-INFLUENCED. Callers must treat it
54
+ * as such: read it ONCE into memory and hash and extract THOSE bytes, never
55
+ * resolving the name a second time (sidecar/electron-custody.js), and never
56
+ * print it unsanitized (utils/text-sanitize.js).
57
+ * @returns {string|null} absolute zip path, or null when no cache hit.
58
+ */
59
+ function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
60
+ const zipName = `electron-v${version}-${platform}-${arch}.zip`;
61
+ for (const root of resolveCacheRoots(env)) {
62
+ let shaDirs;
63
+ try {
64
+ shaDirs = fs.readdirSync(root);
65
+ } catch {
66
+ continue;
67
+ }
68
+ for (const sha of shaDirs) {
69
+ const candidate = path.join(root, sha, zipName);
70
+ try {
71
+ if (fs.existsSync(candidate)) {
72
+ return candidate;
73
+ }
74
+ } catch {
75
+ /* ignore unreadable subdir */
76
+ }
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ module.exports = { resolveCacheRoots, defaultCacheRoot, cachedZip };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * CUSTODY of the Electron artifact: one open, one read, one Buffer.
3
+ *
4
+ * THE PROPERTY THIS MODULE EXISTS TO MAKE TRUE, and the only one it claims:
5
+ *
6
+ * **Amicus never itself writes, or reports as verified, bytes it did not hash.**
7
+ *
8
+ * Deliberately NOT "the user launches genuine Electron". A live attacker
9
+ * running as the same uid can overwrite `<electronDir>/dist/electron.exe`
10
+ * directly, at any moment, with no artifact involved at all — so no
11
+ * acquisition-time design can promise that, and claiming it would be the
12
+ * overclaim this whole change exists to stop.
13
+ *
14
+ * WHY A BUFFER, AND NOT A FILE DESCRIPTOR. Two council designs independently
15
+ * probed the fd remedy and both refuted it: a descriptor names an INODE, not a
16
+ * version of an inode. A same-uid `writeFileSync` at the path truncates and
17
+ * rewrites that same inode, and a positional read through our retained fd then
18
+ * returns the attacker's bytes (MEASURED twice, on Windows 11 / NTFS / Node
19
+ * 24.18: `"CLEANCLEANCLEAN"` before, `"POISONPOISONPOI"` after, through the
20
+ * SAME fd). A Buffer is different in kind: once the bytes are in this process's
21
+ * heap, no filesystem write can reach them. That is the whole design.
22
+ *
23
+ * WHY NOT A PRIVATE COPY EITHER — the remedy this replaces. v4.9.6 copied the
24
+ * artifact into a fresh 0700 `mkdtempSync` directory and asserted the attacker
25
+ * had "no name for it and no handle on it". MEASURED false on both halves: a
26
+ * spinner found the fixed `amicus-electron-stage-` prefix on its FIRST readdir
27
+ * of `os.tmpdir()`, opened the copy `r+` as the same user, and overwrote it;
28
+ * and on Windows `mkdtempSync` yields mode 666 while the module skipped its own
29
+ * `chmod(0o700)` on win32, so the 0700 was never even attempted. 0700 excludes
30
+ * OTHER users; the attacker in this threat model is THIS user.
31
+ *
32
+ * WHAT THE THREAT MODEL IS. An attacker who can write the Electron download
33
+ * cache directory, running as the same user as amicus. Out of scope, and stated
34
+ * rather than implied: that same user can also read and write amicus's process
35
+ * memory (`WriteProcessMemory`, or `process_vm_writev` under
36
+ * `yama.ptrace_scope=0`), rewrite amicus's own `node_modules`, or edit its
37
+ * config. Against THAT capability nothing here matters — an attacker in our
38
+ * address space can simply flip the gate's own verdict. This module closes the
39
+ * attacker whose capability is WRITING FILES, which is the one the digest gate
40
+ * makes sense against: a less-trusted cache root — a shared build box, a
41
+ * restored CI cache volume, a container bind-mount — read by a process whose
42
+ * own tree is trusted.
43
+ *
44
+ * NEAR-LEAF MODULE: `fs` + `path`, plus the pure house sanitizer
45
+ * `utils/text-sanitize`. Requires nothing in this cluster, so it can be
46
+ * required from either side of the electron-install -> electron-provision arrow.
47
+ *
48
+ * @module sidecar/electron-custody
49
+ */
50
+
51
+ 'use strict';
52
+
53
+ const fsDefault = require('fs');
54
+ const path = require('path');
55
+
56
+ /** Positional-read chunk. 8 MiB keeps the loop at ~18 reads for a 138 MiB artifact. */
57
+ const READ_CHUNK = 8 * 1024 * 1024;
58
+
59
+ /**
60
+ * An electron artifact larger than this is not an electron artifact. The real
61
+ * win32 x64 artifact measured 138 MiB (144,265,219 bytes); darwin and linux are
62
+ * the same order. The cap is checked against `fstat`'s size BEFORE anything is
63
+ * allocated, so a 4 GiB sparse file planted in the cache costs one `fstat`.
64
+ */
65
+ const MAX_ARTIFACT_BYTES = 1024 * 1024 * 1024;
66
+
67
+ /**
68
+ * The ONLY shape allowed to become a path component:
69
+ * `electron-v<version>-<platform>-<arch>.zip`, with each field restricted to
70
+ * characters an electron version / platform / arch can actually contain.
71
+ *
72
+ * An ALLOW-list on purpose. A deny-list of `..` and separators is the shape
73
+ * that keeps losing — it has to anticipate every dialect (`..`, `%2e%2e`, a
74
+ * bare `\` that only win32's `path` treats as a separator), and it fails open
75
+ * on the one it did not think of. This fails closed on everything it was not
76
+ * written for.
77
+ *
78
+ * MOVED HERE from the deleted `electron-stage.js`, unchanged. It is still
79
+ * load-bearing: `version` is read out of `<electronDir>/package.json` whenever
80
+ * the caller supplies none, and `doctor --fix` — the one production caller —
81
+ * supplies none, for a directory it located by SCANNING npx caches. MEASURED
82
+ * before the check, end to end, with a planted `"version": "43.1.1/../../victim"`:
83
+ * a path two levels outside the intended directory was written and a
84
+ * pre-existing file there was destroyed.
85
+ */
86
+ const ARTIFACT_NAME = /^electron-v[0-9A-Za-z][0-9A-Za-z.+-]*-[0-9A-Za-z_]+-[0-9A-Za-z_]+\.zip$/;
87
+
88
+ /**
89
+ * True when `fileName` is a plain filename amicus itself could have produced.
90
+ *
91
+ * Both halves are checked deliberately. The pattern is the real control; the
92
+ * `path.basename` equality states the property in the platform's OWN dialect,
93
+ * so the claim "this is a filename, not a path" is asserted by the module that
94
+ * defines what a path is rather than only by a regex that has to imitate it.
95
+ * @param {*} fileName
96
+ * @returns {boolean}
97
+ */
98
+ function isSafeArtifactName(fileName) {
99
+ return typeof fileName === 'string'
100
+ && fileName === path.basename(fileName)
101
+ && ARTIFACT_NAME.test(fileName);
102
+ }
103
+
104
+ /**
105
+ * Read `zip` into memory EXACTLY ONCE, through ONE descriptor.
106
+ *
107
+ * THE PATH IS RESOLVED ONCE AND NEVER AGAIN. `fstatSync(fd)` — never
108
+ * `statSync(zip)` — so even the size we act on comes from the handle we opened;
109
+ * a symlink is already resolved, and a swap after this point cannot change the
110
+ * answer. Every read is POSITIONAL (`readSync(fd, buf, off, len, POSITION)`),
111
+ * so the shared file offset is never used and nothing else in this process can
112
+ * perturb it.
113
+ *
114
+ * THERE IS NO "COULD NOT ALLOCATE" BRANCH, and that is deliberate. Two council
115
+ * designs promised one; both were MEASURED wrong. `Buffer.allocUnsafe` does not
116
+ * throw when the machine is out of memory — the process dies, exactly as it
117
+ * does today when extract-zip inflates a 215 MiB entry. Writing a clean
118
+ * `{why:'no-memory'}` refusal and claiming it works would be a failure branch
119
+ * this change never executed. What DOES protect the allocation is `maxBytes`,
120
+ * checked against `fstat` before a byte is reserved.
121
+ *
122
+ * A TORN READ NEEDS NO SPECIAL HANDLING. If the attacker mutates the file while
123
+ * we are reading it, the buffer we assembled is what would have been extracted,
124
+ * its sha256 will not match the anchor, and the gate refuses. `grew` and
125
+ * `short-read` exist to name the shape, not to provide the security.
126
+ *
127
+ * @param {object} o
128
+ * @param {string} o.zip attacker-influenced path (a cache entry, or what
129
+ * `downloadArtifact` handed back)
130
+ * @param {number} [o.maxBytes] default MAX_ARTIFACT_BYTES
131
+ * @param {object} [o.fs]
132
+ * @returns {{bytes: Buffer, size: number}
133
+ * | {bytes: null, why: 'unreadable'|'not-a-file'|'empty'|'too-large'|'short-read'|'grew',
134
+ * detail: string}}
135
+ */
136
+ function readArtifactBytes({ zip, maxBytes = MAX_ARTIFACT_BYTES, fs = fsDefault }) {
137
+ let fd;
138
+ try {
139
+ fd = fs.openSync(zip, 'r');
140
+ } catch (e) {
141
+ return { bytes: null, why: 'unreadable', detail: (e && e.message) || String(e) };
142
+ }
143
+ try {
144
+ const st = fs.fstatSync(fd);
145
+ if (!st.isFile()) {
146
+ return { bytes: null, why: 'not-a-file', detail: 'it is not a regular file' };
147
+ }
148
+ if (st.size === 0) {
149
+ return { bytes: null, why: 'empty', detail: 'it is empty' };
150
+ }
151
+ if (st.size > maxBytes) {
152
+ return { bytes: null, why: 'too-large', detail: `${st.size} bytes exceeds the ${maxBytes}-byte ceiling` };
153
+ }
154
+ const bytes = Buffer.allocUnsafe(st.size);
155
+ let off = 0;
156
+ while (off < st.size) {
157
+ const n = fs.readSync(fd, bytes, off, Math.min(READ_CHUNK, st.size - off), off);
158
+ if (!(n > 0)) {
159
+ return { bytes: null, why: 'short-read', detail: `the file ended after ${off} of ${st.size} bytes` };
160
+ }
161
+ off += n;
162
+ }
163
+ // One positional read PAST the size we trusted. A file that grew under us is
164
+ // what an active swap looks like, and it means the bytes we hold are a prefix
165
+ // of something else — say that, rather than hashing a truncation.
166
+ const tail = Buffer.allocUnsafe(1);
167
+ if (fs.readSync(fd, tail, 0, 1, st.size) > 0) {
168
+ return { bytes: null, why: 'grew', detail: 'it changed size while amicus was reading it' };
169
+ }
170
+ return { bytes, size: st.size };
171
+ } catch (e) {
172
+ return { bytes: null, why: 'unreadable', detail: (e && e.message) || String(e) };
173
+ } finally {
174
+ try { fs.closeSync(fd); } catch { /* already closed */ }
175
+ }
176
+ }
177
+
178
+ module.exports = {
179
+ readArtifactBytes, isSafeArtifactName, MAX_ARTIFACT_BYTES, READ_CHUNK,
180
+ };
@@ -45,7 +45,7 @@ function _resetEnsureElectron() {
45
45
  * @param {object} [opts.deps] injected
46
46
  * { isElectronUsable, resolveElectronBinary, repairElectron, logProgress }.
47
47
  * @param {object} [opts.repairOptions] forwarded to repairElectron (electronDir, etc.).
48
- * @returns {Promise<{ok:boolean, path?:string, reason?:string}>}
48
+ * @returns {Promise<{ok:boolean, path?:string, reason?:string, unverified?:boolean}>}
49
49
  */
50
50
  function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
51
51
  const usable = deps.isElectronUsable || defaultIsUsable;
@@ -71,11 +71,32 @@ function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
71
71
  return { ok: false, reason: `Electron provisioning failed: ${err && err.message}` };
72
72
  }
73
73
  if (usable()) {
74
+ // A2/B3 (council, confirmed 4 of 4): `unverified` was WRITTEN on both
75
+ // provision routes and read by nothing in src/ or scripts/, while the docs
76
+ // said the outcome was "marked unverified". A flag no code and no human
77
+ // ever sees establishes no property at all. This is the launch-time
78
+ // reader; scripts/postinstall.js is the install-time one and
79
+ // doctor-electron-mcp-check.js reports it from `--fix`.
80
+ if (result && result.unverified) {
81
+ logProgress('[amicus] NOTE: this Electron binary is UNVERIFIED — either no published sha256 covered');
82
+ logProgress('[amicus] the artifact it came from, so its bytes were vouched for only by the');
83
+ logProgress('[amicus] mirror that served them, or its sha256 CONTRADICTED the published one and');
84
+ logProgress('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON accepted it anyway.');
85
+ }
74
86
  logProgress('[amicus] Electron GUI ready.');
75
- return { ok: true, path: resolve() };
87
+ return { ok: true, path: resolve(), ...(result && result.unverified ? { unverified: true } : {}) };
76
88
  }
77
- const reason = (result && result.reason)
78
- || `Electron could not be provisioned; the GUI is unavailable. ${HINTS.doctorFix} (or use --no-ui).`;
89
+ // THE POINTER IS NOT OPTIONAL. This used to be `result.reason || <the
90
+ // pointer>`, so the moment `repairElectron` started returning a reason for a
91
+ // failed controlled download (v4.9.6, when the last-resort installer was
92
+ // deleted and the failure became something to REPORT), the one line telling
93
+ // the user what to do next silently disappeared. A more detailed message is
94
+ // not a reason to stop giving advice.
95
+ const detail = (result && result.reason) || 'Electron could not be provisioned; the GUI is unavailable.';
96
+ // Matched on the COMMAND, not on the whole hint string: the AV-quarantine
97
+ // reason already ends with a bare `amicus doctor --fix` and must not be
98
+ // given a second, longer copy of the same advice.
99
+ const reason = detail.includes('doctor --fix') ? detail : `${detail} ${HINTS.doctorFix} (or use --no-ui).`;
79
100
  return { ok: false, reason };
80
101
  })().then((r) => {
81
102
  // Only memoize SUCCESS; a failure clears the guard so a later launch retries.