amicus 4.9.6 → 4.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +152 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +5 -0
- package/docs/configuration.md +43 -15
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +14 -7
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +7 -12
- package/src/sidecar/electron-layout.js +31 -31
- package/src/sidecar/electron-native-plan.js +23 -5
- package/src/sidecar/electron-native-rescue.js +55 -16
- package/src/sidecar/electron-rescue-notice.js +18 -1
- package/src/sidecar/zip-from-buffer.js +16 -5
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +5 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.7",
|
|
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,158 @@
|
|
|
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.7] - 2026-09-09
|
|
7
|
+
|
|
8
|
+
Three findings deferred from the v4.9.6 cut, and the two open questions filed beside them. The
|
|
9
|
+
council run that raised the three reviewed at **2 of 4 seats**, so each carried the weight of two
|
|
10
|
+
seats — and on the one that mattered most, the filing turned out to be right about the defect and
|
|
11
|
+
wrong about the fix.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **A promote can no longer delete a working install that was cross-installed for another
|
|
16
|
+
platform.** v4.9.6 captured and restored a `path.txt` naming another platform's executable, but
|
|
17
|
+
the guard that decides whether a `dist/` may be removed in place still asked only about THIS
|
|
18
|
+
platform's default name. On a package installed through `npm_config_platform`, a promote whose
|
|
19
|
+
retirement rename failed therefore restored the pointer and deleted the tree it pointed at —
|
|
20
|
+
reproduced against the shipped code with a real filesystem and only `renameSync` injected to
|
|
21
|
+
throw the EPERM the fallback exists for.
|
|
22
|
+
|
|
23
|
+
The guard now judges by what `path.txt` actually names. **As a union with the platform default,
|
|
24
|
+
never as a replacement** — which is the part the finding did not say, and implementing its
|
|
25
|
+
sentence literally destroys a working `dist/electron.exe` on three shapes the old guard protected:
|
|
26
|
+
a whitespace-only `path.txt`, one with a trailing newline (`existsSync` of a name ending in `\n`
|
|
27
|
+
is false on Windows), and a TRUNCATED one — `electr`, the shape this function's own best-effort
|
|
28
|
+
put-back can leave. The platform-default arm runs first and is unchanged, so the set of trees a
|
|
29
|
+
promote will delete can only ever shrink.
|
|
30
|
+
|
|
31
|
+
The `path.txt` name additionally has to be **contained** in `dist/` and has to be a **file**: a
|
|
32
|
+
`path.txt` of `..`, `.` or `../SIBLING` names something that exists but that the delete never
|
|
33
|
+
touches, and every truncation of the darwin name (`Electron.app`, `Electron.app/Contents`,
|
|
34
|
+
`.../MacOS`) is a real directory — accepting either would refuse every promote forever while
|
|
35
|
+
reporting that `dist/` "holds a usable Electron.app". The refusal message now names the executable
|
|
36
|
+
that was found rather than the one that was looked for, because `docs/troubleshooting.md` tells
|
|
37
|
+
the user that refusal means the tree holds a usable executable.
|
|
38
|
+
|
|
39
|
+
An **unreadable** `path.txt` now refuses rather than guessing; an absent one still heals. The rule
|
|
40
|
+
itself moved to `src/sidecar/electron-exe-rel.js` and `resolveElectronBinary` now calls it, with a
|
|
41
|
+
table-driven test asserting both sides agree over seven `path.txt` shapes on three platforms with
|
|
42
|
+
and without `ELECTRON_OVERRIDE_DIST_PATH`. Two copies of one rule are what let these drift, and
|
|
43
|
+
the finding's own words for the defect were "did not carry one function over".
|
|
44
|
+
|
|
45
|
+
- **(docs)** `.github/workflows/ci.yml` claimed `--omit=optional` "skips the Electron download". It
|
|
46
|
+
skips the optional *package*; there is no download on the default path to skip, because
|
|
47
|
+
`electron@43.1.1` ships no install script at all. Measured on run `34246117877` across all three
|
|
48
|
+
runner platforms.
|
|
49
|
+
|
|
50
|
+
### Security
|
|
51
|
+
|
|
52
|
+
- **The rescue boundary now reads BOTH tables an archive declares its entry names in.** A zip
|
|
53
|
+
carries its names twice — in the central directory and in each local file header — and amicus
|
|
54
|
+
read only the first. An archive can blind that one while leaving every local header whole: cutting
|
|
55
|
+
the tail off does it by accident, and four one-field edits to a *complete* end-of-central-directory
|
|
56
|
+
record do it on purpose. Measured against the real extractor with the hatch armed, **seven such
|
|
57
|
+
archives carrying a `../../../` entry reached a native extractor, and two ran to completion and
|
|
58
|
+
promoted the result.** Nothing escaped only because the Windows tools refuse `..` themselves — the
|
|
59
|
+
exact reliance this subsystem says amicus will not make.
|
|
60
|
+
|
|
61
|
+
The two tables can also disagree, and the tools do not agree on which to believe: measured,
|
|
62
|
+
`tar.exe` wrote the name from the local header while `Expand-Archive` wrote the one from the
|
|
63
|
+
central directory. A refusal in either table now refuses the archive.
|
|
64
|
+
|
|
65
|
+
**It costs no measured availability.** On six real Electron artifacts the local walk enumerates
|
|
66
|
+
every name in 0-1 ms and agrees with the central table entry-for-entry; on a truncated archive,
|
|
67
|
+
where the central walk goes blind, it still reads them all. The archives this rescue exists for
|
|
68
|
+
are still rescued. And the notice printed before a spawn now says *which* names were checked —
|
|
69
|
+
all of them, some of them, or none — because a real artifact truncated by a few kilobytes leaves
|
|
70
|
+
both walks incomplete while every name it found was read and cleared, and a two-state notice would
|
|
71
|
+
have claimed nothing was checked over dozens that were.
|
|
72
|
+
|
|
73
|
+
**Two council rounds then found the first cut of this incomplete, and the second cut of it
|
|
74
|
+
incomplete again — both in the same direction.** A walk that could not follow the chain used to
|
|
75
|
+
STOP, and an entry it never reached is an entry it cannot refuse: a lying size jumped one, and
|
|
76
|
+
later a *standard* data-descriptor entry (general-purpose bit 3 with a zero size — an ordinary
|
|
77
|
+
streaming encoding, not a malformed one) hid everything behind it. Neither needed an exotic
|
|
78
|
+
archive. So the walk now DEMOTES its claim and keeps going wherever it still has an offset to
|
|
79
|
+
follow — because a name it can still read is a name it can still refuse — and where it genuinely
|
|
80
|
+
has none, it SWEEPS the region it could not reach for local headers and puts their names through
|
|
81
|
+
the same rule. It can only add refusals, so an archive that is rescued today stops being rescued
|
|
82
|
+
only if it declares a hostile name. **The practical consequence for a user: an archive carrying a
|
|
83
|
+
traversal name behind an unreadable point is now refused where it previously reached the native
|
|
84
|
+
extractor.**
|
|
85
|
+
|
|
86
|
+
The claim "every name was checked" also became a conjunction rather than a disjunction. The two
|
|
87
|
+
tables carry different names and the two extractors read different ones, so one readable table
|
|
88
|
+
never meant both were clean — measured, a stopped local walk beside a benign central directory
|
|
89
|
+
reported all-clear and printed nothing while `tar.exe` reached a `../../../` entry only the local
|
|
90
|
+
table carried.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- **A macOS job that runs the real extract path over the real Electron `.app` artifact**
|
|
95
|
+
(`.github/workflows/darwin-bundle.yml`, `scripts/probe-darwin-extract.js`). v4.9.6 shipped a
|
|
96
|
+
symlink target-escape check that `extract-zip` does not have, and disclosed that it might *reject*
|
|
97
|
+
a layout that previously worked. **It does not** — measured against the real
|
|
98
|
+
`electron-v43.1.1-darwin-arm64.zip`: 585 records, 14 symlinks, every target relative, none with a
|
|
99
|
+
`..` component, none absolute, and none of the 585 entry names traversing a symlinked component.
|
|
100
|
+
The linux artifacts hold zero symlink entries, so that path is unreachable there at all.
|
|
101
|
+
|
|
102
|
+
What was left was coverage rather than risk, and the job closes it: a real `symlinkSync`, a real
|
|
103
|
+
`realpathSync` behind the chain control, the absolute-target branch on POSIX arithmetic, and
|
|
104
|
+
`Electron --version` loading the framework through two of the fourteen links. Ten assertions, each
|
|
105
|
+
naming the one-line mutation it catches. The parity diff against `@electron-internal/extract-zip`'s
|
|
106
|
+
own tree ships **report-only** on its first cut — that comparison has never been measured, and a
|
|
107
|
+
gate that has never been run once is not evidence.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- **The claim that the native extractors refuse traversal themselves is now re-measured on every CI
|
|
112
|
+
run, instead of being asserted once.** That claim is the compensating control the rescue's
|
|
113
|
+
boundary leans on, and a claim nothing re-measures is a claim that rots.
|
|
114
|
+
`tests/sidecar/native-extractor-containment.test.js` drives 12 escape shapes through every
|
|
115
|
+
strategy that resolves on the machine. Newly measured this way: Info-ZIP `unzip` contains its own
|
|
116
|
+
escapes by stripping them, and GNU `tar` cannot read a zip at all — both were previously written
|
|
117
|
+
down as unmeasured. **`ditto`, the first macOS strategy, is now the only unmeasured one**, and the
|
|
118
|
+
macOS runner already in the matrix measures it the first time this suite runs there.
|
|
119
|
+
|
|
120
|
+
A proposed runtime "fence" around the Electron package directory was **refused rather than
|
|
121
|
+
deferred**, and the reasons are recorded because they were measured: a names-diff fence is blind
|
|
122
|
+
to an overwrite of `dist/electron.exe` or `path.txt`; and signalling a breach through the
|
|
123
|
+
available channel deletes the user's cached artifact and reports it corrupt — which a *false*
|
|
124
|
+
breach, reachable from a concurrent provision, would then do to the air-gapped machine this whole
|
|
125
|
+
feature exists for.
|
|
126
|
+
|
|
127
|
+
Half of the finding that prompted it was already false: everything the rescue writes lives under a
|
|
128
|
+
private incoming directory that is deleted unconditionally, so only an absolute write outside that
|
|
129
|
+
tree survives a failed strategy. Two shipped documents said otherwise and are corrected. The child
|
|
130
|
+
now also runs with that directory as its working directory — measured neutral, and it costs a line.
|
|
131
|
+
|
|
132
|
+
- **The CI council bench reserves 64,000 output tokens per leg instead of the engine's 32,000
|
|
133
|
+
default** — for every PR whose base carries the change. The alias map is fetched from the BASE
|
|
134
|
+
REF, so a branch cannot change the reviewers of its own PR; the config therefore lands on `main`
|
|
135
|
+
separately and takes effect for runs opened after it, never for the branch carrying it. Measured:
|
|
136
|
+
the run on the PR that first carried this reported `outputBudget is unset` and lost a seat at the
|
|
137
|
+
32,000 default exactly as before. Across the five paid council runs on the v4.9.6 branch the bench averaged **2.8 of 4
|
|
138
|
+
seats**, and four of the six lost seats died at the reservation with `finish: 'length'` and
|
|
139
|
+
0–651 usable output tokens.
|
|
140
|
+
|
|
141
|
+
**64,000 is measured, not chosen.** Against the pinned engine with the bare descriptors CI
|
|
142
|
+
registers, it lands unclamped on all four seats and the chair in both catalogue states. The
|
|
143
|
+
binding ceiling is **65,536**, and on a cold read it is shared by **two** rows — the chair and
|
|
144
|
+
`deepseek-v4-flash-0731` — not by the chair alone: the flag at 100,000 arrives as
|
|
145
|
+
`max_tokens 65536` on both. A budget above it would land in full on three seats while two were
|
|
146
|
+
silently clamped, and a run would stop reserving one number. The pre-flight step now fails before
|
|
147
|
+
any spend on a budget above the ceiling or on one amicus would reject and silently replace with
|
|
148
|
+
32,000; the alias map's own validator inspects aliases only, so nothing caught that before.
|
|
149
|
+
|
|
150
|
+
**What it does not fix, said up front:** the other two lost seats — and ten of the eleven failed
|
|
151
|
+
first attempts — produced nothing at all and never reached a reservation. Expect roughly 3.6 of 4,
|
|
152
|
+
not 4 of 4. Read the death class rather than the seat count: three of the four targeted legs ran
|
|
153
|
+
529–596 s of a 960 s leg cap, so an `OUTPUT_LENGTH` death can convert into a generic timeout,
|
|
154
|
+
which is the same lost seat with a worse diagnosis. The leg cap deliberately did not move in the
|
|
155
|
+
same change — raising it busts the 75-minute job cap, and a bust cancels the job, which deletes
|
|
156
|
+
the run-directory artifact every diagnosis of this class rests on.
|
|
157
|
+
|
|
6
158
|
## [4.9.6] - 2026-09-08
|
|
7
159
|
|
|
8
160
|
*Amicus never itself writes, or reports as verified, bytes it did not hash.*
|
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.7** (2026-09-09). 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, v4.9.6's artifact custody) it did so to
|
|
19
|
+
`output-budget` doctor row, v4.9.5's Electron digest gate, v4.9.6's artifact custody, v4.9.7's dual name-table rescue boundary) it did so to
|
|
20
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.7 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
|
@@ -112,6 +112,7 @@ src/
|
|
|
112
112
|
│ ├── electron-custody.js # CUSTODY of the Electron artifact: one open, one read, one Buffer.
|
|
113
113
|
│ ├── electron-ensure.js # ensureElectron() — lazy first-GUI provisioning (#55).
|
|
114
114
|
│ ├── electron-env-scrub.js # The ENV SCRUB — which environment names a hostile REPOSITORY can plant.
|
|
115
|
+
│ ├── electron-exe-rel.js # WHICH exe a package resolves through, and whether a `dist/` HOLDS one.
|
|
115
116
|
│ ├── electron-install.js # Electron self-heal primitive (#53, #59).
|
|
116
117
|
│ ├── electron-layout.js # The on-disk LAYOUT of an installed `electron` package: where the executable
|
|
117
118
|
│ ├── electron-lock.js # Stale-aware single-flight lock for the electron self-heal (#53).
|
|
@@ -166,6 +167,7 @@ src/
|
|
|
166
167
|
│ ├── workspace-window.js # Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
|
|
167
168
|
│ ├── zip-entry-write.js # ONE ENTRY of an in-memory archive, and the classified failures every caller
|
|
168
169
|
│ ├── zip-from-buffer.js # Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED.
|
|
170
|
+
│ ├── zip-local-name-scan.js # THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers.
|
|
169
171
|
│ ├── zip-name-scan.js # WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central
|
|
170
172
|
│ └── zip-stall-bound.js # WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work.
|
|
171
173
|
├── template/
|
|
@@ -417,6 +419,7 @@ scripts/
|
|
|
417
419
|
├── integration-test.sh
|
|
418
420
|
├── mark-test-passed.js # Writes the current git HEAD SHA to .test-passed for the pre-push SHA cache
|
|
419
421
|
├── postinstall.js # Post-install script for amicus
|
|
422
|
+
├── probe-darwin-extract.js
|
|
420
423
|
├── probe-max-tokens.js # Wire probe for issue #218: what max_tokens / reasoning / thinking does the
|
|
421
424
|
├── run-integration-keyless.js
|
|
422
425
|
├── setup-hooks.js # Configure git to run the version-controlled hooks in .husky/.
|
|
@@ -566,6 +569,7 @@ evals/
|
|
|
566
569
|
| `sidecar/electron-custody.js` | CUSTODY of the Electron artifact: one open, one read, one Buffer. | `readArtifactBytes()`, `isSafeArtifactName()`, `MAX_ARTIFACT_BYTES()`, `READ_CHUNK()` |
|
|
567
570
|
| `sidecar/electron-ensure.js` | ensureElectron() — lazy first-GUI provisioning (#55). | `ensureElectron()`, `_resetEnsureElectron()` |
|
|
568
571
|
| `sidecar/electron-env-scrub.js` | The ENV SCRUB — which environment names a hostile REPOSITORY can plant. | `isRepoPlantedName()`, `withScrubbedRepoEnv()`, `REPO_ENV_PREFIXES()` |
|
|
572
|
+
| `sidecar/electron-exe-rel.js` | WHICH exe a package resolves through, and whether a `dist/` HOLDS one. | `platformExe()`, `writePathTxt()`, `heldExeRel()`, `distHeldExe()` |
|
|
569
573
|
| `sidecar/electron-install.js` | Electron self-heal primitive (#53, #59). | `resolveElectronBinary()`, `isElectronUsable()`, `cachedZip()`, `repairElectron()`, `platformExe()` |
|
|
570
574
|
| `sidecar/electron-layout.js` | The on-disk LAYOUT of an installed `electron` package: where the executable | `platformExe()`, `writePathTxt()`, `promoteDist()`, `extractBytesToDist()`, `sweepPromoteLitter()` |
|
|
571
575
|
| `sidecar/electron-lock.js` | Stale-aware single-flight lock for the electron self-heal (#53). | `acquireRepairLock()`, `isStaleLock()`, `lockPathFor()`, `STALE_MS()` |
|
|
@@ -620,6 +624,7 @@ evals/
|
|
|
620
624
|
| `sidecar/workspace-window.js` | Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern: | `launchWorkspaceWindow()`, `launchWorkspaceWindowDetached()` |
|
|
621
625
|
| `sidecar/zip-entry-write.js` | ONE ENTRY of an in-memory archive, and the classified failures every caller | `failure()`, `badArchive()`, `badDestination()`, `outOfBound()`, `extractorUnavailable()` |
|
|
622
626
|
| `sidecar/zip-from-buffer.js` | Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED. | `extractZipBuffer()` |
|
|
627
|
+
| `sidecar/zip-local-name-scan.js` | THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers. | `scanLocalNames()`, `declaredCentralOffset()` |
|
|
623
628
|
| `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
629
|
| `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()` |
|
|
625
630
|
| `template/apply.js` | | `applyTemplate()`, `ARTIFACT_CAP_BYTES()` |
|
package/docs/configuration.md
CHANGED
|
@@ -405,26 +405,54 @@ These variables control the polling loop that drives headless sessions. The defa
|
|
|
405
405
|
> entry early in the archive ends the walk before any later name is looked at.
|
|
406
406
|
> Measured — the same three-entry archive, one flag bit apart — that moves it out
|
|
407
407
|
> of the terminal class and into the rescuable one. Amicus therefore also **reads
|
|
408
|
-
> the archive
|
|
409
|
-
> refused**, before it hands anything to a native extractor
|
|
410
|
-
>
|
|
408
|
+
> the entry names the archive declares and refuses any that yauzl would have
|
|
409
|
+
> refused**, before it hands anything to a native extractor — and it reads them in
|
|
410
|
+
> **both of the tables a zip declares them in**, the central directory and the
|
|
411
|
+
> local file headers. That second walk is not belt-and-braces. An archive can
|
|
412
|
+
> blind the central directory while leaving every local header whole — a
|
|
413
|
+
> truncation does it by accident, and four one-field edits to a complete
|
|
414
|
+
> end-of-central-directory record do it on purpose — and measured, seven such
|
|
415
|
+
> archives carrying a `../../../` entry reached a native extractor before this
|
|
416
|
+
> was added, two of them running to completion. The tables can also **disagree**,
|
|
417
|
+
> and the tools do not agree on which one to believe: measured, `tar.exe` wrote
|
|
418
|
+
> the name from the LOCAL header and `Expand-Archive` the one from the CENTRAL
|
|
419
|
+
> directory. A refusal in either table refuses the archive.
|
|
411
420
|
>
|
|
412
|
-
>
|
|
413
|
-
>
|
|
414
|
-
>
|
|
415
|
-
>
|
|
416
|
-
>
|
|
417
|
-
>
|
|
418
|
-
>
|
|
419
|
-
> macOS and Linux strategies, are unmeasured.**
|
|
421
|
+
> Residuals survive, and none is engineered away:
|
|
422
|
+
>
|
|
423
|
+
> - An archive that defeats **both** walks still reaches the native extractor.
|
|
424
|
+
> That is rarer than it was — a truncated zip, the commonest thing this rescue
|
|
425
|
+
> exists for, still yields all of its local names — but it is not impossible,
|
|
426
|
+
> and the notice printed before the spawn now tells you **which** names were
|
|
427
|
+
> checked rather than letting you assume they all were.
|
|
420
428
|
> - A **symlink whose target escapes** the extraction root is a payload, not a
|
|
421
429
|
> name, so no name scan can see it. Amicus's own in-memory extractor refuses
|
|
422
430
|
> those; a native extractor is not asked to.
|
|
431
|
+
> - The name walk trusts a local header's declared size to find the next one, and
|
|
432
|
+
> a wrong size is a property of exactly the corrupt archives this rescue is for.
|
|
433
|
+
> A desynchronised walk can read a "name" out of payload bytes. It fails toward
|
|
434
|
+
> **refusing**, so it can cost a rescue and never grant one.
|
|
435
|
+
>
|
|
436
|
+
> When a name check cannot see it, the remaining check is the extractor's own, and
|
|
437
|
+
> those are measured on every CI run rather than asserted here
|
|
438
|
+
> (`tests/sidecar/native-extractor-containment.test.js`, 12 escape shapes per
|
|
439
|
+
> strategy). At the time of writing: `tar.exe` refuses `..` (`Path contains '..'`,
|
|
440
|
+
> exit 1) and strips absolute and drive-letter names into the destination;
|
|
441
|
+
> `Expand-Archive` refuses `..` (`Can not process invalid archive entry '…'`) and
|
|
442
|
+
> errors out on drive-letter and `\\?\` shapes with an empty destination; Info-ZIP
|
|
443
|
+
> `unzip` contains by stripping the escaping components and writing the entry
|
|
444
|
+
> inside the destination under a mangled name (its own `-hh` text documents this
|
|
445
|
+
> as the default, with `-:` as the opt-out, which amicus does not pass); GNU `tar`
|
|
446
|
+
> cannot read a zip at all. **`ditto`, the first macOS strategy, is the one that
|
|
447
|
+
> remains unmeasured.**
|
|
423
448
|
>
|
|
424
|
-
>
|
|
425
|
-
>
|
|
426
|
-
>
|
|
427
|
-
>
|
|
449
|
+
> Everything amicus writes for a rescue goes under a private incoming directory
|
|
450
|
+
> that is deleted unconditionally afterwards, and the child is run with that
|
|
451
|
+
> directory as its working directory, so a relative write lands where the cleanup
|
|
452
|
+
> reaches. What amicus cannot clean up is a write a native tool makes to an
|
|
453
|
+
> absolute path outside that tree. This is the concrete shape of "not a safe
|
|
454
|
+
> operation": if the archive came from somewhere you do not trust, do not set this
|
|
455
|
+
> variable — get another copy.
|
|
428
456
|
>
|
|
429
457
|
> **The one thing the strip does not cover, stated precisely** (measured against
|
|
430
458
|
> `@electron/get` 5.0.0, and re-measured by
|
package/docs/electron-testing.md
CHANGED
|
@@ -469,3 +469,136 @@ setTimeout(() => { ws.close(); process.exit(0); }, 3000);
|
|
|
469
469
|
```
|
|
470
470
|
|
|
471
471
|
**Note:** `window.sidecar` is `undefined` in the toolbar (see Known Limitations above). The toolbar communicates with the main process via `window.__amicusUpdateAction` polling, not IPC.
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## The darwin `.app` bundle — what CI proves, and what it still does not
|
|
476
|
+
|
|
477
|
+
`.github/workflows/darwin-bundle.yml` drives `scripts/probe-darwin-extract.js` on
|
|
478
|
+
`macos-latest`. It is the only place amicus's real extract path meets a real `.app`
|
|
479
|
+
bundle, a real POSIX `fs.symlinkSync`, a real `fs.realpathSync` and a real dyld.
|
|
480
|
+
|
|
481
|
+
### The filing it closes, and the way it closes it
|
|
482
|
+
|
|
483
|
+
v4.9.6 added a symlink target-escape check (`src/sidecar/zip-entry-write.js ::
|
|
484
|
+
writeSymlink`) that `extract-zip` does not have. The v4.9.7 filing was that it could
|
|
485
|
+
**reject** a real darwin layout. That is **refuted by measurement on the real bytes**,
|
|
486
|
+
not by this job:
|
|
487
|
+
|
|
488
|
+
| artifact | bytes | records | symlinks | dir entries |
|
|
489
|
+
| --- | --- | --- | --- | --- |
|
|
490
|
+
| `electron-v43.1.1-darwin-arm64.zip` | 122,054,683 | 585 | 14 | 310 |
|
|
491
|
+
| `electron-v43.1.1-darwin-x64.zip` | 123,952,132 | 585 | 14 | 310 |
|
|
492
|
+
| `electron-v43.1.1-linux-x64.zip` | 124,861,804 | 74 | **0** | 0 |
|
|
493
|
+
| `electron-v43.1.1-linux-arm64.zip` | 124,456,257 | 74 | **0** | 0 |
|
|
494
|
+
|
|
495
|
+
All 14 darwin targets are relative (`A`, `Versions/Current/Resources`, …); none carries a
|
|
496
|
+
`..` component; none is absolute; **0 of the 585 entry names traverse a symlinked
|
|
497
|
+
component**. The check is purely lexical (`path.resolve` then `path.relative`), so on that
|
|
498
|
+
shape it cannot fire. The extraction root is itself realpath'd
|
|
499
|
+
(`src/sidecar/zip-from-buffer.js :: extractZipBuffer`), which is why macOS's
|
|
500
|
+
`/var` -> `/private/var` does not turn every link into an escape — the one mechanism that
|
|
501
|
+
could have made the filing correct.
|
|
502
|
+
|
|
503
|
+
**Linux is settled outright and gets no job**: both linux artifacts hold 74 entries and
|
|
504
|
+
zero symlinks, so `writeSymlink` is unreachable there. The filing's "darwin/linux" narrows
|
|
505
|
+
to "darwin".
|
|
506
|
+
|
|
507
|
+
### What each assertion pins, and the mutant it kills
|
|
508
|
+
|
|
509
|
+
| id | assertion | mutant it kills |
|
|
510
|
+
| --- | --- | --- |
|
|
511
|
+
| A1a | `resolveAnchor({selfElectronDir: null})` reads the `checksums.json` under test | dropping the `selfElectronDir` override, which silently re-anchors on the repo's own `node_modules` |
|
|
512
|
+
| A1b | `repairElectron({cacheOnly:true})` repairs **and** the gate says `verified` | any change that stops a byte-exact artifact verifying — extract and exec both stay green |
|
|
513
|
+
| A1c | with **no** anchor the same bytes extract but are marked `unverified` | dropping the `verdict !== 'verified'` mark, i.e. a silent degrade of the trust route |
|
|
514
|
+
| A2 | `resolveElectronBinary` names the real launcher and its exec bit survived | a wrong `platformExe` darwin arm, or a `path.txt` naming a different basename |
|
|
515
|
+
| A3a | all 14 archive symlinks are symlinks on disk with byte-identical targets | deleting the `if (symlink)` branch in `placeEntry` — links become regular files |
|
|
516
|
+
| A3b | the five structural framework links exist with their exact targets | writing the **resolved absolute** path instead of the archive's relative target (still runs on the runner; breaks when the tree moves) |
|
|
517
|
+
| A4 | parity vs the tree `@electron-internal/extract-zip` produced | a flat file mode, or a `continue` that silently drops entries — **report-only on its first cut** |
|
|
518
|
+
| A5 | `Electron --version` runs | the same mutant as A3a, observed through dyld: the launcher's Mach-O carries `LC_RPATH @executable_path/../Frameworks` and `LC_LOAD_DYLIB @rpath/Electron Framework.framework/Electron Framework`, so it loads the ~192 MB framework **through two of the fourteen links** |
|
|
519
|
+
| A6i | an escaping relative target is refused, nothing planted | dropping the `..`-prefix limbs of the three-limb test |
|
|
520
|
+
| A6ii | an **absolute POSIX** target is refused | dropping the `startsWith('..' + sep)` limb — on win32 `/etc/passwd` becomes `C:\etc\passwd` and a *different* limb catches it, so the Windows suite proves the wrong arithmetic |
|
|
521
|
+
| A6iii | SYMLINKCHAIN refused by a **real** `realpath`: 3 links made, no victim | reverting the target resolution to `path.dirname(dest)` — the lexical-dirname bug that extracts with no error at all |
|
|
522
|
+
|
|
523
|
+
A4 is deliberately **report-only, exit 0, full diff printed** on its first cut: nothing has
|
|
524
|
+
ever measured that the two extractors agree on directory modes under the runner's umask, so
|
|
525
|
+
making an unmeasured comparison a blocking gate buys a red for reasons unrelated to
|
|
526
|
+
symlinks. Promote it once one clean run exists. Everything else gates from day one.
|
|
527
|
+
|
|
528
|
+
A6iii is the assertion that matters most: in the jest suite the same archive is pinned by a
|
|
529
|
+
`realpathSync` **the test itself injects** (`tests/electron-custody.test.js`, describe
|
|
530
|
+
`symlinks — the darwin .app shape, which cannot be run here`). That is a rule read off the
|
|
531
|
+
surface its own writer wrote. On the runner the three `.` links exist on disk and the kernel
|
|
532
|
+
answers.
|
|
533
|
+
|
|
534
|
+
### `npm ci` does NOT provision Electron — the job asks for it explicitly
|
|
535
|
+
|
|
536
|
+
`electron@43.1.1` ships **no install script at all**: its `package.json` has no `scripts`
|
|
537
|
+
field (it exposes `install.js` only as the `install-electron` bin), and `package-lock.json`
|
|
538
|
+
carries no `hasInstallScript` for it. So npm never fetches the ~122 MB binary, on any
|
|
539
|
+
platform or any install path. Measured on run `34246117877`: plain `npm ci` on ubuntu,
|
|
540
|
+
macos and windows alike finished in 15-38 s and left amicus's own postinstall reporting
|
|
541
|
+
`the Electron GUI binary is not provisioned yet` — an empty electron cache. The lockfile's
|
|
542
|
+
per-platform installable counts (588 / 588 / 587) match the observed `added N packages`
|
|
543
|
+
exactly **with `electron` included**, so the package is present and only the binary is
|
|
544
|
+
missing.
|
|
545
|
+
|
|
546
|
+
The job therefore runs `node node_modules/electron/install.js` in its own step, with one
|
|
547
|
+
retry. It deliberately does **not** use `AMICUS_PREFETCH_ELECTRON=1`, which
|
|
548
|
+
`scripts/postinstall.js` routes through amicus's own `repairElectron` — the A4 diff would
|
|
549
|
+
then compare amicus against amicus.
|
|
550
|
+
|
|
551
|
+
### What it does not prove
|
|
552
|
+
|
|
553
|
+
- **darwin x64.** `macos-latest` is arm64. The x64 artifact was measured at an identical
|
|
554
|
+
shape (585 / 14 / 310), so the residual is small, but no x64 leg exists. Intel runner
|
|
555
|
+
labels changed during 2025 — check GitHub's current list before adding one.
|
|
556
|
+
- **`mas`.** Unreachable in production; no caller passes `platform: 'mas'`.
|
|
557
|
+
- **A case-sensitive APFS volume.** Runners default to case-insensitive; 0 case-insensitive
|
|
558
|
+
name collisions were measured across the 585 entries, but "low exposure" there is
|
|
559
|
+
inference, not measurement.
|
|
560
|
+
- **Future electron layouts, between bumps.** The job proves the version pinned in
|
|
561
|
+
`package-lock.json` at run time. A bump is caught by the `package-lock.json` path filter on
|
|
562
|
+
`pull_request`/`push`, not by the cron.
|
|
563
|
+
- **The native-rescue hatch.** `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`, `ditto` and Info-ZIP
|
|
564
|
+
`unzip` symlink behaviour on darwin stay unmeasured — that is the B2 lane, not this one.
|
|
565
|
+
- **`codesign`.** The archive carries **zero** `_CodeSignature` entries, so
|
|
566
|
+
`codesign --verify` on the extracted bundle would assert nothing. Only the embedded ad-hoc
|
|
567
|
+
Mach-O signature exists, and A5 succeeding is the only evidence it survived byte-exact
|
|
568
|
+
extraction. Do not add a codesign step and call it coverage.
|
|
569
|
+
- **Destination-failure classification on darwin** (ENOSPC, read-only `dist/`, EACCES) and
|
|
570
|
+
**`promoteDist` on APFS** — the job calls the promote once, on a happy path.
|
|
571
|
+
- **A trailing-slash symlink entry.** An entry whose *name* ends with `/` while its mode bits
|
|
572
|
+
say `IFLNK` is turned into a real directory before the symlink branch is reached, so the
|
|
573
|
+
escape check never runs. The real artifact has zero such entries, so no darwin job will
|
|
574
|
+
ever exercise it; it belongs in the platform-independent suite.
|
|
575
|
+
|
|
576
|
+
### Triggers, cost, and the required-check caveat
|
|
577
|
+
|
|
578
|
+
Paths-filtered `pull_request` **and** `push: [main]` (so a bump is proven at merge time),
|
|
579
|
+
plus a weekly cron and `workflow_dispatch`. The cron re-proves the pinned version against the
|
|
580
|
+
live release asset and the current runner image — the two inputs no path filter can see — and
|
|
581
|
+
is the weakest trigger on purpose: a schedule GitHub delays or drops is silent.
|
|
582
|
+
|
|
583
|
+
The job owns a ~122 MB download plus two ~600 MB extractions on a 3-vCPU / 8 GB runner;
|
|
584
|
+
budget 4-6 minutes. It is not free, and macOS *concurrency* rather than minutes is the
|
|
585
|
+
binding constraint on a public repo — `ci.yml` already burns two macOS legs per push.
|
|
586
|
+
|
|
587
|
+
Because both event triggers carry a `paths:` filter, the job reports **skipped** when nothing
|
|
588
|
+
matches, so **it cannot be a required status check as written**. Making it required means
|
|
589
|
+
dropping `paths:` and moving the guard inside the job (a `git diff --name-only` early exit) —
|
|
590
|
+
a pattern this repo does not currently use.
|
|
591
|
+
|
|
592
|
+
### Running it by hand on a Mac
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
npm ci --foreground-scripts
|
|
596
|
+
rm -rf node_modules/electron/dist node_modules/electron/path.txt
|
|
597
|
+
node node_modules/electron/install.js # the artifact + the A4 reference tree
|
|
598
|
+
node scripts/probe-darwin-extract.js --preflight
|
|
599
|
+
node scripts/probe-darwin-extract.js
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
The `rm -rf` is not cosmetic: `install.js` short-circuits on a populated `dist/`, and on a dev
|
|
603
|
+
Mac that `dist/` may well be amicus's own self-heal output — which would make A4 compare
|
|
604
|
+
amicus against amicus. The workflow does the same removal for the same reason.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -452,10 +452,13 @@ That is what a swapped mirror or a planted cache file looks like. It is **also**
|
|
|
452
452
|
|
|
453
453
|
**Fix, in order:**
|
|
454
454
|
|
|
455
|
-
- **Online, do nothing.** Amicus downloads the artifact again with the digest pinned. A truncated download
|
|
455
|
+
- **Online, do nothing.** Amicus downloads the artifact again with the digest pinned. A truncated download
|
|
456
|
+
heals itself. The exception is an archive amicus refuses outright for path traversal — that is terminal
|
|
457
|
+
by design and is not re-fetched, because the problem is what the archive *contains*, not that it arrived
|
|
458
|
+
incomplete.
|
|
456
459
|
- **Air-gapped, re-copy first.** Copy the cache directory again from the machine that downloaded it. A partial copy is the usual cause, and a fresh copy costs you nothing.
|
|
457
460
|
- **Only if you cannot obtain another copy: the native-extractor rescue.** Set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` and provision again. You do not have to have set it in advance: the run that printed the message above left the archive in place precisely so this one has something to work on. Amicus then writes the bytes it hashed to a path inside the Electron package and hands that path to your OS's own extractor (`tar` / `Expand-Archive` / `ditto` / `unzip`) — the same tools that handled the archive before amicus extracted in memory at all. **This is not a safe operation, and it is not described as one.** Between amicus writing the file and the child process opening it, anything running as your user can substitute it, and what that child extracts is promoted into `dist/` without being hashed again. Amicus prints the whole trade on stderr before it spawns anything, and the result is reported `unverified` even when the artifact's own sha256 matched. Unset the variable afterwards: it also downgrades a digest-mismatch refusal to a warning ([configuration.md](./configuration.md#gui-and-debug)).
|
|
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.
|
|
461
|
+
- **What the rescue will *not* do,** whatever this variable is set to: retry an archive refused for path traversal (`REFUSED (unsafe archive)` — terminal by design), retry an extraction that *stalled* (the timeout exists to stop work, not to hand it to a child process), rescue bytes that contradict the published digest (they are known wrong), or paper over a full or unwritable disk. Each of those says something different from "this archive cannot be read", and only the last of those is a rescue amicus was given. Before it hands anything over, amicus also reads the archive's *entry names* and refuses any that would write outside the destination — because an archive can break the extractor early enough that its own traversal check never ran. It reads them in **both** tables a zip declares them in, so cutting the tail off an archive no longer hides its names, and the two tables disagreeing does not let one through. It still cannot see a symlink target, and an archive that defeats both walks still reaches the extractor — the notice printed before the spawn says which names were checked; [configuration.md](./configuration.md#gui-and-debug) states every residual and names which extractors were measured to refuse a `..` entry themselves.
|
|
459
462
|
- Headless runs and the full council work without the GUI throughout.
|
|
460
463
|
|
|
461
464
|
---
|
|
@@ -514,11 +517,15 @@ The third line names which way the read failed:
|
|
|
514
517
|
guaranteed either way: a half-written tree is never what `dist/` contains, and a kill mid-extract
|
|
515
518
|
leaves the previous `dist/` exactly where it was.
|
|
516
519
|
- **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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
520
|
+
the way (a handle held on it, or an AV filter denying the move) and it holds a usable executable —
|
|
521
|
+
the one `path.txt` names, or this platform's default when `path.txt` is absent, unreadable or blank
|
|
522
|
+
— the repair refuses and leaves it untouched rather than deleting it with no way back. That covers
|
|
523
|
+
a package cross-installed for another platform through `npm_config_platform`, whose `path.txt` names
|
|
524
|
+
an executable this platform never looks for; through v4.9.6 the guard asked only about this
|
|
525
|
+
platform's default name, so such a tree was read as "not an install" and a promote that FAILED
|
|
526
|
+
deleted it. If it holds neither, it is not an install, and it is replaced. In the one case where the
|
|
527
|
+
tree was renamed away and neither the swap nor the rollback could run, the previous `dist/` is
|
|
528
|
+
intact at `.amicus-retired-<hex>` and the error names it — rename it back to `dist/` to restore it.
|
|
522
529
|
- **A related refusal**, `Refusing to provision electron: … is not a usable artifact name`, means the
|
|
523
530
|
`version` in the Electron package's own `package.json` is not a plausible version string. Amicus
|
|
524
531
|
builds the artifact filename from it and refuses to use anything that is not a plain filename, since
|
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.7",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|