@evomap/evolver 1.87.2 → 1.87.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.ja-JP.md +1 -1
  2. package/README.ko-KR.md +1 -1
  3. package/README.md +9 -8
  4. package/README.zh-CN.md +9 -8
  5. package/package.json +1 -1
  6. package/scripts/build_binaries.js +31 -7
  7. package/src/adapters/scripts/_runtimePaths.js +178 -1
  8. package/src/adapters/scripts/evolver-session-end.js +63 -33
  9. package/src/adapters/scripts/evolver-session-start.js +127 -43
  10. package/src/atp/atpExecute.js +35 -8
  11. package/src/atp/autoBuyer.js +71 -16
  12. package/src/atp/autoDeliver.js +16 -0
  13. package/src/atp/cliAutobuyPrompt.js +8 -22
  14. package/src/atp/hubClient.js +42 -4
  15. package/src/evolve/guards.js +1 -1
  16. package/src/evolve/pipeline/collect.js +1 -1
  17. package/src/evolve/pipeline/dispatch.js +1 -1
  18. package/src/evolve/pipeline/enrich.js +1 -1
  19. package/src/evolve/pipeline/hub.js +1 -1
  20. package/src/evolve/pipeline/select.js +1 -1
  21. package/src/evolve/pipeline/signals.js +1 -1
  22. package/src/evolve/utils.js +1 -1
  23. package/src/evolve.js +1 -1
  24. package/src/gep/a2aProtocol.js +1 -1
  25. package/src/gep/assetStore.js +52 -5
  26. package/src/gep/candidateEval.js +1 -1
  27. package/src/gep/candidates.js +1 -1
  28. package/src/gep/contentHash.js +1 -1
  29. package/src/gep/crypto.js +1 -1
  30. package/src/gep/curriculum.js +1 -1
  31. package/src/gep/deviceId.js +1 -1
  32. package/src/gep/envFingerprint.js +1 -1
  33. package/src/gep/epigenetics.js +1 -1
  34. package/src/gep/explore.js +1 -1
  35. package/src/gep/hash.js +1 -1
  36. package/src/gep/hubFetch.js +1 -1
  37. package/src/gep/hubReview.js +1 -1
  38. package/src/gep/hubSearch.js +1 -1
  39. package/src/gep/hubVerify.js +1 -1
  40. package/src/gep/idleScheduler.js +155 -6
  41. package/src/gep/learningSignals.js +1 -1
  42. package/src/gep/memoryGraph.js +1 -1
  43. package/src/gep/memoryGraphAdapter.js +1 -1
  44. package/src/gep/mutation.js +1 -1
  45. package/src/gep/narrativeMemory.js +1 -1
  46. package/src/gep/openPRRegistry.js +1 -1
  47. package/src/gep/paths.js +6 -2
  48. package/src/gep/personality.js +1 -1
  49. package/src/gep/policyCheck.js +1 -1
  50. package/src/gep/prompt.js +1 -1
  51. package/src/gep/recallVerifier.js +1 -1
  52. package/src/gep/reflection.js +1 -1
  53. package/src/gep/sanitize.js +57 -3
  54. package/src/gep/selector.js +1 -1
  55. package/src/gep/selfPR.js +34 -1
  56. package/src/gep/skill2gep.js +108 -29
  57. package/src/gep/skillDistiller.js +1 -1
  58. package/src/gep/solidify.js +1 -1
  59. package/src/gep/strategy.js +1 -1
  60. package/src/gep/workspaceKeychain.js +1 -1
  61. package/src/proxy/lifecycle/manager.js +97 -37
  62. package/src/proxy/router/messages_route.js +25 -0
  63. package/src/proxy/sync/engine.js +68 -31
  64. package/assets/gep/candidates.jsonl +0 -1
  65. package/assets/gep/capsules.json +0 -4
  66. package/assets/gep/events.jsonl +0 -0
  67. package/assets/gep/failed_capsules.json +0 -4
  68. package/assets/gep/genes.json +0 -245
  69. package/assets/gep/genes.jsonl +0 -0
@@ -7,17 +7,76 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
 
10
- const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
10
+ const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace } = require('./_runtimePaths');
11
11
  const { filterRelevantOutcomes } = require('./_memoryFiltering');
12
12
 
