amicus 4.9.3 → 4.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +8 -5
  5. package/docs/architecture-map.md +736 -0
  6. package/docs/configuration.md +165 -26
  7. package/docs/council.md +9 -0
  8. package/docs/doc-system.md +12 -9
  9. package/docs/testing.md +2 -1
  10. package/docs/troubleshooting.md +113 -0
  11. package/docs/usage.md +11 -6
  12. package/package.json +1 -1
  13. package/schemas/model-catalog.schema.json +2 -1
  14. package/schemas/run.schema.json +13 -0
  15. package/scripts/postinstall.js +4 -0
  16. package/skills/sidecar/SKILL.md +1 -8
  17. package/src/cli-handlers-doctor.js +3 -0
  18. package/src/cli-handlers-fanout.js +10 -1
  19. package/src/cli-handlers-resume-continue.js +25 -0
  20. package/src/cli.js +5 -8
  21. package/src/council/briefings-chair.js +4 -2
  22. package/src/council/run-assemble.js +7 -2
  23. package/src/council/run-retry-notes.js +21 -1
  24. package/src/council/run-stages.js +8 -1
  25. package/src/headless.js +125 -7
  26. package/src/mcp-server.js +26 -0
  27. package/src/mcp-tools.js +4 -4
  28. package/src/opencode-client.js +84 -8
  29. package/src/pack/pack-validate.js +3 -0
  30. package/src/session-manager.js +2 -2
  31. package/src/sidecar/continue.js +6 -1
  32. package/src/sidecar/conversation-mirror.js +35 -11
  33. package/src/sidecar/electron-install.js +81 -81
  34. package/src/sidecar/electron-provision.js +179 -0
  35. package/src/sidecar/electron-trust.js +299 -0
  36. package/src/sidecar/fanout-leg-fallback.js +1 -0
  37. package/src/sidecar/fanout-leg.js +10 -2
  38. package/src/sidecar/fanout.js +2 -2
  39. package/src/sidecar/interactive.js +31 -4
  40. package/src/sidecar/models-ceiling-line.js +72 -0
  41. package/src/sidecar/models.js +4 -2
  42. package/src/sidecar/reopen-notices.js +97 -0
  43. package/src/sidecar/reopen-spend.js +3 -2
  44. package/src/sidecar/resume.js +15 -2
  45. package/src/sidecar/session-finalize.js +4 -1
  46. package/src/sidecar/session-utils.js +5 -1
  47. package/src/sidecar/start-metadata.js +1 -1
  48. package/src/sidecar/start.js +10 -5
  49. package/src/sidecar/unzip.js +40 -0
  50. package/src/utils/config.js +33 -12
  51. package/src/utils/curated-models.js +8 -8
  52. package/src/utils/degrade.js +7 -0
  53. package/src/utils/doctor-output-budget-check.js +198 -0
  54. package/src/utils/engine-output-flag.js +105 -0
  55. package/src/utils/engine-variants.js +298 -0
  56. package/src/utils/http-get.js +284 -0
  57. package/src/utils/model-catalog.js +36 -4
  58. package/src/utils/model-ceilings-modelsdev.js +230 -0
  59. package/src/utils/model-fetcher.js +12 -36
  60. package/src/utils/model-output-limit.js +21 -13
  61. package/src/utils/output-length.js +90 -0
  62. package/src/utils/result-schema.js +7 -2
  63. package/src/utils/spend-ledger.js +5 -1
  64. package/src/utils/thinking-validators.js +27 -80
  65. package/src/utils/validators.js +2 -3
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Electron CONTROLLED provision — the pinned download, and what happens to a
3
+ * cached artifact the digest gate refuses.
4
+ *
5
+ * Split out of electron-install.js because that file sits at the repo's 300-line
6
+ * gate and cannot grow. The require arrow is
7
+ * electron-install -> electron-provision -> electron-trust and must never point
8
+ * back, so `extractFromCache` arrives as an argument rather than an import.
9
+ *
10
+ * @module sidecar/electron-provision
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const path = require('path');
16
+
17
+ const { resolveCacheRoots } = require('./electron-cache');
18
+ const { artifactFileName, expectedDigest } = require('./electron-trust');
19
+ const { containsOnDisk } = require('../utils/path-fence');
20
+
21
+ /** Best-effort cache root for downloadArtifact (first resolved root). */
22
+ function cacheRootFor(env = process.env) {
23
+ return resolveCacheRoots(env)[0];
24
+ }
25
+
26
+ /**
27
+ * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
28
+ * api install.js uses (downloadArtifact, force:true), extract offline, and let
29
+ * the caller verify isElectronUsable(). No blind install.js spawn.
30
+ *
31
+ * C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
32
+ * @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
33
+ * from the mirror, so a redirected download still has to produce bytes matching
34
+ * electron's own published sha256. Passed as the ONE entry for this artifact:
35
+ * an empty table is a hard throw upstream, and a table missing the requested
36
+ * name fails the download outright — which is why no anchor means no `checksums`
37
+ * key at all rather than an empty one. With no anchor, @electron/get falls back
38
+ * to its own remote SHASUMS256.txt fetch: weaker, but never a re-download loop.
39
+ *
40
+ * THE HATCH REACHES THIS ROUTE TOO. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` used to
41
+ * be handed only to the cached-artifact gate, so the one case both docs describe
42
+ * — a machine whose electron bytes legitimately differ (a local rebuild, an
43
+ * internally-signed build on a corporate mirror) — still had its download pinned
44
+ * to the official digest and failed. MEASURED before this change: with the hatch
45
+ * set and nothing in any cache root, `checksums` was still sent and the call
46
+ * returned `{repaired:false}` with no reason at all. When the hatch is set the
47
+ * pin is dropped here and @electron/get falls back to the mirror's own
48
+ * SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
49
+ * so it is stated out loud on stderr every time rather than happening quietly —
50
+ * and it is reachable ONLY through a bare env name a repository cannot plant.
51
+ * @returns {Promise<void>}
52
+ */
53
+ async function controlledProvision({
54
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
55
+ fs, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
56
+ }) {
57
+ const fileName = artifactFileName({ version, platform, arch });
58
+ let digest = expectedDigest(anchor, fileName);
59
+ if (digest && policy.allowUnverified) {
60
+ log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
61
+ log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
62
+ digest = null;
63
+ }
64
+ const zip = await downloadArtifact({
65
+ version,
66
+ artifactName: 'electron',
67
+ force: true,
68
+ cacheRoot: cacheRootFor(env),
69
+ platform,
70
+ arch,
71
+ ...(digest ? { checksums: { [fileName]: digest } } : {}),
72
+ downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
73
+ });
74
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
75
+ }
76
+
77
+ /**
78
+ * May this refused artifact be deleted? STRICTLY NARROWER than the unconditional
79
+ * `fs.rmSync` on the corrupt-extract path: the basename must be exactly the
80
+ * artifact we asked for, AND the file must resolve inside a resolved cache root.
81
+ * `containsOnDisk` realpaths both sides and returns false on any error, so an
82
+ * unresolvable path is refused rather than trusted — deleting at an
83
+ * attacker-influenceable path is the one thing a poisoned cache could otherwise
84
+ * turn into a weapon.
85
+ *
86
+ * Cost if it returns a wrong false: the mismatched zip stays and is re-downloaded
87
+ * once per provision. An availability cost, never a safety one — the gate above
88
+ * still refuses to extract it.
89
+ */
90
+ function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
91
+ if (path.basename(zip) !== fileName) { return false; }
92
+ return resolveCacheRoots(env).some((root) => containsOnDisk(root, zip));
93
+ }
94
+
95
+ /**
96
+ * Act on a REFUSED cached artifact: remove the poison when it is safe to, say
97
+ * plainly what happened, and hand back the result shape a cacheOnly caller
98
+ * returns. Deletion happens ONLY on `mismatch` — a `no-digest` artifact is not
99
+ * evidence of anything, and an `unreadable` one is a file we could not even hash.
100
+ * @returns {{repaired:false, integrity:string, reason:string}}
101
+ */
102
+ function rejectCachedZip({ gate, zip, fileName, env = process.env, fs, log = () => {} }) {
103
+ let removed = false;
104
+ if (gate.verdict === 'mismatch' && mayDeleteRejectedZip({ zip, fileName, env })) {
105
+ try {
106
+ fs.rmSync(zip, { force: true });
107
+ removed = true;
108
+ } catch { /* a cache we cannot write is not a reason to fail the repair */ }
109
+ }
110
+ const what = gate.verdict === 'mismatch'
111
+ ? `sha256 ${gate.actual} does not match the published ${gate.expected}`
112
+ : gate.reason;
113
+ log(`[amicus] Electron artifact REFUSED: ${fileName}`);
114
+ log(`[amicus] ${zip}`);
115
+ log(`[amicus] ${what}`);
116
+ log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
117
+ log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
118
+ log('[amicus] electron looks like — amicus cannot tell them apart.');
119
+ // ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
120
+ // do about a file that is already gone: a hand-seeded air-gapped cache is the
121
+ // one place the refused artifact was also the ONLY copy, and being told about
122
+ // the hatch after "The file has been removed." is being told too late to use
123
+ // it. The delete itself is required (a poisoned zip must not survive to be
124
+ // re-offered); the words around it are what make it recoverable.
125
+ log('[amicus] If you deliberately run a REBUILT electron, set');
126
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
127
+ log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
128
+ log(`[amicus] ${removed
129
+ ? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
130
+ + '\n[amicus] amicus download it again) once that variable is set.'
131
+ : 'The file was left in place.'}`);
132
+ log('[amicus] Headless runs and the council work without the GUI.');
133
+ return {
134
+ repaired: false,
135
+ integrity: gate.verdict,
136
+ reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
137
+ + `${removed ? ' It has been removed.' : ' It was left in place.'}`,
138
+ };
139
+ }
140
+
141
+ /** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
142
+ function isUnsafeArchive(err) {
143
+ return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
144
+ }
145
+
146
+ /**
147
+ * C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
148
+ * as terminal so the same archive is never handed to an OS extractor that has no
149
+ * such check. That invariant held only INSIDE unzip.js: both of repairElectron's
150
+ * catch blocks used to swallow the refusal without reading `err.code` and launder
151
+ * it back into exactly the retry the control forbids — the network path spawned
152
+ * `node <electronDir>/install.js`, which re-downloads and re-extracts through
153
+ * @electron-internal/extract-zip with no amicus supervision (the forbidden move,
154
+ * one stack frame up), and the cache path deleted the zip through the UNFENCED
155
+ * `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
156
+ * reported as corruption. MEASURED both, before this change.
157
+ *
158
+ * So the refusal ends here: no retry, no fallback extractor, and no delete. The
159
+ * archive is left where it is, because a refused archive is evidence, and
160
+ * `err.message` already carries the path and extract-zip's own reason.
161
+ * @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
162
+ */
163
+ function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
164
+ const detail = (err && err.message) || 'the archive tried to write outside its destination';
165
+ log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
166
+ log(`[amicus] ${detail}`);
167
+ log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
168
+ log('[amicus] will not retry it with another extractor, and has left the file in place.');
169
+ log('[amicus] Headless runs and the council work without the GUI.');
170
+ return {
171
+ repaired: false,
172
+ integrity: 'unsafe-archive',
173
+ reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
174
+ };
175
+ }
176
+
177
+ module.exports = {
178
+ cacheRootFor, controlledProvision, mayDeleteRejectedZip, rejectCachedZip, isUnsafeArchive, refuseUnsafeArchive,
179
+ };
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
3
+ *
4
+ * A hostile REPOSITORY (a clone the user opens, an unpacked sample) controls the
5
+ * `.npmrc` and `package.json` of the directory amicus's own docs tell people to
6
+ * run `npx -y amicus@latest` in. MEASURED (npm 11.16.0): that reaches the child
7
+ * as exactly two name shapes — `npm_config_<key lowercased>` and
8
+ * `npm_package_config_<key case-preserved>` — and NOTHING else. Bare `ELECTRON_*`
9
+ * and `AMICUS_*` names are out of its reach. THAT is the trust boundary this
10
+ * module encodes: the escape hatch can be a plain environment variable, and a
11
+ * machine-level `ELECTRON_MIRROR` can still be honoured, precisely because a
12
+ * repository cannot write either one.
13
+ *
14
+ * The control is the DIGEST, not the URL. Supplying `checksums` to
15
+ * `downloadArtifact` makes `@electron/get` write a LOCAL `SHASUMS256.txt` and
16
+ * never fetch one from the mirror (@electron/get 5.0.0, dist/index.js, lines
17
+ * 28-40), so an attacker who redirects the download still has to produce bytes that match
18
+ * electron's own published sha256. Blocking the URL itself is defence in depth on
19
+ * top of a control that already works, and is deliberately NOT built here.
20
+ *
21
+ * LEAF MODULE: `crypto` + `path` + `fs` and nothing from this repo. The arrow is
22
+ * electron-install -> electron-provision -> electron-trust and must never point
23
+ * back; src/utils/path-fence.js:11-17 records what a cycle does to a destructured
24
+ * import in exactly this cluster.
25
+ *
26
+ * @module sidecar/electron-trust
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const crypto = require('crypto');
32
+ const fsDefault = require('fs');
33
+ const path = require('path');
34
+
35
+ /**
36
+ * Env-name PREFIXES an untrusted REPOSITORY can plant. MEASURED, npm 11: a repo
37
+ * .npmrc key `k` reaches an `npm run` / `npm exec` child as
38
+ * `npm_config_<k lowercased>`; a repo package.json "config" key `k` reaches it as
39
+ * `npm_package_config_<k>` with case preserved. Nothing else.
40
+ *
41
+ * PREFIXES, not a hand-maintained name list. Two prefixes cover every
42
+ * `@electron/get` mirror knob in each repo-reachable spelling, plus electron's
43
+ * own `npm_config_electron_use_remote_checksums` (electron's install.js, lines
44
+ * 47-50 — that name turns electron's bundled pin OFF), plus any knob a future
45
+ * @electron/get adds in the same namespace. Contrast ENGINE_CREDENTIAL_ENV
46
+ * (scripts/run-integration-keyless.js:101), whose own docblock warns that nothing
47
+ * makes a name list follow an upstream bump.
48
+ *
49
+ * The BARE `electron_use_remote_checksums` is deliberately NOT removed: a bare
50
+ * lower-case name is not repo-injectable, so it carries the machine owner's
51
+ * intent, exactly like a bare `ELECTRON_MIRROR`.
52
+ *
53
+ * MATCHED CASE-INSENSITIVELY. This used to fold no case, on the claim that
54
+ * because the Windows environment block is case-insensitive, deleting the
55
+ * lower-case name also removed the `NPM_CONFIG_ELECTRON_*` view @electron/get
56
+ * reads second. That is true of `process.env` and FALSE of the `{...env}` PLAIN
57
+ * OBJECT this module actually deletes from — a plain object is case-sensitive on
58
+ * every platform, so the upper-case key survived and was handed to the child.
59
+ * RE-MEASURED (npm 11.16.0, Windows 11) — two ways a repository reaches an
60
+ * upper-case slot:
61
+ * 1. `.npmrc` `electron_mirror=…` while `NPM_CONFIG_ELECTRON_MIRROR` already
62
+ * exists in the environment: npm overwrites that slot's VALUE and never
63
+ * renames it, so the child sees the ATTACKER's URL under the upper-case name.
64
+ * 2. `package.json` `"config": {"ELECTRON_MIRROR": …}`: npm PRESERVES the key's
65
+ * case, planting `npm_package_config_ELECTRON_MIRROR` with nothing
66
+ * pre-existing at all — and @electron/get's own lookup for
67
+ * `npm_package_config_electron_mirror` (dist/artifact-utils.js, line 28) finds it,
68
+ * because the Windows lookup is case-insensitive too.
69
+ * The old docblock's POSIX half (`NPM_CONFIG_ELECTRON_*` is a distinct variable
70
+ * npm never writes there, so it is the machine owner's) is NOT measurable from
71
+ * this machine, and it is load-bearing in the fail-OPEN direction: wrong, it
72
+ * hands the child an attacker's mirror. Wrong the other way it costs one
73
+ * alternate spelling inside a last-resort spawn, while bare `ELECTRON_MIRROR`
74
+ * — which @electron/get ranks FIRST — still carries owner intent. So the fold is
75
+ * unconditional rather than resting on an unverified platform claim.
76
+ */
77
+ const REPO_ENV_PREFIXES = ['npm_config_electron_', 'npm_package_config_electron_'];
78
+
79
+ /** electron's install.js, lines 20-21 and 99 — these choose WHICH artifact it
80
+ * fetches, and `.npmrc` `platform=`/`arch=` plants both. Same case fold. */
81
+ const ELECTRON_INSTALL_TARGET_ENV = ['npm_config_platform', 'npm_config_arch'];
82
+
83
+ /** A published sha256 is 64 LOWER-case hex characters. Anything else is not an anchor. */
84
+ const HEX64 = /^[0-9a-f]{64}$/;
85
+
86
+ /** Leading 'v' applied exactly once — matches @electron/get's normalizeVersion. */
87
+ function normalizeV(version) {
88
+ const v = String(version || '');
89
+ return v.startsWith('v') ? v : `v${v}`;
90
+ }
91
+
92
+ /** True for a name a hostile repository could have planted, in ANY case (see above). */
93
+ function isRepoPlantedName(name) {
94
+ const lower = String(name).toLowerCase();
95
+ return REPO_ENV_PREFIXES.some((prefix) => lower.startsWith(prefix))
96
+ || ELECTRON_INSTALL_TARGET_ENV.includes(lower);
97
+ }
98
+
99
+ /**
100
+ * The amicus-namespaced trust policy. THE ONLY place the escape hatch is read.
101
+ * Never reads npm_config_* / npm_package_config_* / NPM_CONFIG_* — those are the
102
+ * attacker's channel, and a rule that read its own writer's surface would be no
103
+ * rule at all.
104
+ *
105
+ * `allowUnverified` is true for the string '1' and NOTHING else — 'true', 'yes',
106
+ * ' 1' are all false. A hatch that fails open on a typo is not a hatch.
107
+ * @param {NodeJS.ProcessEnv} [env]
108
+ * @returns {{ allowUnverified: boolean }}
109
+ */
110
+ function electronTrustPolicy(env = process.env) {
111
+ return { allowUnverified: env.AMICUS_ALLOW_UNVERIFIED_ELECTRON === '1' };
112
+ }
113
+
114
+ /** `electron-v43.1.1-win32-x64.zip`. Matches BOTH electron's checksums.json keys
115
+ * and @electron/get's getArtifactFileName + normalizeVersion. */
116
+ function artifactFileName({ version, platform, arch }) {
117
+ return `electron-${normalizeV(version)}-${platform}-${arch}.zip`;
118
+ }
119
+
120
+ /**
121
+ * The electron package THIS amicus resolves — resolveAnchor rung 1. Duplicated
122
+ * (not imported from electron-install.defaultElectronDir) so this module stays a
123
+ * leaf and the require arrow keeps pointing one way.
124
+ * @returns {string|null}
125
+ */
126
+ function selfElectronPackageDir() {
127
+ try {
128
+ return path.dirname(require.resolve('electron/package.json'));
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Parse one checksums.json into a table of ONLY well-formed rows. Never throws. */
135
+ function readChecksumTable(file, fs) {
136
+ try {
137
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
138
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null; }
139
+ const table = {};
140
+ for (const [name, digest] of Object.entries(parsed)) {
141
+ if (typeof digest === 'string' && HEX64.test(digest)) { table[name] = digest; }
142
+ }
143
+ return Object.keys(table).length > 0 ? table : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Resolve the digest ANCHOR, offline. Precedence, highest first:
151
+ * 1. <selfElectronDir>/checksums.json — the RUNNING amicus's own table.
152
+ * 2. <electronDir>/checksums.json — ONLY when rung 1 offers no usable table.
153
+ *
154
+ * RUNG 1 IS LOAD-BEARING, not a convenience. `doctor --fix`
155
+ * (src/utils/doctor-electron-mcp-check.js:122-137) hands `repairElectron` an
156
+ * electronDir found by a FILESYSTEM SCAN of npx caches, so rung 2 on its own
157
+ * would read the anchor out of the same untrusted directory the bytes came from
158
+ * — the pin would vouch for the attacker's own zip. Rung 1 is the same published
159
+ * data out of a tree npm installed for amicus itself, before any hostile
160
+ * directory was visited.
161
+ *
162
+ * WHY RUNG 1 NO LONGER TESTS THE VERSION. It used to apply only when the self
163
+ * package's version equalled the requested one — but the requested `version` is
164
+ * itself read out of `<electronDir>/package.json` whenever the caller supplies
165
+ * none (electron-install.js `if (!version)`), and the ONE production caller,
166
+ * doctor --fix, supplies none. MEASURED on this tree: a planted
167
+ * `{"version":"99.0.0"}` demoted rung 1 by DATA alone, the scanned tree's own
168
+ * checksums.json then vouched for its own bytes, and repairElectron returned
169
+ * `{repaired:true}` after extracting POISONED-BYTES. A rule that reads its own
170
+ * selector off the surface it exists to distrust is not a rule. No version check
171
+ * is needed to keep a genuine version disagreement honest, because the table is
172
+ * keyed by the FULL artifact filename: a self table for 43.1.1 simply holds no
173
+ * `electron-v99.0.0-…zip` row, `expectedDigest` returns null, and the gate's
174
+ * `no-digest` verdict extracts-and-MARKS exactly as the brief requires — never a
175
+ * refusal, never a re-download loop.
176
+ *
177
+ * Rung 2 therefore survives for exactly one case: amicus's own electron package
178
+ * ships no readable checksums.json (an old electron, or the optionalDependency
179
+ * never installed). There the target's table is all there is, and it is still
180
+ * better than nothing against a truncated download.
181
+ *
182
+ * Pass `selfElectronDir: null` to disable rung 1.
183
+ * NEVER THROWS. Rejects a table whose values are not 64 lower-case hex.
184
+ * @returns {{ table: Record<string,string>, source: string } | null}
185
+ */
186
+ function resolveAnchor({ electronDir, fs = fsDefault, selfElectronDir } = {}) {
187
+ const self = selfElectronDir === undefined ? selfElectronPackageDir() : selfElectronDir;
188
+ for (const dir of [self, electronDir]) {
189
+ if (!dir) { continue; }
190
+ const source = path.join(dir, 'checksums.json');
191
+ const table = readChecksumTable(source, fs);
192
+ if (table) { return { table, source }; }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** @returns {string|null} the 64-hex digest for one artifact, or null. */
198
+ function expectedDigest(anchor, fileName) {
199
+ if (!anchor || !anchor.table) { return null; }
200
+ const digest = anchor.table[fileName];
201
+ return typeof digest === 'string' && HEX64.test(digest) ? digest : null;
202
+ }
203
+
204
+ /** Chunked sha256 (1 MiB buffer, openSync/readSync). SYNC so it composes with
205
+ * repairElectron's injected `fs`; chunked so a ~170 MB zip is never buffered whole. */
206
+ function sha256File(file, fs = fsDefault) {
207
+ const hash = crypto.createHash('sha256');
208
+ const buffer = Buffer.alloc(1024 * 1024);
209
+ const fd = fs.openSync(file, 'r');
210
+ try {
211
+ let read = fs.readSync(fd, buffer, 0, buffer.length, null);
212
+ while (read > 0) {
213
+ hash.update(buffer.subarray(0, read));
214
+ read = fs.readSync(fd, buffer, 0, buffer.length, null);
215
+ }
216
+ } finally {
217
+ try { fs.closeSync(fd); } catch { /* already closed */ }
218
+ }
219
+ return hash.digest('hex');
220
+ }
221
+
222
+ /**
223
+ * THE GATE. NEVER THROWS. `allowed` is the single decision bit callers act on.
224
+ *
225
+ * @returns {{verdict:'verified', allowed:true, actual:string}
226
+ * | {verdict:'mismatch', allowed:boolean, expected:string, actual:string}
227
+ * | {verdict:'no-digest', allowed:true}
228
+ * | {verdict:'unreadable', allowed:false, reason:string}}
229
+ *
230
+ * `no-digest` is ALLOWED and merely marked. An electron package that predates
231
+ * `checksums.json` has no anchor through no fault of its own, and refusing it
232
+ * would push that machine into a permanent re-download loop for a file no
233
+ * download can improve.
234
+ *
235
+ * `unreadable` is refused whatever the policy says: bytes that cannot be hashed
236
+ * cannot be extracted either, so there is nothing to fail open to.
237
+ */
238
+ function verifyArtifact({ zip, anchor, fileName, policy = {}, fs = fsDefault, log = () => {} }) {
239
+ const expected = expectedDigest(anchor, fileName);
240
+ if (!expected) {
241
+ log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
242
+ log('[amicus] checksums.json entry for it), so its bytes could not be verified.');
243
+ return { verdict: 'no-digest', allowed: true };
244
+ }
245
+ let actual;
246
+ try {
247
+ actual = sha256File(zip, fs);
248
+ } catch (e) {
249
+ return { verdict: 'unreadable', allowed: false, reason: `could not hash ${fileName}: ${(e && e.message) || e}` };
250
+ }
251
+ if (actual === expected) { return { verdict: 'verified', allowed: true, actual }; }
252
+ if (policy.allowUnverified) {
253
+ log(`[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — accepting ${fileName} even though`);
254
+ log(`[amicus] its sha256 ${actual} does not match the published ${expected}.`);
255
+ log('[amicus] Unset that variable to fail closed.');
256
+ return { verdict: 'mismatch', allowed: true, expected, actual };
257
+ }
258
+ return { verdict: 'mismatch', allowed: false, expected, actual };
259
+ }
260
+
261
+ /**
262
+ * A COPY of env for the runInstaller SPAWN. Never mutates the argument.
263
+ *
264
+ * electron's own install.js honours `npm_config_electron_mirror` (through
265
+ * @electron/get) AND `npm_config_electron_use_remote_checksums` (its lines 47-50,
266
+ * which turns its bundled pin off), so spawning it with an unfiltered
267
+ * `{...process.env}` would funnel a blocked attacker straight into an unpinned
268
+ * downloader. `npm_config_platform` / `npm_config_arch` (its lines 20-21 and 99)
269
+ * choose WHICH artifact it fetches, so they are removed too and amicus's own
270
+ * resolution is pinned through `ELECTRON_INSTALL_PLATFORM`/`_ARCH`, which
271
+ * install.js ranks above them.
272
+ *
273
+ * LEAVES ALONE, deliberately — every one of these is a BARE name a repository
274
+ * cannot plant, so it is the machine owner's: `ELECTRON_MIRROR`,
275
+ * `ELECTRON_CUSTOM_*`, `electron_config_cache`, `ELECTRON_CACHE`,
276
+ * `electron_use_remote_checksums`, `HTTP_PROXY`/`HTTPS_PROXY`/`ELECTRON_GET_USE_PROXY`.
277
+ */
278
+ function scrubbedChildEnv({ env = process.env, platform, arch } = {}) {
279
+ const out = { ...env };
280
+ for (const name of Object.keys(out)) {
281
+ if (isRepoPlantedName(name)) { delete out[name]; }
282
+ }
283
+ if (platform) { out.ELECTRON_INSTALL_PLATFORM = platform; }
284
+ if (arch) { out.ELECTRON_INSTALL_ARCH = arch; }
285
+ return out;
286
+ }
287
+
288
+ module.exports = {
289
+ electronTrustPolicy,
290
+ resolveAnchor,
291
+ expectedDigest,
292
+ verifyArtifact,
293
+ sha256File,
294
+ artifactFileName,
295
+ scrubbedChildEnv,
296
+ normalizeV,
297
+ REPO_ENV_PREFIXES,
298
+ ELECTRON_INSTALL_TARGET_ENV,
299
+ };
@@ -47,6 +47,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
47
47
  project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
48
48
  retryOfWaveId: leg && leg.retryOfWaveId,
49
49
  tag: (leg && leg.tag) || null,
50
+ finish: doc.finish, variant: doc.variant, // #218 PR 3 / PR 4: appendSpend keeps each only when it is a string (named mutant "LEGROWNOVARIANT", tests/sidecar/fanout.test.js)
50
51
  };
51
52
  if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
52
53
  appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
@@ -76,7 +76,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
76
76
  * Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
77
77
  * fallback loop reads a stable shape without re-deriving them.
78
78
  */
79
- async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, noOutputBackstopMs }) {
79
+ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, variant, quiet, foldNonce, noOutputBackstopMs }) {
80
80
  const { IdleWatchdog } = require('../utils/idle-watchdog');
81
81
  const { markAborted } = require('../utils/session-abort');
82
82
  const { runHeadless } = require('../headless');
@@ -123,7 +123,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
123
123
  result = await runHeadless(
124
124
  leg.model, systemPrompt, userMessage, legId, project,
125
125
  timeoutMs, agent || 'build',
126
- { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory, noOutputBackstopMs }
126
+ { client, server, watchdog, summaryLength, variant, nonce: foldNonce, directory, noOutputBackstopMs }
127
127
  );
128
128
  } catch (err) {
129
129
  result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
@@ -215,6 +215,14 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
215
215
  // nothing whichever one holds: it is one `&&` against a value already in a
216
216
  // register, and it is dead code if `result` is truly always assigned.
217
217
  ttftMs: result && isMeasuredTtft(result.ttftMs) ? result.ttftMs : undefined,
218
+ // #218 PR 3: the engine's `finish` for the leg's last assistant message
219
+ // ('length' = stopped at the reservation), emit-when-set like ttftMs above.
220
+ finish: (result && typeof result.finish === 'string') ? result.finish : undefined,
221
+ // #218 PR 4: the effort level SENT (emit-when-sent) and whether the engine's
222
+ // catalogue knew the model when it was sent. Named mutant "LEGVARIANTDROPPED"
223
+ // (tests/sidecar/fanout.test.js).
224
+ variant: (result && typeof result.variant === 'string') ? result.variant : undefined,
225
+ variantUnverified: (result && result.variantUnverified === true) ? true : undefined,
218
226
  };
219
227
  let finalMeta = legPatch;
220
228
  if (legDir) {
@@ -246,7 +246,7 @@ async function runFanout(options) {
246
246
  HEARTBEAT_INTERVAL
247
247
  );
248
248
  const timeoutMs = (options.timeout || 15) * 60 * 1000;
249
- const reasoning = options.thinking ? { effort: options.thinking } : undefined;
249
+ const variant = options.thinking || undefined; // #218 PR 4: one level for every leg, sent as the engine's `variant` field (named mutant "FANOUTVARIANTDROPPED", tests/sidecar/fanout.test.js: drop `variant` from the runLeg args)
250
250
  let legDocs;
251
251
  try {
252
252
  // retryContexts/retryOfWaveId (v4.3 Task 19): absent on a normal wave, so
@@ -261,7 +261,7 @@ async function runFanout(options) {
261
261
  systemPrompt: saved ? rc.systemPrompt : systemPrompt,
262
262
  userMessage: saved ? rc.userMessage : userMessage,
263
263
  timeoutMs, agent: options.agent, client, server,
264
- summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
264
+ summaryLength: options.summaryLength, variant, quiet: options.quiet,
265
265
  foldNonce, directory: options.directory, follow,
266
266
  fallback: options.fallback, catalog: options.catalog, noOutputBackstopMs: options.noOutputBackstopMs,
267
267
  });
@@ -18,6 +18,7 @@ const { canonicalProjectPath } = require('../utils/project-path');
18
18
  const { ensureElectron } = require('./electron-ensure');
19
19
  const { writeProgress } = require('./progress');
20
20
  const { getElectronPath, buildElectronEnv, handleElectronProcess } = require('./interactive-process');
21
+ const { readOutputBudgetSafe } = require('../headless');
21
22
 
22
23
  /** Run sidecar in interactive mode (Electron GUI) */
23
24
  async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
@@ -33,7 +34,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
33
34
  };
34
35
  }
