amicus 4.9.6 → 4.9.8
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 +246 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +24 -4
- package/docs/configuration.md +43 -15
- package/docs/council.md +140 -3
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +14 -7
- package/docs/usage.md +8 -4
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli.js +23 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- 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/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/session-utils.js +7 -34
- 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
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/degrade.js +8 -0
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* caller already hashed and writes `dist/` by extract-into-incoming + promote
|
|
19
19
|
* (see `promoteDist`).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* NEAR-LEAF: `path` and `crypto`, plus the true leaf `./electron-exe-rel` (the
|
|
22
|
+
* one `path.txt` rule, shared with `resolveElectronBinary` since v4.9.7 — A1),
|
|
23
|
+
* with `fs` and the extractor injected by the caller — so electron-provision.js
|
|
24
|
+
* requires it too without any risk of a cycle. `platformExe` is re-exported
|
|
25
|
+
* from here because it was defined here through v4.9.6.
|
|
23
26
|
*
|
|
24
27
|
* @module sidecar/electron-layout
|
|
25
28
|
*/
|
|
@@ -28,24 +31,7 @@
|
|
|
28
31
|
|
|
29
32
|
const crypto = require('crypto');
|
|
30
33
|
const path = require('path');
|
|
31
|
-
|
|
32
|
-
/** Platform exe basename, matching electron's getPlatformPath(). */
|
|
33
|
-
function platformExe(platform) {
|
|
34
|
-
switch (platform) {
|
|
35
|
-
case 'mas':
|
|
36
|
-
case 'darwin':
|
|
37
|
-
return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
|
|
38
|
-
case 'win32':
|
|
39
|
-
return 'electron.exe';
|
|
40
|
-
default:
|
|
41
|
-
return 'electron';
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Write path.txt: the basename `electron/index.js` joins onto `dist/`. */
|
|
46
|
-
function writePathTxt({ electronDir, platform, fs }) {
|
|
47
|
-
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
|
|
48
|
-
}
|
|
34
|
+
const { platformExe, writePathTxt, distHeldExe } = require('./electron-exe-rel');
|
|
49
35
|
|
|
50
36
|
/**
|
|
51
37
|
* The two litter prefixes this module creates, and how long one may survive.
|
|
@@ -154,19 +140,26 @@ function sweepPromoteLitter({
|
|
|
154
140
|
* and the caller then downloaded 138 MB and repeated the same promote.
|
|
155
141
|
*
|
|
156
142
|
* So the in-place removal now happens ONLY when there is nothing to lose: a
|
|
157
|
-
* `dist/` that holds
|
|
158
|
-
* the user nothing they had. When the old
|
|
159
|
-
* promote REFUSES and that tree is untouched —
|
|
160
|
-
* strictly better than a working GUI becoming no GUI.
|
|
143
|
+
* `dist/` that holds NEITHER the exe `path.txt` names NOR `platformExe` is not
|
|
144
|
+
* an install, and destroying it costs the user nothing they had. When the old
|
|
145
|
+
* tree DOES hold an executable, the promote REFUSES and that tree is untouched —
|
|
146
|
+
* the repair fails, which is strictly better than a working GUI becoming no GUI.
|
|
161
147
|
*
|
|
162
148
|
* THE GUARANTEE, stated so it is checkable:
|
|
163
|
-
* **A promote never removes a `dist/` that
|
|
164
|
-
*
|
|
149
|
+
* **A promote never removes a `dist/` that HELD an executable — under the name
|
|
150
|
+
* `path.txt` gives it, or `platformExe` when `path.txt` is absent, unreadable
|
|
151
|
+
* or blank — unless the new tree is already in its place.**
|
|
152
|
+
* "HELD" means a FILE INSIDE `dist/`; `electron-exe-rel.js :: distHeldExe` owns
|
|
153
|
+
* that rule and carries what each of its bounds was measured to cost (A1).
|
|
165
154
|
* The one exit that can still leave a user without a usable `dist/` is both
|
|
166
155
|
* renames failing after step 1 SUCCEEDED. The old tree is then whole and
|
|
167
156
|
* undeleted at `.amicus-retired-<hex>`, and the thrown message names it so the
|
|
168
157
|
* user can rename it back.
|
|
169
158
|
*
|
|
159
|
+
* WHICH VALUE THE GUARD READS (A1): `raw`, captured BEFORE step 0 — not
|
|
160
|
+
* `replaced`, and never a re-read. `electron-exe-rel.js` carries why, the
|
|
161
|
+
* `ELECTRON_OVERRIDE_DIST_PATH` ruling, and the measurements behind both.
|
|
162
|
+
*
|
|
170
163
|
* `path.txt` IS WRITTEN FIRST (B2). Writing it LAST made "dist but no path.txt"
|
|
171
164
|
* unobservable only while that write SUCCEEDED, and it ran after the old tree was
|
|
172
165
|
* retired AND DELETED, so one ENOSPC/EPERM/AV-locked 12-byte write left a `dist/`
|
|
@@ -189,8 +182,10 @@ function sweepPromoteLitter({
|
|
|
189
182
|
function promoteDist({ electronDir, incomingDist, platform, fs }) {
|
|
190
183
|
const distDir = path.join(electronDir, 'dist');
|
|
191
184
|
const pathFile = path.join(electronDir, 'path.txt');
|
|
192
|
-
let
|
|
193
|
-
|
|
185
|
+
let raw = null; // path.txt BEFORE step 0 overwrites it
|
|
186
|
+
let unreadable = false; // a read that failed for a reason other than ENOENT
|
|
187
|
+
try { raw = fs.readFileSync(pathFile, 'utf8'); } catch (e) { unreadable = !e || e.code !== 'ENOENT'; }
|
|
188
|
+
let replaced = raw; // step 0's overwritten DIFFERENT value
|
|
194
189
|
try {
|
|
195
190
|
if (replaced === platformExe(platform)) { replaced = null; } else {
|
|
196
191
|
try { writePathTxt({ electronDir, platform, fs }); } catch (e) {
|
|
@@ -205,10 +200,15 @@ function promoteDist({ electronDir, incomingDist, platform, fs }) {
|
|
|
205
200
|
retiredExists = true;
|
|
206
201
|
} catch (e) {
|
|
207
202
|
// The old tree cannot be moved. Removing it in place is irreversible, so
|
|
208
|
-
// it is allowed only when the tree is
|
|
209
|
-
if (
|
|
203
|
+
// it is allowed only when the tree is an install under NEITHER name (A1).
|
|
204
|
+
if (unreadable) {
|
|
205
|
+
throw new Error(`${(e && e.message) || e} — path.txt could not be read, so which exe `
|
|
206
|
+
+ 'dist/ holds is unknown and it was left exactly as it was');
|
|
207
|
+
}
|
|
208
|
+
const held = distHeldExe({ distDir, raw, platform, fs });
|
|
209
|
+
if (held) {
|
|
210
210
|
throw new Error(`${(e && e.message) || e} — the existing dist/ holds a usable `
|
|
211
|
-
+ `${
|
|
211
|
+
+ `${held} and was left exactly as it was`);
|
|
212
212
|
}
|
|
213
213
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
214
214
|
}
|
|
@@ -80,6 +80,17 @@ function cleanDir(fs, dir) {
|
|
|
80
80
|
/**
|
|
81
81
|
* Walk the platform's native plan until one strategy leaves files in `dir`.
|
|
82
82
|
*
|
|
83
|
+
* `cwd` IS THE INCOMING TREE, and it is a containment measure with an honest
|
|
84
|
+
* label: MEASURED NEUTRAL, not measured needed. Across 18 paired runs no shipped
|
|
85
|
+
* strategy wrote anything cwd-relative, so this changes no observed behaviour.
|
|
86
|
+
* It costs one line and adds no decision surface, and it converts a hypothetical
|
|
87
|
+
* cwd-relative write from "the user's own repo, under npx, forever" into "the
|
|
88
|
+
* incoming tree, which `extractBytesToDist`'s `finally` deletes unconditionally
|
|
89
|
+
* and `sweepPromoteLitter` takes if a kill skipped that". `unzip.js ::
|
|
90
|
+
* robustExtract`'s native loop deliberately does NOT get the same treatment: its
|
|
91
|
+
* `dir` is not inside a tree amicus deletes unconditionally, so binding a cwd
|
|
92
|
+
* there would point a child's working directory at the user's install.
|
|
93
|
+
*
|
|
83
94
|
* The verdicts are unzip.js's, because they were right there: a spawn error or
|
|
84
95
|
* an external signal-kill (`status: null` — SIGKILL, an OOM) is a FAILURE even
|
|
85
96
|
* if files landed, a non-zero exit is a failure, and a clean exit that produced
|
|
@@ -87,12 +98,14 @@ function cleanDir(fs, dir) {
|
|
|
87
98
|
* strategy starts from an empty directory.
|
|
88
99
|
* @returns {string|null} the strategy name that worked, or null
|
|
89
100
|
*/
|
|
90
|
-
function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
101
|
+
function runNativePlan({ zip, dir, cwd, platform, fs, spawn, maxMs, log }) {
|
|
91
102
|
const failures = [];
|
|
92
103
|
for (const strat of nativeUnzipPlan(zip, dir, platform)) {
|
|
93
104
|
let res;
|
|
94
105
|
try {
|
|
95
|
-
res = spawn(strat.cmd, strat.args, {
|
|
106
|
+
res = spawn(strat.cmd, strat.args, {
|
|
107
|
+
stdio: 'ignore', windowsHide: true, timeout: maxMs, cwd,
|
|
108
|
+
});
|
|
96
109
|
} catch (e) {
|
|
97
110
|
failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
|
|
98
111
|
continue;
|
|
@@ -118,6 +131,11 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
|
118
131
|
* will find it — so a rescue lands in `dist/` by the SAME single rename, with the
|
|
119
132
|
* same litter sweep, and this module never touches the promote at all.
|
|
120
133
|
*
|
|
134
|
+
* `namesComplete`/`namesChecked` are REQUIRED and deliberately have no defaults:
|
|
135
|
+
* they drive a disclosure, and a caller that forgot to thread them must not get
|
|
136
|
+
* the reassuring branch by omission. `announceNativeRescue` treats `undefined` as
|
|
137
|
+
* "not complete" for the same reason.
|
|
138
|
+
*
|
|
121
139
|
* `flag: 'wx'` is a real control and a small one: `O_EXCL` refuses to write
|
|
122
140
|
* through a name that already exists, INCLUDING a symlink someone pre-planted at
|
|
123
141
|
* it. It does nothing about a substitution AFTER the write — that window is the
|
|
@@ -126,14 +144,14 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
|
126
144
|
* `extractBytesToDist` removes the whole incoming tree regardless.
|
|
127
145
|
* @returns {string|null} the strategy name that recovered the archive, or null
|
|
128
146
|
*/
|
|
129
|
-
function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
|
|
147
|
+
function nativeRescue({ bytes, dir, reason, namesComplete, namesChecked, platform, fs, spawn, maxMs, log }) {
|
|
130
148
|
const incoming = path.dirname(dir);
|
|
131
149
|
if (!path.basename(incoming).startsWith(INCOMING_PREFIX)) {
|
|
132
150
|
log(`[amicus] the native-extractor rescue was NOT attempted: ${collapseExcerpt(dir, PATH_EXCERPT_CHARS)} is not inside an amicus incoming directory.`);
|
|
133
151
|
return null;
|
|
134
152
|
}
|
|
135
153
|
const zip = path.join(incoming, RESCUE_ZIP);
|
|
136
|
-
announceNativeRescue({ zip, reason, log });
|
|
154
|
+
announceNativeRescue({ zip, reason, namesComplete, namesChecked, log });
|
|
137
155
|
try {
|
|
138
156
|
fs.writeFileSync(zip, bytes, { flag: 'wx', mode: 0o600 });
|
|
139
157
|
} catch (e) {
|
|
@@ -144,7 +162,7 @@ function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
|
|
|
144
162
|
// The failed extractor's partial tree is evidence of nothing and would be
|
|
145
163
|
// promoted as if it were a rescue. It goes before the child runs.
|
|
146
164
|
cleanDir(fs, dir);
|
|
147
|
-
const strategy = runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log });
|
|
165
|
+
const strategy = runNativePlan({ zip, dir, cwd: incoming, platform, fs, spawn, maxMs, log });
|
|
148
166
|
if (strategy) {
|
|
149
167
|
log(`[amicus] recovered via the native extractor (${strategy}). These bytes were NOT re-hashed; the result is marked unverified.`);
|
|
150
168
|
}
|
|
@@ -118,6 +118,7 @@ const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native
|
|
|
118
118
|
const { offerNativeRescue } = require('./electron-rescue-notice');
|
|
119
119
|
// The read-only name walk the boundary consults before it trusts a verdict.
|
|
120
120
|
const { scanEntryNames } = require('./zip-name-scan');
|
|
121
|
+
const { scanLocalNames } = require('./zip-local-name-scan');
|
|
121
122
|
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
122
123
|
|
|
123
124
|
/** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
|
|
@@ -144,27 +145,46 @@ function isRescuableFailure(err) {
|
|
|
144
145
|
* unadvertised, left in place, exactly as if the archive had had nothing wrong
|
|
145
146
|
* with it but that entry.
|
|
146
147
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
148
|
+
* BOTH TABLES, BECAUSE THE STRATEGIES DO NOT AGREE ON WHICH ONE THEY READ (B3).
|
|
149
|
+
* Through v4.9.6 this asked the CENTRAL directory only, so an archive that blinds
|
|
150
|
+
* yauzl there — a truncation, or any of four ONE-FIELD forgeries of a COMPLETE
|
|
151
|
+
* end-of-central-directory record — declared no names amicus could see and went
|
|
152
|
+
* to the native extractor anyway. MEASURED: seven such archives carrying
|
|
153
|
+
* `../../../PWNED-BY-NATIVE.txt` reached a real spawn, and on two the rescue ran
|
|
154
|
+
* to COMPLETION and promoted. Only the Windows tools' own `..` guards stopped the
|
|
155
|
+
* escape — the exact reliance this module says amicus will not make.
|
|
156
|
+
* And the tables can DISAGREE: on an archive declaring one name locally and
|
|
157
|
+
* another centrally, `tar.exe` wrote the LOCAL name while `Expand-Archive` wrote
|
|
158
|
+
* the CENTRAL one. So a refusal in EITHER table refuses the archive.
|
|
159
|
+
*
|
|
160
|
+
* THE RESIDUALS THAT REMAIN. Neither walk sees a SYMLINK whose target escapes:
|
|
161
|
+
* that is a payload, not a name. And an archive that defeats BOTH walks still
|
|
162
|
+
* reaches the extractor — rarer than before, but not impossible — so the notice
|
|
163
|
+
* printed before the spawn now says WHICH names were checked, rather than letting
|
|
164
|
+
* the user assume they all were. `docs/configuration.md` says the same thing to
|
|
156
165
|
* the user who has to decide whether to set the flag.
|
|
166
|
+
*
|
|
167
|
+
* WHEN A NAME CHECK CANNOT SEE IT, the only check left is the extractor's own —
|
|
168
|
+
* and that claim is now RE-MEASURED on every CI run rather than asserted once
|
|
169
|
+
* (`tests/sidecar/native-extractor-containment.test.js`, 12 escape shapes per
|
|
170
|
+
* strategy). `tar.exe`, `Expand-Archive` and Info-ZIP `unzip` all contain their
|
|
171
|
+
* own escapes; GNU `tar` cannot read a zip at all; `ditto` is the one strategy
|
|
172
|
+
* still unmeasured, and that suite measures it the first time it runs on a Mac.
|
|
173
|
+
* What a failed strategy can still leave behind is ONLY a write to an ABSOLUTE
|
|
174
|
+
* path outside the incoming tree: everything else the rescue writes lives under
|
|
175
|
+
* that tree, which `extractBytesToDist`'s `finally` deletes unconditionally (B2).
|
|
176
|
+
* @param {{central:object, local:object}} seen the two walks' results
|
|
157
177
|
* @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
|
|
158
178
|
*/
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
179
|
+
function hostileName(seen, log) {
|
|
180
|
+
const refusal = seen.central.refusal || seen.local.refusal;
|
|
181
|
+
if (!refusal) { return null; }
|
|
162
182
|
log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
|
|
163
183
|
log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
|
|
164
|
-
log(`[amicus] ${collapseExcerpt(
|
|
184
|
+
log(`[amicus] ${collapseExcerpt(refusal)}`);
|
|
165
185
|
log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
|
|
166
186
|
return Object.assign(
|
|
167
|
-
new Error(`refusing to extract this archive: ${collapseExcerpt(
|
|
187
|
+
new Error(`refusing to extract this archive: ${collapseExcerpt(refusal)}`),
|
|
168
188
|
{ code: 'UNZIP_UNSAFE_ARCHIVE' },
|
|
169
189
|
);
|
|
170
190
|
}
|
|
@@ -200,7 +220,8 @@ function withNativeRescue({
|
|
|
200
220
|
// ...and the one class that IS rescuable is asked what names it declares
|
|
201
221
|
// first, because the exclusion above keys on the refusal yauzl FORMED and
|
|
202
222
|
// an earlier bad entry stops it forming one. See `hostileName`.
|
|
203
|
-
const
|
|
223
|
+
const seen = { central: await scanEntryNames(bytes), local: scanLocalNames(bytes) };
|
|
224
|
+
const hostile = hostileName(seen, log);
|
|
204
225
|
if (hostile) { throw hostile; }
|
|
205
226
|
if (!policy.allowUnverified) {
|
|
206
227
|
// `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
|
|
@@ -214,7 +235,25 @@ function withNativeRescue({
|
|
|
214
235
|
throw err;
|
|
215
236
|
}
|
|
216
237
|
const strategy = nativeRescue({
|
|
217
|
-
bytes,
|
|
238
|
+
bytes,
|
|
239
|
+
dir: o.dir,
|
|
240
|
+
reason: (err && err.message) || '',
|
|
241
|
+
// WHAT THE NOTICE MAY CLAIM. Three states, not two: a real artifact
|
|
242
|
+
// truncated by a few KB has BOTH walks incomplete while the local walk
|
|
243
|
+
// read and cleared every name it found, so `central.read || local.complete`
|
|
244
|
+
// would print "nothing checked its entries" over 73 checked entries.
|
|
245
|
+
// BOTH, NOT EITHER. The two tables carry DIFFERENT names and the two
|
|
246
|
+
// strategies read different ones, so a disjunction cannot mean "every
|
|
247
|
+
// name was checked". MEASURED: a local walk stopped at entry 1 with a
|
|
248
|
+
// readable, benign central directory reported TRUE and printed nothing,
|
|
249
|
+
// while `tar.exe` reached a `../../../` entry only the local table had.
|
|
250
|
+
namesComplete: seen.central.read && seen.local.complete,
|
|
251
|
+
namesChecked: seen.local.names,
|
|
252
|
+
platform,
|
|
253
|
+
fs,
|
|
254
|
+
spawn,
|
|
255
|
+
maxMs,
|
|
256
|
+
log,
|
|
218
257
|
});
|
|
219
258
|
// A rescue that failed leaves the ORIGINAL classified error in flight, so
|
|
220
259
|
// a genuinely bad archive is still evicted exactly as it was before.
|
|
@@ -62,9 +62,26 @@ function offerNativeRescue({ reason, log = () => {} }) {
|
|
|
62
62
|
* instead of reassuring anyone about it — the rescue is not safe, and the words a
|
|
63
63
|
* user reads while it happens have to say so.
|
|
64
64
|
*/
|
|
65
|
-
function announceNativeRescue({
|
|
65
|
+
function announceNativeRescue({
|
|
66
|
+
zip, reason, namesComplete, namesChecked = 0, log = () => {},
|
|
67
|
+
}) {
|
|
66
68
|
log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — running the NATIVE-EXTRACTOR RESCUE.');
|
|
67
69
|
log(`[amicus] amicus could not read the archive itself: ${collapseExcerpt(reason)}`);
|
|
70
|
+
// THREE STATES, NOT TWO, and the middle one is the common one: a real artifact
|
|
71
|
+
// truncated by a few KB leaves both walks incomplete while the local walk still
|
|
72
|
+
// read and cleared every name it reached. Saying "nothing checked its entries"
|
|
73
|
+
// there would be a FALSE disclosure on the shape this rescue exists for.
|
|
74
|
+
// `namesComplete` is undefined-means-no on purpose (see `nativeRescue`).
|
|
75
|
+
if (!namesComplete && namesChecked > 0) {
|
|
76
|
+
log(`[amicus] IT CHECKED ${namesChecked} ENTRY NAMES AND COULD NOT CONFIRM IT SAW THEM ALL:`);
|
|
77
|
+
log('[amicus] the archive stopped amicus part-way through its own tables, so an entry that');
|
|
78
|
+
log('[amicus] writes OUTSIDE dist/ could sit past the point it reached.');
|
|
79
|
+
} else if (!namesComplete) {
|
|
80
|
+
log('[amicus] AND IT COULD NOT READ THE ENTRY NAMES EITHER: neither this archive\'s central');
|
|
81
|
+
log('[amicus] directory nor its local file headers could be walked, so NOTHING checked its');
|
|
82
|
+
log('[amicus] entries for paths that write OUTSIDE dist/. The extractor below is the only');
|
|
83
|
+
log('[amicus] check left.');
|
|
84
|
+
}
|
|
68
85
|
log('[amicus] THE WINDOW THIS OPENS, stated plainly. amicus has written the bytes it hashed to');
|
|
69
86
|
log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
|
|
70
87
|
log('[amicus] and is about to hand that PATH to a native extractor it does not control. Between');
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -46,6 +46,7 @@ const { deriveLegIds } = require('./leg-ids');
|
|
|
46
46
|
* to run this wave's legs on. Both or neither. When supplied this wave never
|
|
47
47
|
* starts a server and never closes one — see the seam comment in step 4.
|
|
48
48
|
* NOT `client`, which is the client TYPE string on this function.)
|
|
49
|
+
* serverAgents? (spec 2026-09-11 §4: agents to register if this wave starts its own server)
|
|
49
50
|
* @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
|
|
50
51
|
*/
|
|
51
52
|
async function runFanout(options) {
|
|
@@ -218,7 +219,12 @@ async function runFanout(options) {
|
|
|
218
219
|
logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
|
|
219
220
|
} else {
|
|
220
221
|
try {
|
|
221
|
-
({ client, server } = await startOpenCodeServer(mcpServers, {
|
|
222
|
+
({ client, server } = await startOpenCodeServer(mcpServers, {
|
|
223
|
+
models: validated.serverModels || okLegs.map(l => l.model),
|
|
224
|
+
// Spec 2026-09-11 §4: a wave that starts its own server still needs the
|
|
225
|
+
// council agents registered on it. Spread-guarded: byte-identical otherwise.
|
|
226
|
+
...(options.serverAgents ? { agents: options.serverAgents } : {}),
|
|
227
|
+
}));
|
|
222
228
|
} catch (err) {
|
|
223
229
|
writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
|
|
224
230
|
return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/sidecar/heartbeat.js
|
|
2
|
+
'use strict';
|
|
3
|
+
// HEARTBEAT_INTERVAL + createHeartbeat — moved verbatim from session-utils.js
|
|
4
|
+
// (size-gate split, spec 2026-09-11 §4 PR 2: that file was already at the
|
|
5
|
+
// 300-line ceiling before this task's additions). Zero behavior change; both
|
|
6
|
+
// re-exported from session-utils.js's existing module.exports, so no caller
|
|
7
|
+
// (start.js, resume.js, continue.js, fanout.js, index.js, and their tests)
|
|
8
|
+
// needs to change — see tests/sidecar/session-utils.test.js and
|
|
9
|
+
// tests/sidecar/start.test.js, which exercise these through that re-export.
|
|
10
|
+
|
|
11
|
+
/** Standard heartbeat interval in milliseconds */
|
|
12
|
+
const HEARTBEAT_INTERVAL = 15000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a heartbeat that writes status to stderr periodically.
|
|
16
|
+
* When sessionDir is provided, includes message count and latest activity.
|
|
17
|
+
*
|
|
18
|
+
* @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
|
|
19
|
+
* @param {string} [sessionDir] - Session directory to read progress from
|
|
20
|
+
* @returns {{ stop: () => void }}
|
|
21
|
+
*/
|
|
22
|
+
function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
const intervalId = setInterval(() => {
|
|
25
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
26
|
+
const mins = Math.floor(elapsed / 60);
|
|
27
|
+
const secs = elapsed % 60;
|
|
28
|
+
const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
|
29
|
+
|
|
30
|
+
if (sessionDir) {
|
|
31
|
+
const { readProgress } = require('./progress');
|
|
32
|
+
const progress = readProgress(sessionDir);
|
|
33
|
+
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
34
|
+
} else {
|
|
35
|
+
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
36
|
+
}
|
|
37
|
+
}, interval);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
stop() {
|
|
41
|
+
clearInterval(intervalId);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { HEARTBEAT_INTERVAL, createHeartbeat };
|
|
@@ -20,8 +20,10 @@ const {
|
|
|
20
20
|
resolveExistingSessionDir
|
|
21
21
|
} = require('../session-manager');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// HEARTBEAT_INTERVAL + createHeartbeat live in ./heartbeat (size-gate split:
|
|
24
|
+
// this file was already at the 300-line ceiling — spec 2026-09-11 §4 PR 2).
|
|
25
|
+
// Re-exported below so no caller changes.
|
|
26
|
+
const { HEARTBEAT_INTERVAL, createHeartbeat } = require('./heartbeat');
|
|
25
27
|
|
|
26
28
|
/** Session path utilities - eliminates magic strings across modules */
|
|
27
29
|
const SessionPaths = {
|
|
@@ -122,38 +124,6 @@ function outputSummary(summary) {
|
|
|
122
124
|
console.log(fenceSidecarOutput(summary));
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
/**
|
|
126
|
-
* Create a heartbeat that writes status to stderr periodically.
|
|
127
|
-
* When sessionDir is provided, includes message count and latest activity.
|
|
128
|
-
*
|
|
129
|
-
* @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
|
|
130
|
-
* @param {string} [sessionDir] - Session directory to read progress from
|
|
131
|
-
* @returns {{ stop: () => void }}
|
|
132
|
-
*/
|
|
133
|
-
function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
134
|
-
const startTime = Date.now();
|
|
135
|
-
const intervalId = setInterval(() => {
|
|
136
|
-
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
137
|
-
const mins = Math.floor(elapsed / 60);
|
|
138
|
-
const secs = elapsed % 60;
|
|
139
|
-
const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
|
140
|
-
|
|
141
|
-
if (sessionDir) {
|
|
142
|
-
const { readProgress } = require('./progress');
|
|
143
|
-
const progress = readProgress(sessionDir);
|
|
144
|
-
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
145
|
-
} else {
|
|
146
|
-
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
147
|
-
}
|
|
148
|
-
}, interval);
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
stop() {
|
|
152
|
-
clearInterval(intervalId);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
127
|
/**
|
|
158
128
|
* Execute sidecar in either headless or interactive mode
|
|
159
129
|
* Consolidates the if/else pattern duplicated across start, resume, continue
|
|
@@ -228,6 +198,7 @@ async function executeMode(options) {
|
|
|
228
198
|
* @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
|
|
229
199
|
* @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
|
|
230
200
|
* @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
|
|
201
|
+
* @param {Object<string, object>} [options.agents] - Extra agents to register (council seat agents)
|
|
231
202
|
* @param {string[]} [options.models] - Resolved executable id(s) actually launched on this
|
|
232
203
|
* server (#61 Task 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout)
|
|
233
204
|
* has no single default `config.model`, so this registers ALL of them in provider.models
|
|
@@ -257,6 +228,8 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
|
|
|
257
228
|
if (options.models) { serverOptions.models = options.models; }
|
|
258
229
|
if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
|
|
259
230
|
if (options.agentName) { serverOptions.agentName = options.agentName; }
|
|
231
|
+
// Spec 2026-09-11 §4: the council's two agents ride into buildServerOptions.
|
|
232
|
+
if (options.agents) { serverOptions.agents = options.agents; }
|
|
260
233
|
// Explicit per-call override only. Unset is the normal case and is correct:
|
|
261
234
|
// buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
|
|
262
235
|
// default downstream, so forwarding `undefined` here would change nothing.
|
|
@@ -40,11 +40,22 @@
|
|
|
40
40
|
* extraction root is REFUSED here and is not by extract-zip, and it is resolved
|
|
41
41
|
* against the REALPATH of the directory the link lands in because the lexical
|
|
42
42
|
* `path.dirname` was measured to be defeated outright by a chain of
|
|
43
|
-
* directory-symlink entries earlier in the same archive.
|
|
44
|
-
*
|
|
45
|
-
* is the only electron artifact with real
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* directory-symlink entries earlier in the same archive. It is refused in the
|
|
44
|
+
* same `Out of bound path` wording and exercised against synthetic archives
|
|
45
|
+
* here; the darwin `.app` bundle is the only electron artifact with real
|
|
46
|
+
* symlinks, and since v4.9.7 `.github/workflows/darwin-bundle.yml` runs this
|
|
47
|
+
* path over the REAL artifact on a real Mac. The v4.9.6 worry that the check
|
|
48
|
+
* might REJECT a working layout is refuted by measurement: the real
|
|
49
|
+
* `electron-v43.1.1-darwin-arm64.zip` declares 585 records and 14 symlinks,
|
|
50
|
+
* every target relative, none carrying a `..` component, none absolute, and 0
|
|
51
|
+
* of the 585 entry names traversing a symlinked component. The linux artifacts
|
|
52
|
+
* hold ZERO symlink entries, so `writeSymlink` is unreachable there at all.
|
|
53
|
+
*
|
|
54
|
+
* `root = fs.realpathSync(dir)` below is load-bearing for that answer and no
|
|
55
|
+
* Windows probe would ever show it: on macOS the extraction root usually sits
|
|
56
|
+
* under `/var`, which is itself a symlink to `/private/var`, so comparing a
|
|
57
|
+
* resolved target against an UNRESOLVED root would read every link in a real
|
|
58
|
+
* `.app` as an escape.
|
|
48
59
|
*
|
|
49
60
|
* ── ERROR CODES ARE A CAUSAL CLAIM ───────────────────────────────────────
|
|
50
61
|
* `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad. `UNZIP_DEST_FAILED` = the
|