amicus 1.7.3 → 1.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.5] - 2026-07-01
9
+
10
+ A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
11
+
12
+ ### Fixed
13
+ - **Long prompts no longer truncate on Windows.** The `amicus_start` and `amicus_continue` MCP
14
+ handlers passed the full prompt inline on the spawned command line, which silently truncated once
15
+ it crossed Windows's ~32 KB argument cap — so a sidecar could run against a corrupted briefing with
16
+ no error. Both paths now write the prompt to a `briefing.md` in the session directory and pass
17
+ `--prompt-file`, matching the existing fanout handler; the CLI `continue` command learned
18
+ `--prompt-file` as well.
19
+ - **`getMessages` no longer masks SDK error responses.** An error-shaped response with no `data`
20
+ array was indistinguishable from "zero messages" in the poll loop; it now logs a warning carrying
21
+ the session id and surfaced error while still returning `[]`.
22
+ - **`getSessionDir` rejects path-traversal task ids.** A defense-in-depth containment guard (the same
23
+ check style used elsewhere in the codebase) throws on a task id that would escape the sessions dir.
24
+ - **Cross-platform `auth.json` discovery.** The one-time OpenCode key-import path was hardcoded to the
25
+ Unix XDG location; it now probes `$XDG_DATA_HOME`, `~/.local/share`, and `%APPDATA%` (Windows) and
26
+ uses the first that exists.
27
+
28
+ ### Changed
29
+ - **The fold-completion marker is harder to spoof.** A bare `[SIDECAR_FOLD]` echoed mid-output (e.g. a
30
+ model reproducing these instructions or summarizing a prior sidecar session) no longer forces a
31
+ premature fold — the marker now completes a run only when it is the final non-empty line of output,
32
+ with the existing idle/timeout fallbacks unchanged so a run can never hang.
33
+ - **The conversation-mirror tool-call buffer is bounded.** Capped at 2000 entries with a separate
34
+ dedup set, so a very long tool-heavy session can't grow it without limit.
35
+ - **Unknown `--no-*` flags are treated as boolean.** They no longer swallow the following positional
36
+ argument (`--no-x=value` still records its inline value; allowlisted flags are unchanged).
37
+ - **`--prompt-file` validation is order-independent.** `validateStartArgs` now resolves the prompt
38
+ source itself, so validation no longer depends on the handler having resolved it first.
39
+
40
+ ### Docs
41
+ - **Corrected the `tiktoken` dependency note.** It is declared but unused; token sizing uses a
42
+ `length/4` heuristic. Added caveat comments at both estimators. (Removing the unused dependency is
43
+ tracked as a follow-up.)
44
+
45
+ ## [1.7.4] - 2026-06-30
46
+
47
+ ### Fixed
48
+ - **The Electron GUI self-heal survives a stalled `extract-zip` on Node 24.** On some Node 24 boxes the
49
+ bundled `extract-zip@2.0.1` (its latest release — it cannot be bumped) stalls mid-extract: its promise
50
+ never resolves *and* never rejects. Because the self-heal `await`s it, the event loop drains and the
51
+ process exits `0` with a half-extracted `dist/` and **no `electron.exe`** — so the repair looked like it
52
+ "did nothing." Extraction is now hardened two ways: `extract-zip` is bounded by an idle + max timer (a
53
+ stall becomes a caught error instead of a silent hang, and the live timer prevents the premature exit),
54
+ and if it stalls, throws, or produces no files, amicus falls back to a **native OS unzip** (Windows:
55
+ bundled `bsdtar`, then PowerShell `Expand-Archive`; macOS: `ditto`, then `unzip`; Linux: `unzip`, then
56
+ `tar`) — each verified to extract the exact Electron zip that `extract-zip` choked on. Success is still
57
+ reported **only** when the real binary lands on disk (the existing exe-stat verify is unchanged), so no
58
+ path can claim a false repair.
59
+
8
60
  ## [1.7.3] - 2026-06-30
9
61
 
10
62
  ### Fixed
package/bin/amicus.js CHANGED
@@ -215,6 +215,19 @@ async function handleContinue(args) {
215
215
  process.exit(1);
216
216
  }
217
217
 
