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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +8 -5
- package/docs/architecture-map.md +736 -0
- package/docs/configuration.md +165 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +113 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/scripts/postinstall.js +4 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/sidecar/unzip.js +40 -0
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- 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,
|
|
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,
|
|
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) {
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
});
|