13
- function readLastN(filePath, n) {
13
+ // One-line notice shown (throttled) when the workspace is not a git repo.
14
+ // Evolver derives every outcome from the git diff, so in a non-git folder the
15
+ // session-end hook records nothing — silently, unless we say so here. We surface
16
+ // it in session-start's additionalContext (injected as opening context, which
17
+ // does NOT trigger an extra inference round, unlike a stop-hook systemMessage).
18
+ const NON_GIT_NOTICE =
19
+ '[Evolver] This folder is not a git repository, so evolution memory is inactive ' +
20
+ '(outcomes are derived from git diffs). Run `git init` here, or open a git project, ' +
21
+ 'to enable recall and recording.';
22
+ const NON_GIT_NOTICE_TTL_MS = 30 * 60 * 1000; // once per 30 min per folder
23
+
24
+ // Return up to `n` of the current workspace's most-recent entries, in
25
+ // chronological (oldest-first) order.
26
+ //
27
+ // Why scan from the end: a plain tail-N-then-filter read would let outcomes
28
+ // from other projects (which share the user-level fallback graph on npm-global
29
+ // installs) crowd this workspace's entries out of the window — we must scope
30
+ // to the workspace BEFORE trimming. But parsing the ENTIRE file to do that is
31
+ // wasteful: the graph can reach ~100 MB before rotation, and JSON-parsing every
32
+ // line on each session start is real CPU/memory cost (Bugbot PR #555 round-3).
33
+ //
34
+ // So we read the file (cheap; the previous readLastN read it whole too) but
35
+ // JSON-parse lines lazily from the newest end, keeping only workspace matches,
36
+ // and stop as soon as we have `n`. Parse count is bounded by where this
37
+ // workspace's n-th-most-recent entry sits, not by total file size.
38
+ function readRecentWorkspaceEntries(filePath, currentId, currentDir, n) {
39
+ let lines;
14
40
  try {
15
- const content = fs.readFileSync(filePath, 'utf8');
16
- const lines = content.trim().split('\n').filter(Boolean);
17
- return lines.slice(-n).map(line => {
18
- try { return JSON.parse(line); } catch { return null; }
19
- }).filter(Boolean);
41
+ lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
20
42
  } catch { return []; }
43
+ const out = [];
44
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
45
+ const line = lines[i];
46
+ if (!line) continue;
47
+ let entry;
48
+ try { entry = JSON.parse(line); } catch { continue; }
49
+ if (belongsToWorkspace(entry, currentId, currentDir)) out.push(entry);
50
+ }
51
+ return out.reverse(); // newest-collected-first -> chronological
52
+ }
53
+
54
+ // Does this memory-graph entry belong to the current workspace?
55
+ //
56
+ // The session-end writer stamps two tags: `workspace_id` (forge-resistant,
57
+ // preferred) and `cwd` (backward-compat). We scope reads so that one project
58
+ // never sees another's outcomes through the shared user-level fallback graph
59
+ // (~/.evolver/memory/evolution/memory_graph.jsonl) — the cross-project
60
+ // disclosure / prompt-injection surface Bugbot flagged on the writer side
61
+ // (PR #105 round-2), which the reader never enforced until now.
62
+ //
63
+ // Rules, in order:
64
+ // - currentId known + entry.workspace_id present -> must match exactly.
65
+ // - currentId unknown OR entry has neither tag (pre-hardening / Hub-sourced
66
+ // entries) -> do NOT exclude; falling back to "show it" preserves prior
67
+ // behavior and avoids hiding all memory when ids can't be resolved.
68
+ // - As a softer fallback, when the entry has no workspace_id but does carry a
69
+ // cwd, match that against the current project dir.
70
+ function belongsToWorkspace(entry, currentId, currentDir) {
71
+ if (entry && typeof entry.workspace_id === 'string' && entry.workspace_id) {
72
+ if (currentId) return entry.workspace_id === currentId;
73
+ return true; // can't compare — don't hide it
74
+ }
75
+ if (entry && typeof entry.cwd === 'string' && entry.cwd) {
76
+ if (currentDir) return entry.cwd === currentDir;
77
+ return true;
78
+ }
79
+ return true; // untagged (legacy / Hub) — never excluded
21
80
  }
22
81
 