35
36
 
36
- const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client, foldNonce } = options;
37
+ const { agent, isResume, conversation, mcp, variant, opencodeSessionId, client, foldNonce } = options;
37
38
 
38
39
  // F6c: mirror headless's lifecycle stages (best-effort — a write failure must
39
40
  // never break the GUI) so the heartbeat/status never read "Starting up...".
@@ -70,6 +71,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
70
71
  };
71
72
  }
72
73
 
74
+ let sent = null; // #218 PR 4 whole-branch review (EP-4): what sendPrompt SENT, for the record
73
75
  // Create or reconnect to session
74
76
  let sessionId;
75
77
  try {
@@ -93,9 +95,29 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
93
95
 
94
96
  // Always set agent — defaults to 'chat' when not specified
95
97
  promptOptions.agent = agentConfig.agent;
96
- if (reasoning) { promptOptions.reasoning = reasoning; }
97
-
98
- await sendPromptAsync(ocClient, sessionId, promptOptions);
98
+ // #218 PR 4: the engine's `variant` field, validated in sendPrompt; the
99
+ // spawn-time budget rides the handle (PR 3). A refusal lands in the catch
100
+ // below as "Session setup failed: VARIANT_…" — nothing was sent.
101
+ // Named mutants "GUIBUDGETDROPPED" / "GUIREFUSALPREFIX" (tests/sidecar/interactive-variant.test.js).
102
+ if (variant) {
103
+ promptOptions.variant = variant;
104
+ promptOptions.outputBudget = readOutputBudgetSafe(server); // council #235 r1 (A1): the SAME reader headless uses (src/headless.js :: readOutputBudgetSafe) — the handle's spawn value, else config
105
+ }
106
+
107
+ const promptResult = await sendPromptAsync(ocClient, sessionId, promptOptions);
108
+ sent = promptResult && promptResult.sentVariant;
109
+ if (sent && !sent.verified) {
110
+ const { formatUnverifiedVariantNote } = require('../utils/engine-variants');
111
+ const note = formatUnverifiedVariantNote({ model, variant: sent.variant, waitedMs: sent.waitedMs, unreadable: sent.unreadable });
112
+ logger.warn('Variant sent unverified', { taskId, sessionId, note });
113
+ // council #235 r2 (B2): logger.warn is DROPPED at the shipped default
114
+ // (LOG_LEVEL defaults to 'error', utils/logger.js), so the structured line alone
115
+ // told the user nothing — the silent degrade the product principle forbids, and the
116
+ // same invisibility this release cites against 4.9.3's silent adjustment. stderr
117
+ // carries it in every mode; stdout keeps the run document intact. Named mutant
118
+ // "UNVERIFIEDNOTICESILENT": drop the stderr write.
119
+ process.stderr.write(`Notice: ${note}\n`);
120
+ }
99
121
  progressStage('prompt_sent');
100
122
  }
101
123
  logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
@@ -201,6 +223,11 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
201
223
  } catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
202
224
  try { await server.close(); } catch { /* best-effort */ }
203
225
  logger.debug('OpenCode server closed after Electron exit');
226
+ // #218 PR 4 whole-branch review (EP-4/REC-2/PRT-2): the level SENT rides the interactive
227
+ // result too (emit-when-sent, the derivation headless.js:811-814 makes), so start.js's
228
+ // writers stamp `variant` / `variantUnverified` for the default GUI mode as well.
229
+ // Named mutant "INTERACTIVEVARIANTDROPPED" (tests/sidecar/interactive-variant.test.js).
230
+ if (sent) { result.variant = sent.variant; if (!sent.verified) { result.variantUnverified = true; } }
204
231
  result.opencodeSessionId = sessionId;
205
232
  resolve(result);
206
233
  });