218
+ // BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
219
+ // follow-up prompt via file, dodging the ~32KB Windows command-line cap.
220
+ if (args['prompt-file'] !== undefined) {
221
+ const { resolvePromptSource } = require('../src/utils/prompt-source');
222
+ const promptRes = resolvePromptSource(args);
223
+ if (promptRes.error) {
224
+ console.error(promptRes.error);
225
+ process.exit(1);
226
+ }
227
+ args.prompt = promptRes.prompt;
228
+ delete args['prompt-file'];
229
+ }
230
+
218
231
  if (!args.prompt && !args.briefing) {
219
232
  console.error('Error: --prompt is required for continue');
220
233
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
@@ -28,6 +28,10 @@ async function handleStart(args) {
28
28
  const promptRes = resolvePromptSource(args);
29
29
  if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
30
30
  args.prompt = promptRes.prompt;
31
+ // Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
32
+ // guard would otherwise re-run resolvePromptSource with both prompt and
33
+ // prompt-file set and trip its mutually-exclusive branch.
34
+ delete args['prompt-file'];
31
35
  }
32
36
  if (args.json && !args['no-ui']) {
33
37
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  validateApiKey,
17
17
  validateThinkingLevel
18
18
  } = require('./utils/validators');
19
+ const { resolvePromptSource } = require('./utils/prompt-source');
19
20
  const { logger } = require('./utils/logger');
20
21
 
21
22
  /**
@@ -78,6 +79,14 @@ function parseArgs(argv) {
78
79
  continue;
79
80
  }
80
81
 
82
+ // Unknown negation flags: known --no-* flags are already handled by
83
+ // isBooleanFlag() above; treat any *unregistered* --no-* token as a
84
+ // boolean so it can never swallow the following positional as a value.
85
+ if (key.startsWith('no-')) {
86
+ result[key] = true;
87
+ continue;
88
+ }
89
+
81
90
  // Options with values
82
91
  if (next && !next.startsWith('--')) {
83
92
  result[key] = parseValue(key, next);
@@ -153,6 +162,19 @@ function parseValue(key, value) {
153
162
  * @returns {{ valid: boolean, error?: string }}
154
163
  */
155
164
  function validateStartArgs(args) {
165
+ // Resolve the prompt source (--prompt XOR --prompt-file) here so validation
166
+ // is self-contained and order-independent: a caller need not have run
167
+ // resolvePromptSource() first. Skipped when args.prompt is already a plain
168
+ // string (the classic path / already-resolved by handleStart), so the empty
169
+ // '' case still falls through to the presence/content checks below.
170
+ if (args['prompt-file'] !== undefined || args.prompt === undefined || args.prompt === true) {
171
+ const res = resolvePromptSource(args);
172
+ if (res.error) {
173
+ return { valid: false, code: 'MISSING_PROMPT', error: res.error };
174
+ }
175
+ args.prompt = res.prompt;
176
+ }
177
+
156
178
  // Required: --prompt (presence check)
157
179
  if (!args.prompt) {
158
180
  return { valid: false, error: 'Error: --prompt is required' };
@@ -28,6 +28,8 @@ function estimateTokenCount(text) {
28
28
  return 0;
29
29
  }
30
30
 
31
+ // Intentional cheap heuristic — not a real BPE tokenizer; under/over-counts
32
+ // for CJK, code, and punctuation-dense text. (ceil, vs floor in context.js.)
31
33
  return Math.ceil(text.length / 4);
32
34
  }
33
35
 
package/src/context.js CHANGED
@@ -52,7 +52,8 @@ function estimateTokens(text) {
52
52
  if (!text || typeof text !== 'string') {
53
53
  return 0;
54
54
  }
55
- // Spec specifies ~4 chars per token
55
+ // Intentional cheap heuristic (spec §5.3: ~4 chars/token). Not a real BPE
56
+ // tokenizer — under/over-counts for CJK, code, and punctuation-dense text.
56
57
  return Math.floor(text.length / 4);
57
58
  }
58
59
 
package/src/headless.js CHANGED
@@ -21,6 +21,27 @@ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/con
21
21
  const FOLD_MARKER = '[SIDECAR_FOLD]';
22
22
  const COMPLETE_MARKER = FOLD_MARKER; // backward compat
23
23
 
24
+ /**
25
+ * #BL-7: the fold marker is the fixed public string [SIDECAR_FOLD]. A model can
26
+ * legitimately emit it on its own line mid-output — summarizing a prior sidecar,
27
+ * reproducing these instructions, or from scraped content — which used to force a
28
+ * PREMATURE fold. Harden by requiring the marker to be the FINAL non-empty line
29
+ * of the output: a standalone marker followed by MORE content is treated as
30
+ * echoed prose, not a completion signal. Only the true trailing marker folds.
31
+ *
32
+ * @param {string} output - Accumulated assistant output
33
+ * @returns {number} char index where the trailing marker line begins, or -1
34
+ */
35
+ function findTrailingFoldMarker(output) {
36
+ if (!output) { return -1; }
37
+ // The marker must be the last non-empty line: it sits alone on its line
38
+ // (only intra-line whitespace around it) and NOTHING but whitespace follows
39
+ // to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
40
+ // end — a bare marker followed by more prose is echoed content, not a signal.
41
+ const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
42
+ return m ? m.index : -1;
43
+ }
44
+
24
45
  /**
25
46
  * Default timeout: 15 minutes per spec §6.2
26
47
  */
@@ -399,10 +420,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
399
420
  elapsed: Date.now() - startTime
400
421
  });