23
82
  function formatOutcome(entry) {
@@ -46,18 +105,14 @@ function getDedupStatePath() {
46
105
  return path.join(dir, 'session-start-state.json');
47
106
  }
48
107
 
49
- function shouldSkipInjection() {
50
- // Only apply dedup when explicitly enabled (set by Kiro adapter) OR when
51
- // we detect a per-prompt-firing platform via PROMPT_SUBMIT heuristic in
52
- // stdin. The stdin is drained in main(), so we rely on env flag here.
53
- const dedupEnabled = String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === '1'
54
- || String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === 'true';
55
- if (!dedupEnabled) return false;
56
-
57
- const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
58
- const key = process.cwd();
108
+ // TTL throttle keyed by an arbitrary string, persisted in session-start-state
109
+ // .json. Returns true if `key` fired within the last `ttlMs` (caller should
110
+ // suppress); otherwise records "now" for `key` and returns false. Best-effort:
111
+ // a state read/write failure just means no throttling (fail open). Shared by
112
+ // the Kiro per-prompt dedup and the non-git notice so both age out of the same
113
+ // file (entries older than 24h are pruned on write).
114
+ function throttled(key, ttlMs) {
59
115
  const statePath = getDedupStatePath();
60
-
61
116
  let state = {};
62
117
  try {
63
118
  if (fs.existsSync(statePath)) {
@@ -67,9 +122,7 @@ function shouldSkipInjection() {
67
122
 
68
123
  const now = Date.now();
69
124
  const last = state[key];
70
- if (typeof last === 'number' && now - last < ttlMs) {
71
- return true;
72
- }
125
+ if (typeof last === 'number' && now - last < ttlMs) return true;
73
126
 
74
127
  state[key] = now;
75
128
  try {
@@ -82,47 +135,78 @@ function shouldSkipInjection() {
82
135
  fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
83
136
  fs.renameSync(tmp, statePath);
84
137
  } catch { /* best-effort */ }
85
-
86
138
  return false;
87
139
  }
88
140
 
141
+ function shouldSkipInjection() {
142
+ // Only apply dedup when explicitly enabled (set by Kiro adapter) OR when
143
+ // we detect a per-prompt-firing platform via PROMPT_SUBMIT heuristic in
144
+ // stdin. The stdin is drained in main(), so we rely on env flag here.
145
+ const dedupEnabled = String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === '1'
146
+ || String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === 'true';
147
+ if (!dedupEnabled) return false;
148
+
149
+ const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
150
+ return throttled(process.cwd(), ttlMs);
151
+ }
152
+
89
153
  function main() {
90
154
  if (shouldSkipInjection()) {
91
155
  process.stdout.write(JSON.stringify({}));
92
156
  return;
93
157
  }
94
158
 
159
+ const currentDir = resolveProjectDir();
160
+
161
+ // Non-git notice: evolver records nothing in a non-git folder (outcomes come
162
+ // from git diffs), so tell the user — once per folder per TTL — instead of
163
+ // failing silently. Emitted regardless of whether any memory exists below.
164
+ const parts = [];
165
+ if (!isGitWorkspace(currentDir) && !throttled('nongit:' + currentDir, NON_GIT_NOTICE_TTL_MS)) {
166
+ parts.push(NON_GIT_NOTICE);
167
+ }
168
+
95
169
  const evolverRoot = findEvolverRoot();
96
170
  const graphPath = findMemoryGraph(evolverRoot);
97
171
 
98
- if (!graphPath) {
99
- process.stdout.write(JSON.stringify({}));
100
- return;
172
+ // Scope to the current workspace BEFORE trimming to the most-recent window,
173
+ // so other projects sharing the user-level fallback graph can't crowd this
174
+ // workspace's outcomes out of view. When the workspace id can't be resolved,
175
+ // belongsToWorkspace() falls back to "show it" — no regression vs. the old
176
+ // unscoped behavior.
177
+ if (graphPath) {
178
+ const currentId = resolveWorkspaceId(evolverRoot, currentDir);
179
+ const recent = readRecentWorkspaceEntries(graphPath, currentId, currentDir, 5);
180
+ const filtered = filterRelevantOutcomes(recent);
181
+ if (filtered.length > 0) {
182
+ const successCount = filtered.filter(e => e.outcome && e.outcome.status === 'success').length;
183
+ const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
184
+ parts.push([
185
+ `[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
186
+ ...filtered.map(formatOutcome),
187
+ '',
188
+ 'Use successful approaches. Avoid repeating failed patterns.',
189
+ ].join('\n'));
190
+ }
101
191
  }
102
192
 
103
- const entries = readLastN(graphPath, 5);
104
- const filtered = filterRelevantOutcomes(entries);
105
-
106
- if (filtered.length === 0) {
193
+ if (parts.length === 0) {
107
194
  process.stdout.write(JSON.stringify({}));
108
195
  return;
109
196
  }
110
197
 
111
- const successCount = filtered.filter(e => e.outcome && e.outcome.status === 'success').length;
112
- const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
113
-
114
- const lines = filtered.map(formatOutcome);
115
- const summary = [
116
- `[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
117
- ...lines,
118
- '',
119
- 'Use successful approaches. Avoid repeating failed patterns.',
120
- ].join('\n');
121
-
198
+ const out = parts.join('\n\n');
122
199
  process.stdout.write(JSON.stringify({
123
- agent_message: summary,
124
- additionalContext: summary,
200
+ agent_message: out,
201
+ additionalContext: out,
125
202
  }));
126
203
  }
127
204
 
128
- main();
205
+ // Run as a hook when invoked directly; expose pure helpers for unit tests when
206
+ // required as a module. Guarding on require.main keeps the direct-execution
207
+ // behavior (the hosts run `node evolver-session-start.js`) unchanged.
208
+ if (require.main === module) {
209
+ main();
210
+ } else {
211
+ module.exports = { belongsToWorkspace };
212
+ }
@@ -27,6 +27,7 @@ const https = require('https');
27
27
  const crypto = require('crypto');
28
28
 
29
29
  const { computeAssetId } = require('../gep/contentHash');
30
+ const { enforceHubScheme, strictHttpsAgent } = require('../gep/hubFetch');
30
31
  const {
31
32
  getNodeId,
32
33
  getHubUrl,
@@ -114,6 +115,19 @@ function _publishUrl() {
114
115
 
115
116
  function _postJson(urlStr, body, timeoutMs) {
116
117
  return new Promise(function (resolve) {
118
+ // Same TLS posture as hubFetch: refuse plain http:// unless
119
+ // EVOMAP_HUB_ALLOW_INSECURE=1. Before this guard the function
120
+ // silently fell back to `lib = http` for any non-https URL, so an
121
+ // operator override `A2A_HUB_URL=http://...` would send /a2a/publish
122
+ // and /a2a/task/complete in cleartext while hubFetch-routed calls
123
+ // (e.g. /a2a/verify-solidify) refused the same URL — inconsistent
124
+ // TLS enforcement across modules.
125
+ try {
126
+ enforceHubScheme(urlStr);
127
+ } catch (e) {
128
+ resolve({ ok: false, error: 'tls_refused: ' + (e && e.message) });
129
+ return;
130
+ }
117
131
  let parsed;
118
132
  try {
119
133
  parsed = new URL(urlStr);
@@ -128,15 +142,28 @@ function _postJson(urlStr, body, timeoutMs) {
128
142
  { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
129
143
  buildHubHeaders() || {},
130
144
  );
145
+ // Pin TLS cert verification for https calls so a globally-disabled
146
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 cannot weaken the Hub channel
147
+ // (Cursor Security Reviewer #160 Medium). hubFetch enforces the
148
+ // same via its undici dispatcher; this is the Node-native-https
149
+ // equivalent.
150
+ //
151
+ // Skipped under EVOMAP_HUB_ALLOW_INSECURE=1 so local-dev / self-
152
+ // signed mock hubs that legitimately rely on
153
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 still work.
154
+ const requestOpts = {
155
+ hostname: parsed.hostname,
156
+ port: parsed.port || (isHttps ? 443 : 80),
157
+ path: parsed.pathname + (parsed.search || ''),
158
+ method: 'POST',
159
+ headers: headers,
160
+ timeout: timeoutMs || PUBLISH_TIMEOUT_MS,
161
+ };
162
+ if (isHttps && process.env.EVOMAP_HUB_ALLOW_INSECURE !== '1') {
163
+ requestOpts.agent = strictHttpsAgent;
164
+ }
131
165
  const req = lib.request(
132
- {
133
- hostname: parsed.hostname,
134
- port: parsed.port || (isHttps ? 443 : 80),
135
- path: parsed.pathname + (parsed.search || ''),
136
- method: 'POST',
137
- headers: headers,
138
- timeout: timeoutMs || PUBLISH_TIMEOUT_MS,
139
- },
166
+ requestOpts,
140
167
  function (res) {
141
168
  const chunks = [];
142
169
  res.on('data', function (c) { chunks.push(c); });
@@ -36,7 +36,13 @@ const DEFAULT_DAILY_CAP = 50;
36
36
  const DEFAULT_PER_ORDER_CAP = 10;
37
37
  const DEFAULT_ORDER_TIMEOUT_MS = 3000;
38
38
  const COLD_START_WINDOW_MS = 5 * 60 * 1000;
39
+ // Successful orders dedup for 24h so the same capability gap is only paid for
40
+ // once per day. Failed orders dedup for 5 minutes only — long enough to
41
+ // absorb tight retry loops (the original goal of "don't hammer the hub")
42
+ // without making the user wait 24h to retry a question after a transient
43
+ // 503/network blip.
39
44
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
45
+ const DEDUP_FAILURE_TTL_MS = 5 * 60 * 1000;
40
46
  const LEDGER_FILENAME = 'atp-autobuyer-ledger.json';
41
47
  const ACK_FILENAME = 'atp-autobuy-ack.json';
42
48
 
@@ -145,13 +151,22 @@ function _rotateIfNewDay(ledger, now) {
145
151
  }
146
152
 
147
153
  function _pruneDedup(ledger, now) {
148
- const cutoff = (typeof now === 'number' ? now : Date.now()) - DEDUP_TTL_MS;
154
+ const nowMs = typeof now === 'number' ? now : Date.now();
149
155
  const out = {};
150
156
  const src = ledger.dedup || {};
151
157
  const keys = Object.keys(src);
152
158
  for (let i = 0; i < keys.length; i++) {
153
159
  const k = keys[i];
154
- if (typeof src[k] === 'number' && src[k] >= cutoff) out[k] = src[k];
160
+ const entry = src[k];
161
+ // Legacy ledgers written by older versions stored plain timestamps; treat
162
+ // them as successful orders (the original behaviour) so an upgrade does
163
+ // not suddenly forget recent dedups.
164
+ if (typeof entry === 'number') {
165
+ if (entry >= nowMs - DEDUP_TTL_MS) out[k] = entry;
166
+ } else if (entry && typeof entry.ts === 'number') {
167
+ const ttl = entry.failed ? DEDUP_FAILURE_TTL_MS : DEDUP_TTL_MS;
168
+ if (entry.ts >= nowMs - ttl) out[k] = entry;
169
+ }
155
170
  }
156
171
  ledger.dedup = out;
157
172
  return ledger;
@@ -205,11 +220,36 @@ function _withTimeout(promise, timeoutMs) {
205
220
  ]);
206
221
  }
207
222
 
208
- async function considerOrder(opts) {
209
- if (!_started) return { ok: false, skipped: true, reason: 'not_started' };
223
+ // Single-flight queue: serialize the read → cap-check → placeOrder → write
224
+ // pipeline so two concurrent considerOrder() calls cannot both pass the cap
225
+ // check on the same ledger snapshot and silently double-spend.
226
+ //
227
+ // Without this, two parallel calls (e.g. user runs Claude Code in two tabs
228
+ // through the same proxy, or two capability gaps fire in the same tick) both
229
+ // read spent=40, both compute remaining=10, both await placeOrder, both
230
+ // increment to spent=50, and write — silently exceeding the daily cap by one
231
+ // full order each. autoBuyer is single-process so an in-memory queue is
232
+ // sufficient; a file lock would only be needed if multiple OS processes
233
+ // shared the same ledger file (not the current deployment model).
234
+ let _orderQueue = Promise.resolve();
235
+
236
+ function considerOrder(opts) {
237
+ if (!_started) return Promise.resolve({ ok: false, skipped: true, reason: 'not_started' });
210
238
  if (!opts || !Array.isArray(opts.capabilities) || opts.capabilities.length === 0) {
211
- return { ok: false, skipped: true, reason: 'no_capabilities' };
239
+ return Promise.resolve({ ok: false, skipped: true, reason: 'no_capabilities' });
212
240
  }
241
+ const next = _orderQueue.then(
242
+ () => _considerOrderSerialized(opts),
243
+ () => _considerOrderSerialized(opts), // never let a prior rejection break the chain
244
+ );
245
+ // Swallow rejection on the queue tail so a single thrown error here does
246
+ // not poison every subsequent call; the original `next` promise still
247
+ // surfaces the error to the caller.
248
+ _orderQueue = next.then(() => {}, () => {});
249
+ return next;
250
+ }
251
+
252
+ async function _considerOrderSerialized(opts) {
213
253
  const now = Date.now();
214
254
  let ledger = _readLedger();
215
255
  ledger = _rotateIfNewDay(ledger, now);
@@ -248,15 +288,17 @@ async function considerOrder(opts) {
248
288
 
249
289
  if (result && result.ok) {
250
290
  ledger.spent = (ledger.spent || 0) + budget;
251
- ledger.dedup[hash] = now;
291
+ ledger.dedup[hash] = { ts: now, failed: false };
252
292
  _writeLedger(ledger);
253
293
  console.log('[ATP-AutoBuyer] Order placed: ' + (result.data && result.data.order_id) + ' budget=' + budget + ' remaining_today=' + Math.max(0, dailyCap - ledger.spent));
254
294
  return { ok: true, data: result.data, spent: budget };
255
295
  }
256
296
 
257
- // On failure still record dedup so we don't hammer the hub for the same
258
- // capability gap within the TTL window (but do NOT charge the spend).
259
- ledger.dedup[hash] = now;
297
+ // On failure record a SHORT-TTL dedup entry (5 min) so we don't hammer the
298
+ // hub for the same capability gap inside a tight retry loop, but the user
299
+ // can retry the same question once the transient error clears — far better
300
+ // than the previous 24h block for a single 503.
301
+ ledger.dedup[hash] = { ts: now, failed: true };
260
302
  _writeLedger(ledger);
261
303
  return { ok: false, error: (result && result.error) || 'unknown_error' };
262
304
  }
@@ -267,15 +309,25 @@ async function considerOrder(opts) {
267
309
  // via .tmp + rename so a crash mid-write never produces a corrupt ack file.
268
310
  function setConsent(enabled) {
269
311
  const dir = getMemoryDir();
270
- try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch (_) {}
271
312
  const body = {
272
313
  enabled: !!enabled,
273
314
  acknowledged_at: new Date().toISOString(),
274
315
  version: 1,
275
316
  };
276
317
  const tmp = _ackPath() + '.tmp';
277
- fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8');
278
- fs.renameSync(tmp, _ackPath());
318
+ // Single try/catch over the whole pipeline. Previously the mkdirSync was
319
+ // wrapped in its own swallowing try/catch, so an EACCES on the parent dir
320
+ // would surface to the caller as a confusing ENOENT from writeFileSync.
321
+ // Surface the original error verbatim and best-effort clean up any
322
+ // partial .tmp file so a retry from a TTY prompt sees a clean slate.
323
+ try {
324
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
325
+ fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8');
326
+ fs.renameSync(tmp, _ackPath());
327
+ } catch (err) {
328
+ try { fs.unlinkSync(tmp); } catch (_) {}
329
+ throw err;
330
+ }
279
331
  return body;
280
332
  }
281
333
 
@@ -288,6 +340,7 @@ function _resetForTests() {
288
340
  perOrderCap: DEFAULT_PER_ORDER_CAP,
289
341
  timeoutMs: DEFAULT_ORDER_TIMEOUT_MS,
290
342
  };
343
+ _orderQueue = Promise.resolve();
291
344
  }
292
345
 
293
346
  module.exports = {
@@ -306,22 +359,24 @@ module.exports = {
306
359
  readAck: _readAck,
307
360
  ACK_FILENAME,
308
361
  // Exposed for tests and diagnostics only; callers should not depend on
309
- // these internals in production code paths.
362
+ // these internals in production code paths. Ack-file helpers
363
+ // (getAckPath / readAck / ACK_FILENAME) are intentionally NOT mirrored
364
+ // here — production and tests both go through the public surface above,
365
+ // so a single source of truth survives schema changes (Bugbot PR #141 R6
366
+ // follow-up: keep "test-only" honest, no production caller may reach in).
310
367
  __internals: {
311
368
  readLedger: _readLedger,
312
369
  writeLedger: _writeLedger,
313
370
  questionHash: _questionHash,
314
371
  effectiveCap: _effectiveCap,
315
372
  resetForTests: _resetForTests,
316
- ackPath: _ackPath,
317
- readAck: _readAck,
318
373
  constants: {
319
374
  DEFAULT_DAILY_CAP,
320
375
  DEFAULT_PER_ORDER_CAP,
321
376
  COLD_START_WINDOW_MS,
322
377
  DEDUP_TTL_MS,
378
+ DEDUP_FAILURE_TTL_MS,
323
379
  LEDGER_FILENAME,
324
- ACK_FILENAME,
325
380
  },
326
381
  },
327
382
  };
@@ -155,6 +155,18 @@ function start(opts) {
155
155
  _pollInterval = setInterval(function () {
156
156
  _tick().catch(function () { /* swallowed in _tick */ });
157
157
  }, _pollMs);
158
+ // .unref() so this background poller does NOT keep the Node event
159
+ // loop alive on its own. `evolver run` (single-shot) writes its
160
+ // artifacts and expects to exit — without unref, the setInterval
161
+ // handle pins the process and the run sits as a residual `node`
162
+ // process until manually killed (public issue #553). `evolver --loop`
163
+ // (daemon) keeps the foreground evolve loop alive on its own
164
+ // schedule, so an unref'd poller still polls — unref only changes
165
+ // whether THIS handle alone keeps the loop alive, not whether the
166
+ // handle fires.
167
+ if (_pollInterval && typeof _pollInterval.unref === 'function') {
168
+ _pollInterval.unref();
169
+ }
158
170
  // Do not await -- fire the first tick asynchronously so start() returns
159
171
  // immediately. This matches the autoBuyer start() semantics.
160
172
  _tick().catch(function () { /* swallowed in _tick */ });
@@ -189,6 +201,10 @@ module.exports = {
189
201
  writeLedger: _writeLedger,
190
202
  buildProofPayload: _buildProofPayload,
191
203
  resetForTests: _resetForTests,
204
+ // Test-only accessor for the active poll Timeout. Used to assert
205
+ // the timer was `.unref()`ed so it does not pin the Node event
206
+ // loop (regression guard for public issue #553).
207
+ getPollIntervalForTest: () => _pollInterval,
192
208
  constants: {
193
209
  DEFAULT_POLL_MS,
194
210
  MIN_POLL_MS,
@@ -22,22 +22,13 @@
22
22
  const readline = require("readline");
23
23
  const autoBuyer = require("./autoBuyer");
24
24
 
25
- // All ack file plumbing is owned by autoBuyer: filename constant, path
26
- // resolution, read (strict validation), and write (atomic tmp+rename).
27
- // cliAutobuyPrompt delegates through the public API (not __internals) so
28
- // the two modules cannot diverge on schema or validation — pre-
29
- // consolidation drift bit us twice (Bugbot PR #141: duplicate writers +
30
- // lenient-vs-strict reader). Using public exports keeps the "test-only"
31
- // contract on __internals honest (Bugbot PR #141 R6).
32
- const ACK_FILE_NAME = autoBuyer.ACK_FILENAME;
33
-
34
- function _getAckPath() {
35
- return autoBuyer.getAckPath();
36
- }
37
-
38
- function _readAck() {
39
- return autoBuyer.readAck();
40
- }
25
+ // All ack file plumbing lives on autoBuyer (filename constant, path
26
+ // resolution, read with strict validation, atomic write via tmp+rename).
27
+ // cliAutobuyPrompt always reaches it through the public surface so the
28
+ // two modules cannot diverge on schema or validation — pre-consolidation
29
+ // drift bit us twice (Bugbot PR #141: duplicate writers + lenient-vs-
30
+ // strict reader). No __internals re-export here either: tests import
31
+ // autoBuyer directly so a future rename trips a single set of asserts.
41
32
 
42
33
  /**
43
34
  * @returns {"ack_present"|"env_set"|"non_tty"|"eligible"}
@@ -50,7 +41,7 @@ function classify(env, stdin) {
50
41
  if (!stdin || !stdin.isTTY) {
51
42
  return "non_tty";
52
43
  }
53
- if (_readAck()) {
44
+ if (autoBuyer.readAck()) {
54
45
  return "ack_present";
55
46
  }
56
47
  return "eligible";
@@ -160,9 +151,4 @@ async function runPrompt(opts) {
160
151
  module.exports = {
161
152
  runPrompt,
162
153
  classify,
163
- __internals: {
164
- ACK_FILE_NAME,
165
- _readAck,
166
- _getAckPath,
167
- },
168
154
  };
@@ -16,6 +16,7 @@
16
16
 
17
17
  const http = require('http');
18
18
  const { getHubUrl, buildHubHeaders, getNodeId } = require('../gep/a2aProtocol');
19
+ const { hubFetch } = require('../gep/hubFetch');
19
20
  const { getProxyUrl, getProxyToken } = require('../proxy/server/settings');
20
21
 
21
22
  function _isProxyMode() {
@@ -68,12 +69,33 @@ function _proxyRequest(method, path, body, timeoutMs) {
68
69
  });
69
70
  }
70
71
 
72
+ // Route through hubFetch() rather than the global `fetch()` for two
73
+ // reasons (both flagged by Cursor reviewers on PR #160):
74
+ //
75
+ // 1. Dispatcher mixing (Bugbot HIGH): `strictUndiciAgent` is an Agent
76
+ // from the *installed* `undici` package, but `global.fetch` is
77
+ // backed by Node's *internal* undici. Passing one to the other
78
+ // throws `UND_ERR_INVALID_ARG: invalid onRequestStart method` at
79
+ // request time — exactly the failure mode the comment at the top
80
+ // of hubFetch.js calls out. hubFetch already routes through
81
+ // `undici.fetch` from the same package as its Agent, so all calls
82
+ // that go through hubFetch are immune.
83
+ //
84
+ // 2. Case-sensitive scheme check (Security Reviewer MEDIUM): a hand-
85
+ // rolled `endpoint.startsWith('https:')` would skip the strict
86
+ // dispatcher for `HTTPS://...`. hubFetch's `_validateHubUrl` uses
87
+ // `new URL(url).protocol`, which normalises to lowercase, so
88
+ // routing through it eliminates the bug class.
89
+ //
90
+ // Routing through hubFetch also inherits the URL-scheme enforcement and
91
+ // the EVOMAP_HUB_ALLOW_INSECURE escape hatch automatically; we no
92
+ // longer need the explicit `enforceHubScheme` guard here.
71
93
  function _hubPost(pathSuffix, body, timeoutMs) {
72
94
  const hubUrl = getHubUrl();
73
95
  if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
74
96
  const endpoint = hubUrl.replace(/\/+$/, '') + pathSuffix;
75
97
  const timeout = timeoutMs || require('../config').HTTP_TRANSPORT_TIMEOUT_MS;
76
- return fetch(endpoint, {
98
+ return hubFetch(endpoint, {
77
99
  method: 'POST',
78
100
  headers: buildHubHeaders(),
79
101
  body: JSON.stringify(body),
@@ -83,7 +105,17 @@ function _hubPost(pathSuffix, body, timeoutMs) {
83
105
  if (!res.ok) return res.text().then(function (t) { return { ok: false, status: res.status, error: t.slice(0, 400) }; });
84
106
  return res.json().then(function (data) { return { ok: true, data: data }; });
85
107
  })
86
- .catch(function (err) { return { ok: false, error: err.message }; });
108
+ .catch(function (err) {
109
+ // hubFetch throws synchronously (rejected Promise) when the URL
110
+ // fails scheme validation in secure mode. Translate to the same
111
+ // structured envelope the previous in-line guard produced so the
112
+ // caller contract is unchanged.
113
+ const msg = (err && err.message) || String(err);
114
+ if (msg.indexOf('[hubFetch]') !== -1) {
115
+ return { ok: false, error: 'tls_refused: ' + msg };
116
+ }
117
+ return { ok: false, error: msg };
118
+ });
87
119
  }
88
120
 
89
121
  function _hubGet(pathSuffix, timeoutMs) {
@@ -91,7 +123,7 @@ function _hubGet(pathSuffix, timeoutMs) {
91
123
  if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
92
124
  const endpoint = hubUrl.replace(/\/+$/, '') + pathSuffix;
93
125
  const timeout = timeoutMs || require('../config').HTTP_TRANSPORT_TIMEOUT_MS;
94
- return fetch(endpoint, {
126
+ return hubFetch(endpoint, {
95
127
  method: 'GET',
96
128
  headers: buildHubHeaders(),
97
129
  signal: AbortSignal.timeout(timeout),
@@ -100,7 +132,13 @@ function _hubGet(pathSuffix, timeoutMs) {
100
132
  if (!res.ok) return res.text().then(function (t) { return { ok: false, status: res.status, error: t.slice(0, 400) }; });
101
133
  return res.json().then(function (data) { return { ok: true, data: data }; });
102
134
  })
103
- .catch(function (err) { return { ok: false, error: err.message }; });
135
+ .catch(function (err) {
136
+ const msg = (err && err.message) || String(err);
137
+ if (msg.indexOf('[hubFetch]') !== -1) {
138
+ return { ok: false, error: 'tls_refused: ' + msg };
139
+ }
140
+ return { ok: false, error: msg };
141
+ });
104
142
  }
105
143
 
106
144
  // Dispatcher: choose proxy or direct hub based on env + proxy availability.