amicus 4.9.5 → 4.9.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.5",
3
+ "version": "4.9.6",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,92 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.6] - 2026-09-08
7
+
8
+ *Amicus never itself writes, or reports as verified, bytes it did not hash.*
9
+
10
+ v4.9.5 hashed the Electron artifact **at a path** and then handed **that path** to an extractor,
11
+ which re-opened it. Anyone able to write the Electron cache directory — running as the same user —
12
+ could substitute the bytes in between, and the swapped archive was extracted and launched. Three
13
+ remedies were tried and two were defeated by measurement before one held; the failures are recorded
14
+ below because they are the useful part.
15
+
16
+ ### Security
17
+
18
+ - **The artifact is read once, into memory, and never re-opened (#237).** One `open`, `fstat` on the
19
+ descriptor rather than `stat` on the name, positional reads into a single Buffer, sha256 over
20
+ **that Buffer**, extraction from **that Buffer**. There is no second path resolution left to race:
21
+ instrumenting every path-taking call across a full repair records exactly one `openSync` of the
22
+ artifact, and poisoning the file on disk afterwards cannot alter the hashed bytes. Both the cached
23
+ and the downloaded routes go through it.
24
+
25
+ **What was tried first, and why it failed** — because "we staged it privately" is the intuition
26
+ this release exists to correct:
27
+ - *Rename into a private directory.* A rename moves a directory **entry**, not an inode. An
28
+ attacker who hard-links the cache entry keeps a name for the same bytes and writes through it
29
+ after the rename.
30
+ - *Copy into a `0700` directory under the system temp.* `0700` excludes **other** users, not the
31
+ one the threat model actually assumes. Worse in practice: the directory prefix was fixed and
32
+ discoverable on a first `readdir`, and on Windows the `chmod` was skipped entirely, so the mode
33
+ was never even attempted.
34
+ - *A retained file descriptor.* Not custody either: a same-user `writeFileSync` truncates and
35
+ rewrites the **same inode**, and the held descriptor then reads the substituted bytes.
36
+
37
+ - **The last-resort Electron installer is deleted.** It performed its own download and extraction,
38
+ bypassing every control this release adds; it pinned checksums out of the **scanned** directory,
39
+ reopening the anchor hole v4.9.5 closed; and it extracted with no stall bound at all. Its one
40
+ claimed justification — that amicus's dependency tree might fail to resolve `@electron/get` where
41
+ Electron's own tree succeeds — was measured false from both resolution paths.
42
+
43
+ - **A native-extractor rescue exists, but only behind `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`.** Every
44
+ OS extractor takes a **path**, so using one means writing bytes down and letting a child process
45
+ open them — the exact custody the rest of this release establishes. That trade is available only
46
+ under a flag whose documented meaning is already "I accept Electron bytes amicus cannot vouch
47
+ for", it is announced on stderr **before** the child is spawned in the terms above rather than as
48
+ a safe operation, and its result is always marked `unverified` even when the artifact's own sha256
49
+ matched. It fires on one classified failure — the extractor's positive "this archive is bad"
50
+ verdict — and on nothing else: a path-traversal refusal stays terminal and does not even mention
51
+ the flag, because inviting a retry would be laundering a security refusal through a human.
52
+
53
+ - **`yauzl` is now a declared dependency.** It previously resolved only through `extract-zip`, which
54
+ is the shape of the v4.5.2 outage this project already recorded.
55
+
56
+ - **The repo-plantable Electron mirror names are scrubbed around amicus's own in-process download**,
57
+ not only the (now removed) child spawn. Measured against the installed library rather than
58
+ assumed: on the pinned route all twenty reads land inside the scrub window and none after. A
59
+ contract test re-runs that measurement on every suite run, so a library that moves a read past an
60
+ `await` fails here rather than in the field.
61
+
62
+ ### Fixed
63
+
64
+ - **A promote can no longer cost you a working install.** `path.txt` is written **first**, while
65
+ `dist/` is still whole — its value never depended on the new tree — and a failure there refuses
66
+ the promote instead of leaving a replacement Electron the npm entry point cannot resolve. A
67
+ `path.txt` naming another platform's executable (an `npm_config_platform` cross-install) is
68
+ captured and restored on every failure exit.
69
+ - **The in-memory extraction is bounded and actually stops.** The idle bound arms on **bytes the
70
+ destination accepted**, so a single large file on slow storage is not mistaken for a stall, and
71
+ firing it aborts the pipeline rather than merely reporting a failure while the work continues.
72
+ - **Extraction litter is swept.** Incoming and retired trees from an interrupted promote no longer
73
+ accrete in the Electron package.
74
+
75
+ ### Known limits, stated rather than implied
76
+
77
+ - **The symlink handling is unverified on macOS and Linux.** The darwin artifact is an `.app` bundle
78
+ containing real symlinks, and the target-escape check added here is a behaviour `extract-zip` does
79
+ not have — it could reject a layout that previously worked. It could not be exercised on the
80
+ machine this was built on.
81
+ - Peak memory during a repair rises to roughly 210–260 MB, floored at the artifact size.
82
+ - Unchanged from v4.9.5: `registry=` in a hostile `.npmrc` dominates every control here and amicus
83
+ cannot close it; Electron's own npm postinstall runs before any amicus code; nothing verifies the
84
+ binary at launch — this closes acquisition, not custody of what is already installed; and where no
85
+ published digest covers an artifact at all, those bytes are extracted and marked `unverified`
86
+ rather than refused.
87
+ - Three findings from the final review are deferred to 4.9.7 and filed in `BACKLOG.md`: a failed
88
+ `dist` retirement can still delete a working install whose `path.txt` names another platform's
89
+ executable; a truncated archive whose entry names cannot be read reaches the native rescue; and
90
+ the rescue's cleanup does not reach writes a native tool makes outside its own directory.
91
+
6
92
  ## [4.9.5] - 2026-09-07
7
93
 
8
94
  *A repository you cloned could choose which bytes became your Electron.*
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.5",
458
+ "version": "4.9.6",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/ROADMAP.md CHANGED
@@ -13,13 +13,13 @@ lives under **Backlog (tracked, not scheduled)** with everything else that is re
13
13
  Nothing about the content changed and no judgment about its value is implied; only its status. When
14
14
  an org buyer and the org to support them exist, it earns a number then.
15
15
 
16
- Amicus is at **v4.9.5** (2026-09-07). Each 4.x rev below leads with the benefit, not the
16
+ Amicus is at **v4.9.6** (2026-09-08). Each 4.x rev below leads with the benefit, not the
17
17
  plumbing; the patch releases since v4.9.0 carry no section of their own, because each corrected a
18
18
  defect rather than adding scope — where one added a surface (v4.9.4's `--thinking` refusals and
19
- `output-budget` doctor row, v4.9.5's Electron digest gate) it did so to make an existing promise
20
- true, not to widen it. See `CHANGELOG.md` for what each one contained.
19
+ `output-budget` doctor row, v4.9.5's Electron digest gate, v4.9.6's artifact custody) it did so to
20
+ make an existing promise true, not to widen it. See `CHANGELOG.md` for what each one contained.
21
21
 
22
- **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.5 patch releases —
22
+ **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.6 patch releases —
23
23
  everything on this page is a record of what landed, not a plan. Composition — the scope that
24
24
  carried the number v4.6 here until the degrade-announcement-invariant milestone took the v4.6.0
25
25
  release (2026-08-02) — is now an unscheduled candidate for the next rev, tabled in its own section
@@ -109,13 +109,21 @@ src/
109
109
  │ ├── conversation-mirror.js
110
110
  │ ├── crash-handler.js # Crash Handler - Updates metadata to 'error' on uncaught exceptions
111
111
  │ ├── electron-cache.js # Electron download-cache root resolution (#53 helper).
112
+ │ ├── electron-custody.js # CUSTODY of the Electron artifact: one open, one read, one Buffer.
112
113
  │ ├── electron-ensure.js # ensureElectron() — lazy first-GUI provisioning (#55).
114
+ │ ├── electron-env-scrub.js # The ENV SCRUB — which environment names a hostile REPOSITORY can plant.
113
115
  │ ├── electron-install.js # Electron self-heal primitive (#53, #59).
116
+ │ ├── electron-layout.js # The on-disk LAYOUT of an installed `electron` package: where the executable
114
117
  │ ├── electron-lock.js # Stale-aware single-flight lock for the electron self-heal (#53).
115
- │ ├── electron-provision.js # Electron CONTROLLED provision the pinned download, and what happens to a
118
+ │ ├── electron-native-plan.js # THE MECHANICS OF A RESCUE: write the verified buffer down, walk the platform's
119
+ │ ├── electron-native-rescue.js # THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor
120
+ │ ├── electron-provision.js # Electron CONTROLLED provision — the pinned download, and the fence that says
116
121
  │ ├── electron-quarantine.js # AV / antivirus quarantine detection for the electron self-heal (#53).
122
+ │ ├── electron-refuse.js # Electron artifact REFUSALS — the ways amicus declines to turn bytes into an
123
+ │ ├── electron-repair-cache.js # ATTEMPT 1 of the electron self-heal: turn a CACHED artifact into a `dist/`,
124
+ │ ├── electron-rescue-notice.js # THE TWO NOTICES the native-extractor rescue speaks — the offer a parse failure
117
125
  │ ├── electron-state.js # Electron install-state probes (#76).
118
- │ ├── electron-trust.js # Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
126
+ │ ├── electron-trust.js # Electron artifact TRUST core — the digest anchor and the gate. (The third
119
127
  │ ├── fallback-chains.js
120
128
  │ ├── fanout-budget.js
121
129
  │ ├── fanout-leg-fallback.js
@@ -155,7 +163,11 @@ src/
155
163
  │ ├── unzip.js # Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24).
156
164
  │ ├── wave-progress.js
157
165
  │ ├── workspace-auto-open.js # Workspace Auto-Open Decision Helper
158
- └── workspace-window.js # Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
166
+ ├── workspace-window.js # Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
167
+ │ ├── zip-entry-write.js # ONE ENTRY of an in-memory archive, and the classified failures every caller
168
+ │ ├── zip-from-buffer.js # Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED.
169
+ │ ├── zip-name-scan.js # WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central
170
+ │ └── zip-stall-bound.js # WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work.
159
171
  ├── template/
160
172
  │ ├── apply.js
161
173
  │ ├── render.js
@@ -550,14 +562,22 @@ evals/
550
562
  | `sidecar/continue.js` | Sidecar Continue Operations - Handles continuing from previous sessions | `loadPreviousSession()`, `buildContinuationContext()`, `createContinueSessionMetadata()`, `continueSidecar()` |
551
563
  | `sidecar/conversation-mirror.js` | | `createMirrorState()`, `mirrorMessages()`, `logMessage()`, `mirrorUsageOnly()`, `allAssistantUsagePresent()` |
552
564
  | `sidecar/crash-handler.js` | Crash Handler - Updates metadata to 'error' on uncaught exceptions | `installCrashHandler()` |
553
- | `sidecar/electron-cache.js` | Electron download-cache root resolution (#53 helper). | `resolveCacheRoots()`, `defaultCacheRoot()` |
565
+ | `sidecar/electron-cache.js` | Electron download-cache root resolution (#53 helper). | `resolveCacheRoots()`, `defaultCacheRoot()`, `cachedZip()` |
566
+ | `sidecar/electron-custody.js` | CUSTODY of the Electron artifact: one open, one read, one Buffer. | `readArtifactBytes()`, `isSafeArtifactName()`, `MAX_ARTIFACT_BYTES()`, `READ_CHUNK()` |
554
567
  | `sidecar/electron-ensure.js` | ensureElectron() — lazy first-GUI provisioning (#55). | `ensureElectron()`, `_resetEnsureElectron()` |
568
+ | `sidecar/electron-env-scrub.js` | The ENV SCRUB — which environment names a hostile REPOSITORY can plant. | `isRepoPlantedName()`, `withScrubbedRepoEnv()`, `REPO_ENV_PREFIXES()` |
555
569
  | `sidecar/electron-install.js` | Electron self-heal primitive (#53, #59). | `resolveElectronBinary()`, `isElectronUsable()`, `cachedZip()`, `repairElectron()`, `platformExe()` |
570
+ | `sidecar/electron-layout.js` | The on-disk LAYOUT of an installed `electron` package: where the executable | `platformExe()`, `writePathTxt()`, `promoteDist()`, `extractBytesToDist()`, `sweepPromoteLitter()` |
556
571
  | `sidecar/electron-lock.js` | Stale-aware single-flight lock for the electron self-heal (#53). | `acquireRepairLock()`, `isStaleLock()`, `lockPathFor()`, `STALE_MS()` |
557
- | `sidecar/electron-provision.js` | Electron CONTROLLED provision the pinned download, and what happens to a | `cacheRootFor()`, `controlledProvision()`, `mayDeleteRejectedZip()`, `rejectCachedZip()`, `isUnsafeArchive()` |
572
+ | `sidecar/electron-native-plan.js` | THE MECHANICS OF A RESCUE: write the verified buffer down, walk the platform's | `nativeRescue()`, `RESCUE_ZIP()`, `INCOMING_PREFIX()` |
573
+ | `sidecar/electron-native-rescue.js` | THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor | `withNativeRescue()`, `isRescuableFailure()`, `RESCUE_TRIGGER()`, `RESCUE_ZIP()`, `INCOMING_PREFIX()` |
574
+ | `sidecar/electron-provision.js` | Electron CONTROLLED provision — the pinned download, and the fence that says | `cacheRootFor()`, `controlledProvision()`, `mayDeleteRejectedZip()` |
558
575
  | `sidecar/electron-quarantine.js` | AV / antivirus quarantine detection for the electron self-heal (#53). | `avHint()`, `quarantineReason()`, `verifyExtractOutcome()` |
576
+ | `sidecar/electron-refuse.js` | Electron artifact REFUSALS — the ways amicus declines to turn bytes into an | `isUnsafeArchive()`, `refuseUnsafeArchive()`, `rejectCachedZip()`, `rejectDownloadedZip()`, `refuseUnreadableArtifact()` |
577
+ | `sidecar/electron-repair-cache.js` | ATTEMPT 1 of the electron self-heal: turn a CACHED artifact into a `dist/`, | `repairFromCache()` |
578
+ | `sidecar/electron-rescue-notice.js` | THE TWO NOTICES the native-extractor rescue speaks — the offer a parse failure | `offerNativeRescue()`, `announceNativeRescue()` |
559
579
  | `sidecar/electron-state.js` | Electron install-state probes (#76). | `electronDirFor()`, `probeElectronState()` |
560
- | `sidecar/electron-trust.js` | Electron artifact TRUST core — the digest anchor, the gate, and the env scrub. | `electronTrustPolicy()`, `resolveAnchor()`, `expectedDigest()`, `verifyArtifact()`, `sha256File()` |
580
+ | `sidecar/electron-trust.js` | Electron artifact TRUST core — the digest anchor and the gate. (The third | `electronTrustPolicy()`, `resolveAnchor()`, `expectedDigest()`, `verifyArtifactBytes()`, `sha256Bytes()` |
561
581
  | `sidecar/fallback-chains.js` | | `resolveFallbackConfig()`, `deriveChain()`, `vendorOf()`, `DEFAULT_MAX_SUBSTITUTIONS()` |
562
582
  | `sidecar/fanout-budget.js` | | `preflightBudget()` |
563
583
  | `sidecar/fanout-leg-fallback.js` | | `runLegWithFallback()`, `recordAttemptSpend()`, `sumAttemptUsage()` |
@@ -594,10 +614,14 @@ evals/
594
614
  | `sidecar/start-metadata.js` | | `createSessionMetadata()` |
595
615
  | `sidecar/start.js` | Sidecar Start Operations - Handles starting new sidecar sessions | `generateTaskId()`, `createSessionMetadata()`, `buildMcpConfig()`, `checkElectronAvailable()`, `runInteractive()` |
596
616
  | `sidecar/tool-part.js` | | `TERMINAL_TOOL_STATUSES()`, `LIVE_TOOL_STATUSES()`, `isToolPart()`, `toolPartName()`, `toolPartInput()` |
597
- | `sidecar/unzip.js` | Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24). | `robustExtract()`, `nativeUnzipPlan()`, `IDLE_MS()`, `MAX_MS()` |
617
+ | `sidecar/unzip.js` | Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24). | `robustExtract()`, `nativeUnzipPlan()`, `IDLE_MS()`, `MAX_MS()`, `UNSAFE_PATTERNS()` |
598
618
  | `sidecar/wave-progress.js` | | `formatWaveProgress()`, `readLegState()`, `createWaveHeartbeat()`, `WAVE_HEARTBEAT_INTERVAL()` |
599
619
  | `sidecar/workspace-auto-open.js` | Workspace Auto-Open Decision Helper | `shouldAutoOpenWorkspace()` |
600
620
  | `sidecar/workspace-window.js` | Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern: | `launchWorkspaceWindow()`, `launchWorkspaceWindowDetached()` |
621
+ | `sidecar/zip-entry-write.js` | ONE ENTRY of an in-memory archive, and the classified failures every caller | `failure()`, `badArchive()`, `badDestination()`, `outOfBound()`, `extractorUnavailable()` |
622
+ | `sidecar/zip-from-buffer.js` | Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED. | `extractZipBuffer()` |
623
+ | `sidecar/zip-name-scan.js` | WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central | `scanEntryNames()`, `nameRefusal()`, `SCAN_MS()`, `MAX_ENTRIES()` |
624
+ | `sidecar/zip-stall-bound.js` | WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work. | `IDLE_MS()`, `MAX_MS()`, `UNWIND_MS()`, `stalled()`, `awaitUnwind()` |
601
625
  | `template/apply.js` | | `applyTemplate()`, `ARTIFACT_CAP_BYTES()` |
602
626
  | `template/render.js` | | `renderTemplate()`, `KNOWN_VARIABLES()` |
603
627
  | `template/store.js` | | `templatesDir()`, `resolveTemplate()`, `listTemplates()`, `BUILTIN_TEMPLATES()` |
@@ -343,17 +343,22 @@ These variables control the polling loop that drives headless sessions. The defa
343
343
  | `AMICUS_GUI_LOAD_TIMEOUT_MS` | Maximum wait in milliseconds for the Electron UI to load before the load-failsafe fires. If the OpenCode web UI fails to respond within this window, Amicus shows a load-error page instead of hanging invisibly. | `15000` |
344
344
  | `AMICUS_DEBUG_PORT` | Chrome DevTools Protocol port for the Electron window. Increment (e.g. `9223`) to avoid conflicts with a running Chrome or another Amicus window. | `9222` |
345
345
  | `AMICUS_MOCK_UPDATE` | Mock the update-notification state for UI development. Values: `available` \| `updating` \| `success` \| `error`. Has no effect outside development. | *(unset)* |
346
- | `AMICUS_ALLOW_UNVERIFIED_ELECTRON` | Accept an Electron artifact whose sha256 does **not** match the digest Electron publishes for it, with a loud warning on every use instead of a refusal. On a **cached** artifact it downgrades the refusal to a warning; on a **download** it also drops the published-digest pin, so `@electron/get` falls back to the `SHASUMS256.txt` of whatever mirror you pointed it at — without that, a legitimately rebuilt Electron could never be fetched at all, only accepted if it was already in a cache root. For one case only: you deliberately run a **rebuilt** Electron whose bytes legitimately differ. True for the exact string `1` — `true`, `yes` and ` 1` are all false, because a hatch that fails open on a typo is not a hatch. | *(unset)* |
347
-
348
- > **What `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not do.** It does not re-enable
349
- > anything else. `npm_config_electron_mirror`, `npm_package_config_electron_*`,
350
- > `npm_config_electron_use_remote_checksums`, `npm_config_platform` and
351
- > `npm_config_arch` stay stripped from the Electron installer's environment
352
- > whether it is set or not — those are the names a *repository* can plant in a
353
- > child process through its own `.npmrc` or `package.json`, which is why this
354
- > variable is a plain environment variable read under that exact bare spelling
355
- > only: a repo-planted `npm_config_amicus_allow_unverified_electron` reads back
356
- > as unset. It also does not change where the download comes from — a real
346
+ | `AMICUS_ALLOW_UNVERIFIED_ELECTRON` | Accept an Electron artifact whose sha256 does **not** match the digest Electron publishes for it, with a loud warning on every use instead of a refusal, **and** arm the native-extractor rescue for an archive amicus cannot read (below). On a **cached** artifact it downgrades the refusal to a warning; on a **download** it also drops the published-digest pin, so `@electron/get` falls back to the `SHASUMS256.txt` of whatever mirror you pointed it at — without that, a legitimately rebuilt Electron could never be fetched at all, only accepted if it was already in a cache root. For two cases only: you deliberately run a **rebuilt** Electron whose bytes legitimately differ, or amicus cannot read an archive at all and you have no other copy of it. True for the exact string `1` — `true`, `yes` and ` 1` are all false, because a hatch that fails open on a typo is not a hatch. | *(unset)* |
347
+
348
+ > **What `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not do.** It arms exactly the two
349
+ > things this page describes — the digest relaxation in the table above, and the
350
+ > native-extractor rescue in the blockquote below — and re-enables nothing beyond
351
+ > them. `npm_config_electron_mirror`, `npm_package_config_electron_*`
352
+ > and `npm_config_electron_use_remote_checksums` are stripped from the
353
+ > environment while amicus works out **where the artifact comes from**, whether
354
+ > this variable is set or not those are the names a *repository* can plant
355
+ > through its own `.npmrc` or `package.json`, which is why this variable is a
356
+ > plain environment variable read
357
+ > under that exact bare spelling only: a repo-planted
358
+ > `npm_config_amicus_allow_unverified_electron` reads back as unset. (`platform`
359
+ > and `arch` are no longer scrubbed anywhere, because there is no longer a child
360
+ > installer to hand an environment to: amicus's downloader takes them as
361
+ > arguments, so no environment name can choose which artifact is fetched.) It also does not change where the download comes from — a real
357
362
  > `ELECTRON_MIRROR` environment variable is honoured exactly as before, set or
358
363
  > unset. What it *does* relax, deliberately and on both routes, is the digest:
359
364
  > with it set, a cached artifact that contradicts Electron's published sha256 is
@@ -361,6 +366,81 @@ These variables control the polling loop that drives headless sessions. The defa
361
366
  > (`@electron/get` then trusts the `SHASUMS256.txt` served alongside the artifact).
362
367
  > Leave it unset and the published digest is enforced on both routes.
363
368
 
369
+ > **The second thing it arms: the native-extractor rescue, and the window that
370
+ > opens.** Amicus extracts the Electron archive **in memory**, from the buffer it
371
+ > hashed — nothing is written to a path and handed to anything else, which is what
372
+ > makes "amicus only ever writes bytes it hashed" true. The cost is that an
373
+ > archive its own extractor cannot read has nowhere else to go: normally that is a
374
+ > failed repair and a re-download, and on an air-gapped machine there is no
375
+ > re-download, so it is the end of the road. With this variable set, that one
376
+ > failure gets a rescue: amicus writes the bytes it hashed into a private
377
+ > directory inside the Electron package and hands **that path** to a native
378
+ > extractor (`tar` / `Expand-Archive` on Windows, `ditto` / `unzip` on macOS,
379
+ > `unzip` / `tar` on Linux). **Between amicus writing that file and the child
380
+ > process opening it, anything running as your user can replace it, and whatever
381
+ > the child extracts is promoted into `dist/` without being hashed again.** That
382
+ > is not a safe operation; it is the trade this variable buys, and amicus prints
383
+ > the whole of it on stderr before it spawns anything. A rescued install is always
384
+ > reported `unverified`, even when the artifact's own sha256 matched, because what
385
+ > reached `dist/` is no longer what amicus hashed.
386
+ >
387
+ > The rescue is deliberately narrow. It fires **only** when amicus's extractor
388
+ > positively identified the archive as unreadable — the same verdict that lets it
389
+ > discard a corrupt cached artifact, which is why that discard now waits: with
390
+ > this variable unset, amicus prints the offer of this rescue and **keeps** the
391
+ > cached archive, and only discards it once the rescue has run and every native
392
+ > extractor has failed on it too. A **path-traversal refusal** (`REFUSED
393
+ > (unsafe archive)`) is terminal and this variable does not apply to it, a
394
+ > **stall** is not a rescue trigger (the timeout exists to stop work, not to hand
395
+ > it to a child process), a **digest mismatch** is not one either (the bytes are
396
+ > known wrong, so there is nothing to rescue — even though this same variable let
397
+ > them reach the extractor), and neither is a destination failure such as a full
398
+ > disk. With the variable unset, an unreadable archive fails with a message that
399
+ > names this variable and says what it would do, so you do not have to read the
400
+ > source to find it.
401
+ >
402
+ > **What "path-traversal refusals are excluded" does and does not buy you.** That
403
+ > exclusion keys on the refusal amicus's extractor *formed*, and an archive can
404
+ > stop it forming one: yauzl checks an entry's size before its name, so one bad
405
+ > entry early in the archive ends the walk before any later name is looked at.
406
+ > Measured — the same three-entry archive, one flag bit apart — that moves it out
407
+ > of the terminal class and into the rescuable one. Amicus therefore also **reads
408
+ > the archive's central directory and refuses any entry name yauzl would have
409
+ > refused**, before it hands anything to a native extractor. Two residuals
410
+ > survive, and neither is engineered away:
411
+ >
412
+ > - An archive whose **central directory cannot be read at all** — a truncated
413
+ > zip, which is the commonest thing this rescue exists for — declares no names
414
+ > amicus can see, and it still goes to the native extractor. The only remaining
415
+ > check is that extractor's own. `tar` and `Expand-Archive` were measured to
416
+ > refuse a `..` entry themselves (`tar.exe`: `Path contains '..'`, exit 1;
417
+ > `Expand-Archive`: `Can not process invalid archive entry '…'`; nothing written
418
+ > outside the destination in either case). **`ditto` and Info-ZIP `unzip`, the
419
+ > macOS and Linux strategies, are unmeasured.**
420
+ > - A **symlink whose target escapes** the extraction root is a payload, not a
421
+ > name, so no name scan can see it. Amicus's own in-memory extractor refuses
422
+ > those; a native extractor is not asked to.
423
+ >
424
+ > Amicus cleans up only *inside* the directory it asked the extractor to write to,
425
+ > so anything a native tool wrote outside it would survive a failed strategy. This
426
+ > is the concrete shape of "not a safe operation": if the archive came from
427
+ > somewhere you do not trust, do not set this variable — get another copy.
428
+ >
429
+ > **The one thing the strip does not cover, stated precisely** (measured against
430
+ > `@electron/get` 5.0.0, and re-measured by
431
+ > `tests/electron-env-scrub-get5-contract.test.js` on every test run). Every read
432
+ > that decides the artifact's URL happens while those names are stripped — with a
433
+ > mirror planted, the download still goes to the official
434
+ > `github.com/electron/electron/releases/download/…`. But when **no digest is
435
+ > pinned** — either this variable is set, or your Electron package ships no
436
+ > `checksums.json` entry — `@electron/get` fetches a `SHASUMS256.txt` of its own,
437
+ > *after* the names have been restored, and a planted mirror is read again for
438
+ > that one fetch. It cannot change which bytes you get, because the artifact's
439
+ > URL was already settled: it can only serve a checksum file that disagrees with
440
+ > the official artifact, which makes the download **fail**. Unset the planted
441
+ > names (or the variable) if an unpinned download fails checksum validation on a
442
+ > machine whose `.npmrc` names an Electron mirror.
443
+
364
444
  ---
365
445
 
366
446
  ## Process Lifecycle
@@ -421,18 +421,111 @@ 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 heals itself.
456
+ - **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.
457
+ - **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)).
458
+ - **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. That name check cannot see inside an archive whose central directory is unreadable, and cannot see a symlink target at all; [configuration.md](./configuration.md#gui-and-debug) states both residuals and names which extractors were measured to refuse a `..` entry themselves.
459
+ - Headless runs and the full council work without the GUI throughout.
460
+
461
+ ---
462
+
463
+ ## Electron artifact NOT extracted (could not be read)
464
+
465
+ **Symptom:** provisioning stops with
466
+
467
+ ```
468
+ [amicus] Electron artifact NOT extracted: electron-v43.1.1-win32-x64.zip
469
+ [amicus] C:\Users\me\AppData\Local\electron\Cache\<sha>\electron-v43.1.1-win32-x64.zip
470
+ [amicus] could not be opened or read at all (EACCES: permission denied, open '...')
471
+ [amicus] amicus reads an artifact ONCE, into memory, and hashes and extracts THOSE
472
+ [amicus] bytes. It could not read these, so it has nothing it could vouch for and
473
+ [amicus] has extracted nothing. The file was left exactly where it is.
474
+ ```
475
+
476
+ **Cause:** amicus reads an Electron artifact exactly once, through a single file descriptor, into a
477
+ buffer of its own. The sha256 and the extraction both act on that buffer. Anything that can write the
478
+ download cache — which, on your own machine, includes anything running as you — can change the file
479
+ afterwards, and it changes nothing: the bytes amicus hashed are already the bytes it is going to
480
+ write. When the read itself cannot be completed there is nothing to vouch for, so amicus refuses
481
+ rather than extracting something it never saw whole.
482
+
483
+ The third line names which way the read failed:
484
+
485
+ | line | what happened |
486
+ |---|---|
487
+ | `could not be opened or read at all` | permissions, a broken path, a disconnected drive — the fs error is quoted |
488
+ | `is not a regular file` | the cache entry is a directory, a fifo, or a device node |
489
+ | `is empty` | a zero-byte file, usually an interrupted download |
490
+ | `is far larger than any electron artifact` | above the 1 GiB ceiling; a real artifact is ~140–160 MB |
491
+ | `ended early while amicus was reading it` | the file is shorter than it said it was |
492
+ | `changed size while amicus was reading it` | it grew under the read — what an active swap looks like |
493
+
494
+ **Fix:**
495
+ - Delete the cache entry the message names and provision again; amicus re-downloads it.
496
+ - Check the permissions on the cache root (`ELECTRON_CACHE`, or `%LOCALAPPDATA%\electron\Cache` /
497
+ `~/Library/Caches/electron` / `~/.cache/electron`).
498
+ - Headless runs and the full council work without the GUI meanwhile.
499
+
500
+ **Notes:**
501
+ - **Your cached artifact is never moved, copied, or deleted by this refusal.** The download cache is
502
+ exactly as it was, whether the repair succeeded, was refused, or was interrupted — which matters
503
+ most on an air-gapped machine whose cache was hand-seeded. Nothing is written to the temp directory
504
+ either: earlier versions staged a ~170 MB copy there, and no longer do.
505
+ - **A killed run can leave one directory behind, and the next provision sweeps it.** Extraction
506
+ happens in `<electron package>/.amicus-incoming-<hex>/`, which is promoted into `dist/` by a single
507
+ rename at the end and removed afterwards — but that removal is an in-process `finally`, and a kill
508
+ does not run it. Ctrl-C during `npm install`, a lid close or an AV kill can therefore leave one
509
+ behind, as can the sibling `.amicus-retired-<hex>` when an Electron is running off the tree being
510
+ replaced (Windows refuses to delete it). Both live inside the Electron package directory, where no
511
+ OS temp cleaner reaches them, so **amicus sweeps them itself: every provision removes any
512
+ `.amicus-incoming-*` or `.amicus-retired-*` in that directory that is more than a day old.** The
513
+ age rule is deliberate — it cannot take a tree another run may still be writing. What is still
514
+ guaranteed either way: a half-written tree is never what `dist/` contains, and a kill mid-extract
515
+ leaves the previous `dist/` exactly where it was.
516
+ - **A promote never removes a working `dist/` to make room.** If the old tree cannot be renamed out of
517
+ the way (a handle held on it, or an AV filter denying the move) and it holds a usable executable,
518
+ the repair refuses and leaves it untouched rather than deleting it with no way back. If it holds no
519
+ executable it is not an install, and it is replaced. In the one case where the tree was renamed away
520
+ and neither the swap nor the rollback could run, the previous `dist/` is intact at
521
+ `.amicus-retired-<hex>` and the error names it — rename it back to `dist/` to restore it.
522
+ - **A related refusal**, `Refusing to provision electron: … is not a usable artifact name`, means the
523
+ `version` in the Electron package's own `package.json` is not a plausible version string. Amicus
524
+ builds the artifact filename from it and refuses to use anything that is not a plain filename, since
525
+ it would otherwise be joined into a path. Reinstall the `electron` package.
526
+ - **`Cached electron artifact … was NOT extracted (…); it was LEFT IN PLACE`** is the other half of
527
+ the same rule: the archive was fine and the *destination* was not (no space, an unwritable `dist/`,
528
+ a path too long). A cached artifact is only ever evicted when the archive itself is bad.
436
529
 
437
530
  ---
438
531
 
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.6",
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.6",
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 };