401
422
 
402
- // Check for completion marker on its own line (not inline in prose).
403
- // Models may mention [SIDECAR_FOLD] when describing code only treat
404
- // it as a signal when it appears as a standalone line.
405
- if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(mirror.output)) {
423
+ // Check for the completion marker as the FINAL non-empty line (#BL-7).
424
+ // Models may emit [SIDECAR_FOLD] on its own line mid-output (echoing a
425
+ // prior sidecar, these instructions, or scraped content) only treat
426
+ // it as a completion signal when nothing but blank lines follow it.
427
+ if (findTrailingFoldMarker(mirror.output) !== -1) {
406
428
  completed = true;
407
429
  break;
408
430
  }
@@ -608,7 +630,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
608
630
  }
609
631
 
610
632
  /**
611
- * Extract summary from output (everything before [SIDECAR_FOLD])
633
+ * Extract summary from output (everything before the trailing [SIDECAR_FOLD])
612
634
  * Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
613
635
  *
614
636
  * @param {string} output - Raw output from OpenCode
@@ -619,13 +641,13 @@ function extractSummary(output) {
619
641
  return '';
620
642
  }
621
643
 
622
- // Split on the fold marker only when it appears on its own line.
623
- // Models may mention [SIDECAR_FOLD] inline when describing code
624
- // only treat it as a delimiter when standalone.
625
- const markerRegex = /^\s*\[SIDECAR_FOLD\]\s*$/m;
626
- const match = output.match(markerRegex);
627
- if (match) {
628
- return output.slice(0, match.index).trim();
644
+ // Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
645
+ // A [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
646
+ // instructions, or from scraped content) is NOT a delimiter keep it as
647
+ // content. Only the true trailing marker is stripped.
648
+ const idx = findTrailingFoldMarker(output);
649
+ if (idx !== -1) {
650
+ return output.slice(0, idx).trim();
629
651
  }
630
652
  return output.trim();
631
653
  }
@@ -659,6 +681,7 @@ module.exports = {
659
681
  waitForServer,
660
682
  withTimeout,
661
683
  extractSummary,
684
+ findTrailingFoldMarker,
662
685
  formatFoldOutput,
663
686
  DEFAULT_TIMEOUT,
664
687
  FOLD_MARKER,
package/src/mcp-server.js CHANGED
@@ -211,7 +211,17 @@ const handlers = {
211
211
  const { generateTaskId } = require('./sidecar/start');
212
212
  const taskId = generateTaskId();
213
213
 
214
- const args = ['start', '--prompt', input.prompt, '--task-id', taskId, '--client', 'cowork'];
214
+ // New session canonical amicus dir (writes).
215
+ const sessionDir = getSessionDir(cwd, taskId);
216
+
217
+ // BL-1: the prompt goes via file, not inline. A long prompt passed as a CLI
218
+ // arg silently truncates/corrupts on Windows (~32KB command-line cap). Mirror
219
+ // the amicus_fanout briefing-file pattern; the spawn command line must NOT
220
+ // carry the prompt. --prompt-file is resolved by handleStart/resolvePromptSource.
221
+ // The file itself is written just before the spawn fallback below (the
222
+ // shared-server path passes the prompt in-process and never reads args).
223
+ const briefingPath = path.join(sessionDir, 'briefing.md');
224
+ const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', 'cowork'];
215
225
  if (resolvedModel) { args.push('--model', resolvedModel); }
216
226
  const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
217
227
  ? 'build' : input.agent;
@@ -229,9 +239,6 @@ const handlers = {
229
239
  if (input.windowPosition) { args.push('--position', input.windowPosition); }
230
240
  args.push('--cwd', cwd);
231
241
 
232
- // New session → canonical amicus dir (writes).
233
- const sessionDir = getSessionDir(cwd, taskId);
234
-
235
242
  if (sharedServer.enabled && input.noUi) {
236
243
  // Shared server path: headless only, delegates to runHeadless()
237
244
  let sessionId;
@@ -349,14 +356,20 @@ const handlers = {
349
356
  }
350
357
  }
351
358
 
352
- // Feature flag disabled (or shared server failed): fall back to per-process spawn
359
+ // Feature flag disabled (or shared server failed): fall back to per-process spawn.
360
+ // BL-1: create the session dir and write the prompt to briefing.md BEFORE the
361
+ // spawn so --prompt-file (built above) resolves to a real file, keeping the
362
+ // full prompt off the ~32KB-capped Windows command line.
353
363
  let child;
354
- try { child = spawnSidecarProcess(args, sessionDir); } catch (err) {
364
+ try {
365
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
366
+ fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
367
+ child = spawnSidecarProcess(args, sessionDir);
368
+ } catch (err) {
355
369
  return textResult(`Failed to start Amicus: ${err.message}`, true);
356
370
  }
357
371
 
358
372
  if (child && child.pid) {
359
- fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
360
373
  recordSession(taskId, cwd); // #40: global index for cross-project lookup
361
374
  const metaPath = path.join(sessionDir, 'metadata.json');
362
375
  if (!fs.existsSync(metaPath)) {
@@ -631,14 +644,22 @@ const handlers = {
631
644
  // New continuation session → canonical amicus dir (writes).
632
645
  const sessionDir = getSessionDir(cwd, newTaskId);
633
646
 
634
- const args = ['continue', input.taskId, '--prompt', input.prompt,
647
+ // BL-1: the follow-up prompt goes via file, not inline, so a long prompt is
648
+ // never truncated by the ~32KB Windows command-line cap. handleContinue reads
649
+ // --prompt-file. The briefing is written into the NEW session dir below.
650
+ const briefingPath = path.join(sessionDir, 'briefing.md');
651
+ const args = ['continue', input.taskId, '--prompt-file', briefingPath,
635
652
  '--task-id', newTaskId, '--client', 'cowork', '--cwd', cwd];
636
653
  if (input.model) { args.push('--model', input.model); }
637
654
  if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
638
655
  if (input.timeout) { args.push('--timeout', String(input.timeout)); }
639
656
  if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
640
657
  if (input.contextMaxTokens) { args.push('--context-max-tokens', String(input.contextMaxTokens)); }
641
- try { spawnSidecarProcess(args, sessionDir); } catch (err) {
658
+ try {
659
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
660
+ fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
661
+ spawnSidecarProcess(args, sessionDir);
662
+ } catch (err) {
642
663
  return textResult(`Failed to continue: ${err.message}`, true);
643
664
  }
644
665
  recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
@@ -261,7 +261,21 @@ async function getMessages(client, sessionId, directory) {
261
261
  ...directoryQuery(directory)
262
262
  });
263
263
 
264
- return result.data || [];
264
+ // A well-formed response carries a data array (possibly empty). If it's
265
+ // absent, the SDK returned an error shape (e.g. a 5xx with { error }) that
266
+ // would otherwise be indistinguishable from "0 messages" — surface it so the
267
+ // poll loop's diagnostics aren't blind. Return contract unchanged: an array.
268
+ if (!Array.isArray(result.data)) {
269
+ const { logger } = require('./utils/logger');
270
+ logger.warn('getMessages: SDK response had no data array', {
271
+ sessionId,
272
+ status: (result.response && result.response.status) || (result.error && result.error.status),
273
+ error: result.error && (result.error.message || JSON.stringify(result.error))
274
+ });
275
+ return [];
276
+ }
277
+
278
+ return result.data;
265
279
  }
266
280
 
267
281
  /**
@@ -37,7 +37,15 @@ const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
37
37
  * // Returns: '/path/to/project/.claude/amicus_sessions/abc123'
38
38
  */
