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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +31 -7
- package/docs/configuration.md +91 -11
- package/docs/troubleshooting.md +96 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-install.js +115 -125
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
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
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.
|
|
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
|
|
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.
|
|
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
|
package/docs/architecture-map.md
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
│
|
|
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-
|
|
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
|
|
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()` |
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
347
|
-
|
|
348
|
-
> **What `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not do.** It
|
|
349
|
-
>
|
|
350
|
-
>
|
|
351
|
-
> `
|
|
352
|
-
>
|
|
353
|
-
>
|
|
354
|
-
> variable is
|
|
355
|
-
>
|
|
356
|
-
>
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
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": {
|
package/scripts/postinstall.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
-
|
|
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 };
|