39
39
  function getSessionDir(projectDir, taskId) {
40
- return path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
40
+ const sessionDir = path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
41
+ // Defense-in-depth: reject a taskId that would escape the sessions dir
42
+ // (path separators / '..'). Callers pre-validate today; this is a backstop.
43
+ const base = path.resolve(projectDir, '.claude', SESSIONS_DIR);
44
+ const resolved = path.resolve(sessionDir);
45
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
46
+ throw new Error('Invalid task ID: path traversal detected');
47
+ }
48
+ return sessionDir;
41
49
  }
42
50
 
43
51
  /**
@@ -9,11 +9,20 @@
9
9
  * except the injectable `now`.
10
10
  */
11
11
 
12
+ // Bound the unbounded toolCalls accumulator (BL-4). This array holds {id,name,input}
13
+ // objects whose `input` can be large; it is the only mirror-state member that grows
14
+ // per tool call with no natural bound and carries heavy payloads. Keep the most recent
15
+ // N, dropping the oldest. Dedup identity lives in the separate seenToolCallIds Set so
16
+ // dropping an old array entry never causes a re-append or a spurious toolCalls.length
17
+ // bump in the headless idle detector.
18
+ const MAX_TOOL_CALLS = 2000;
19
+
12
20
  /** Fresh cursor for a session's mirror. */
13
21
  function createMirrorState() {
14
22
  return {
15
23
  seenTextParts: new Map(), // partId -> last captured text length
16
- toolCalls: [], // [{id,name,input}]
24
+ toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
25
+ seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
17
26
  seenToolResultIds: new Set(),
18
27
  receivingReported: false,
19
28
  output: '', // accumulated assistant text
@@ -75,9 +84,13 @@ function mirrorMessages(messages, state, opts = {}) {
75
84
  progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
76
85
  }
77
86
  }
78
- } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.toolCalls.find(t => t.id === part.id)) {
87
+ } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.seenToolCallIds.has(part.id)) {
79
88
  const toolCall = { id: part.id, name: part.name, input: part.input };
89
+ state.seenToolCallIds.add(part.id);
80
90
  state.toolCalls.push(toolCall);
91
+ // Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
92
+ // Set above, so dropping the oldest here never causes a re-append.
93
+ if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
81
94
  appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
82
95
 
83
96
  // Update progress on tool_use detection
@@ -27,6 +27,12 @@ const { spawnSync } = require('child_process');
27
27
  const { resolveCacheRoots } = require('./electron-cache');
28
28
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
29
  const { acquireRepairLock } = require('./electron-lock');
30
+ const { robustExtract } = require('./unzip');
31
+
32
+ /** Self-heal progress line to stderr (visible during first-GUI provision). */
33
+ function stderrLog(msg) {
34
+ try { process.stderr.write(`${msg}\n`); } catch { /* stderr closed */ }
35
+ }
30
36
 
31
37
  /** Default on-disk location of the installed electron package. */
32
38
  function defaultElectronDir() {
@@ -199,7 +205,9 @@ async function repairElectron({
199
205
  deps = {},
200
206
  } = {}) {
201
207
  const fs = deps.fs || fsDefault;
202
- const extract = deps.extract || require('extract-zip');
208
+ // Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
209
+ const extract = deps.extract
210
+ || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
203
211
  // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
204
212
  // provision that reaches runInstaller without an explicit timeoutMs can't hang
205
213
  // the holder — the caller's timeoutMs still wins when provided.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24).
3
+ *
4
+ * FIELD BUG: on some Node 24 boxes `extract-zip@2.0.1` STALLS mid-extract — its
5
+ * promise never resolves AND never rejects. Because the self-heal `await`s it,
6
+ * when the event loop drains Node exits 0 with a partial extract and no
7
+ * electron.exe, so `repairElectron` silently no-ops. extract-zip 2.0.1 is the
8
+ * LATEST published release, so "just bump it" is impossible.
9
+ *
10
+ * robustExtract() survives that with three independent layers, none of which
11
+ * can report a false success:
12
+ * 1. BOUND extract-zip with an idle timer (reset on each onEntry) + a hard max
13
+ * timer, so a stall becomes a catchable outcome — and the live timer keeps
14
+ * the event loop alive so the process can't exit 0 before we fall back.
15
+ * 2. FALL BACK to a native OS unzip (tar / Expand-Archive on Windows,
16
+ * ditto / unzip on macOS, unzip / tar on Linux) — each confirmed to extract
17
+ * the exact electron zip the field box choked on.
18
+ * 3. Only report success when files actually landed on disk. The electron
19
+ * exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
20
+ *
21
+ * Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
22
+ * hit the real clock, spawn a real process, or extract a real binary.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const path = require('path');
28
+ const fsDefault = require('fs');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ // No-progress window: if extract-zip reports no new entry for this long AND has
32
+ // not settled, treat it as the silent stall. Reset on every onEntry so a slow-
33
+ // but-progressing extract is never falsely aborted.
34
+ const IDLE_MS = 30_000;
35
+ // Hard cap so a "drips one entry forever" pathology can't run unbounded.
36
+ const MAX_MS = 240_000;
37
+
38
+ /** PowerShell single-quoted string literal, injection-safe (double any quote). */
39
+ function psQuote(s) {
40
+ return `'${String(s).replace(/'/g, "''")}'`;
41
+ }
42
+
43
+ /**
44
+ * Native OS unzip strategies, tried in order per platform. Each writes the
45
+ * zip's entries at the ROOT of `dir` — the SAME on-disk layout extract-zip
46
+ * produces (electron.exe, resources/, locales/, ...). Confirmed on the field
47
+ * box (Expand-Archive) and locally (tar/bsdtar + Expand-Archive, both <1s).
48
+ * @returns {Array<{name:string, cmd:string, args:string[]}>}
49
+ */
50
+ function nativeUnzipPlan(zip, dir, platform = process.platform) {
51
+ if (platform === 'win32') {
52
+ // ABSOLUTE path to System32 bsdtar (Win10 1803+/11). A bare "tar" resolves
53
+ // to GNU tar when git-bash/MSYS is on PATH — GNU tar reads "C:\..." as a
54
+ // remote host ("Cannot connect to C:") and can't read zips at all. path.win32
55
+ // keeps this a valid Windows path even when the plan is built off-Windows.
56
+ const winTar = path.win32.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe');
57
+ return [
58
+ // bsdtar — fast, auto-detects zip format. If absent (rare/WOW64), the
59
+ // spawn errors ENOENT and we fall through to Expand-Archive below.
60
+ { name: 'tar', cmd: winTar, args: ['-xf', zip, '-C', dir] },
61
+ // Universal Windows fallback; silence progress so stdio:'ignore' is clean.
62
+ {
63
+ name: 'Expand-Archive',
64
+ cmd: 'powershell',
65
+ args: ['-NoProfile', '-NonInteractive', '-Command',
66
+ `$ProgressPreference='SilentlyContinue'; Expand-Archive -LiteralPath ${psQuote(zip)} -DestinationPath ${psQuote(dir)} -Force`],
67
+ },
68
+ ];
69
+ }
70
+ if (platform === 'darwin') {
71
+ return [
72
+ { name: 'ditto', cmd: 'ditto', args: ['-x', '-k', zip, dir] },
73
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
74
+ ];
75
+ }
76
+ return [
77
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
78
+ { name: 'tar', cmd: 'tar', args: ['-xf', zip, '-C', dir] },
79
+ ];
80
+ }
81
+
82
+ /** True if `dir` exists and holds at least one entry. */
83
+ function dirNonEmpty(fs, dir) {
84
+ try {
85
+ return fs.readdirSync(dir).length > 0;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Remove everything inside `dir` (best-effort) so the next strategy starts clean. */
92
+ function cleanDir(fs, dir) {
93
+ try {
94
+ for (const entry of fs.readdirSync(dir)) {
95
+ fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
96
+ }
97
+ } catch {
98
+ /* best-effort */
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run extract-zip bounded by an idle timer (reset on each onEntry) and a hard
104
+ * max timer. NEVER rejects — resolves {ok:true} on completion, {ok:false,reason}
105
+ * on stall/throw. The timers keep the event loop alive so a stalled extract
106
+ * can't let the process exit 0 before we fall back. NOTE: a stalled extract-zip
107
+ * promise is abandoned (2.0.1 has no cancel API); it holds a fd until the short-
108
+ * lived process exits — acceptable versus a wedged, no-op self-heal.
109
+ */
110
+ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer }) {
111
+ return new Promise((resolve) => {
112
+ let settled = false;
113
+ let idleTimer = null;
114
+ let maxTimer = null;
115
+ const done = (val) => {
116
+ if (settled) { return; }
117
+ settled = true;
118
+ if (idleTimer !== null) { clearTimer(idleTimer); }
119
+ if (maxTimer !== null) { clearTimer(maxTimer); }
120
+ resolve(val);
121
+ };
122
+ const armIdle = () => {
123
+ if (idleTimer !== null) { clearTimer(idleTimer); }
124
+ idleTimer = setTimer(() => done({ ok: false, reason: `stalled: no extract progress for ${idleMs}ms` }), idleMs);
125
+ };
126
+ maxTimer = setTimer(() => done({ ok: false, reason: `stalled: exceeded ${maxMs}ms` }), maxMs);
127
+ armIdle();
128
+ try {
129
+ const result = extractZip(zip, {
130
+ dir,
131
+ onEntry: (entry, zipfile) => {
132
+ armIdle(); // progress → restart the idle window
133
+ if (onEntry) {
134
+ try { onEntry(entry, zipfile); } catch { /* caller onEntry must not break extraction */ }
135
+ }
136
+ },
137
+ });
138
+ Promise.resolve(result).then(
139
+ () => done({ ok: true }),
140
+ (e) => done({ ok: false, reason: (e && e.message) || 'extract-zip threw' }),
141
+ );
142
+ } catch (e) {
143
+ done({ ok: false, reason: (e && e.message) || 'extract-zip threw synchronously' });
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Extract `zip` into `dir`, surviving a stalled or broken extract-zip.
150
+ *
151
+ * CONTRACT: a returned {strategy} means files LANDED in `dir` — NOT that any
152
+ * specific payload (e.g. electron.exe) is present. Callers needing a usable
153
+ * binary MUST still stat it (electron-quarantine.verifyExtractOutcome does).
154
+ *
155
+ * @param {string} zip absolute path to the .zip
156
+ * @param {object} opts
157
+ * @param {string} opts.dir destination dir (created if absent)
158
+ * @param {function} [opts.onEntry] forwarded to extract-zip's onEntry
159
+ * @param {string} [opts.platform] override process.platform (native plan)
160
+ * @param {number} [opts.idleMs] no-progress window before treating as stalled
161
+ * @param {number} [opts.maxMs] hard cap for both extract-zip and each spawn
162
+ * @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
163
+ * @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
164
+ * @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
165
+ */
166
+ async function robustExtract(zip, opts = {}) {
167
+ const {
168
+ dir,
169
+ onEntry,
170
+ platform = process.platform,
171
+ idleMs = IDLE_MS,
172
+ maxMs = MAX_MS,
173
+ deps = {},
174
+ } = opts;
175
+ const fs = deps.fs || fsDefault;
176
+ const extractZip = deps.extractZip || require('extract-zip');
177
+ const spawn = deps.spawn || spawnSync;
178
+ const setTimer = deps.setTimeout || setTimeout;
179
+ const clearTimer = deps.clearTimeout || clearTimeout;
180
+ const log = deps.log || (() => {});
181
+
182
+ fs.mkdirSync(dir, { recursive: true });
183
+
184
+ // Strategy 1: extract-zip, bounded. Trust it only if it RESOLVED and files landed.
185
+ const z = await runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer });
186
+ if (z.ok && dirNonEmpty(fs, dir)) {
187
+ return { strategy: 'extract-zip' };
188
+ }
189
+
190
+ // extract-zip stalled / threw / produced nothing → clean partial output, go native.
191
+ const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
192
+ cleanDir(fs, dir);
193
+ log(`[amicus] extract-zip did not complete (${zipReason}); falling back to native unzip.`);
194
+
195
+ const failures = [];
196
+ for (const strat of nativeUnzipPlan(zip, dir, platform)) {
197
+ let res;
198
+ try {
199
+ res = spawn(strat.cmd, strat.args, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
200
+ } catch (e) {
201
+ failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
202
+ continue;
203
+ }
204
+ // A spawn error OR an external signal-kill (status:null, e.g. SIGKILL/OOM,
205
+ // possibly leaving partial files) is a FAILURE — never trust dirNonEmpty here.
206
+ if (res && (res.error || res.signal)) {
207
+ failures.push(`${strat.name}: ${res.error ? (res.error.code || res.error.message) : `killed by ${res.signal}`}`);
208
+ cleanDir(fs, dir);
209
+ continue;
210
+ }
211
+ if (res && typeof res.status === 'number' && res.status !== 0) {
212
+ failures.push(`${strat.name}: exit ${res.status}`);
213
+ cleanDir(fs, dir);
214
+ continue;
215
+ }
216
+ if (dirNonEmpty(fs, dir)) {
217
+ log(`[amicus] recovered via native unzip (${strat.name}).`);
218
+ return { strategy: strat.name, fallback: true, extractZipReason: zipReason };
219
+ }
220
+ failures.push(`${strat.name}: produced no files`);
221
+ cleanDir(fs, dir);
222
+ }
223
+
224
+ const err = new Error(
225
+ `unzip failed for ${zip} (extract-zip: ${zipReason}; native: ${failures.join('; ') || 'no native strategy available'})`,
226
+ );
227
+ err.code = 'UNZIP_ALL_FAILED';
228
+ throw err;
229
+ }
230
+
231
+ module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS };
@@ -11,7 +11,43 @@ const os = require('os');
11
11
  const path = require('path');
12
12
  const { logger } = require('./logger');
13
13
 
14
- const AUTH_JSON_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
14
+ /**
15
+ * Ordered, de-duplicated candidate locations for OpenCode's auth.json, most
16
+ * specific first. OpenCode uses XDG-style data dirs; on Windows it still writes
17
+ * to ~/.local/share/opencode (verified), so that path stays FIRST after XDG.
18
+ * Mirrors the cross-platform precedence pattern in src/sidecar/electron-cache.js.
19
+ * @param {NodeJS.ProcessEnv} [env] - Environment (injectable for tests)
20
+ * @returns {string[]}
21
+ */
22
+ function authJsonCandidates(env = process.env) {
23
+ const home = os.homedir();
24
+ const candidates = [];
25
+ if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'auth.json')); }
26
+ candidates.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
27
+ if (process.platform === 'win32') {
28
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
29
+ candidates.push(path.join(appData, 'opencode', 'auth.json'));
30
+ }
31
+ return [...new Set(candidates)];
32
+ }
33
+
34
+ /**
35
+ * Resolve the auth.json path to use: first existing candidate, else the primary
36
+ * (~/.local/share) path so callers/writers have a stable default.
37
+ * @param {NodeJS.ProcessEnv} [env]
38
+ * @returns {string}
39
+ */
40
+ function resolveAuthJsonPath(env = process.env) {
41
+ const candidates = authJsonCandidates(env);
42
+ for (const c of candidates) {
43
+ if (fs.existsSync(c)) { return c; }
44
+ }
45
+ const localShare = path.join('.local', 'share');
46
+ return candidates.find((c) => c.includes(localShare)) || candidates[0];
47
+ }
48
+
49
+ // Backward-compat export: the resolved path at module load time.
50
+ const AUTH_JSON_PATH = resolveAuthJsonPath();
15
51
 
16
52
  /** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
17
53
  const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
@@ -37,10 +73,11 @@ function extractKey(entry) {
37
73
  * @returns {Object<string, string>} Map of provider -> key string (only providers with keys)
38
74
  */
39
75
  function readAuthJsonKeys() {
40
- if (!fs.existsSync(AUTH_JSON_PATH)) { return {}; }
76
+ const authPath = resolveAuthJsonPath();
77
+ if (!fs.existsSync(authPath)) { return {}; }
41
78
  let parsed;
42
79
  try {
43
- parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
80
+ parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
44
81
  } catch (_err) {
45
82
  logger.debug('auth.json is malformed, skipping import');
46
83
  return {};
@@ -89,11 +126,12 @@ function checkAuthJson(provider) {
89
126
  */
90
127
  function removeFromAuthJson(provider) {
91
128
  try {
92
- if (!fs.existsSync(AUTH_JSON_PATH)) { return; }
93
- const parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
129
+ const authPath = resolveAuthJsonPath();
130
+ if (!fs.existsSync(authPath)) { return; }
131
+ const parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
94
132
  if (!parsed[provider]) { return; }
95
133
  delete parsed[provider];
96
- fs.writeFileSync(AUTH_JSON_PATH, JSON.stringify(parsed, null, 2), 'utf-8');
134
+ fs.writeFileSync(authPath, JSON.stringify(parsed, null, 2), 'utf-8');
97
135
  } catch (_err) {
98
136
  logger.debug('Failed to remove provider from auth.json', { provider });
99
137
  }
@@ -105,5 +143,7 @@ module.exports = {
105
143
  checkAuthJson,
106
144
  removeFromAuthJson,
107
145
  AUTH_JSON_PATH,
108
- KNOWN_PROVIDERS
146
+ KNOWN_PROVIDERS,
147
+ resolveAuthJsonPath,
148
+ authJsonCandidates
109
149
  };