@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.
- package/README.ja-JP.md +1 -1
- package/README.ko-KR.md +1 -1
- package/README.md +9 -8
- package/README.zh-CN.md +9 -8
- package/package.json +1 -1
- package/scripts/build_binaries.js +31 -7
- package/src/adapters/scripts/_runtimePaths.js +178 -1
- package/src/adapters/scripts/evolver-session-end.js +63 -33
- package/src/adapters/scripts/evolver-session-start.js +127 -43
- package/src/atp/atpExecute.js +35 -8
- package/src/atp/autoBuyer.js +71 -16
- package/src/atp/autoDeliver.js +16 -0
- package/src/atp/cliAutobuyPrompt.js +8 -22
- package/src/atp/hubClient.js +42 -4
- package/src/evolve/guards.js +1 -1
- package/src/evolve/pipeline/collect.js +1 -1
- package/src/evolve/pipeline/dispatch.js +1 -1
- package/src/evolve/pipeline/enrich.js +1 -1
- package/src/evolve/pipeline/hub.js +1 -1
- package/src/evolve/pipeline/select.js +1 -1
- package/src/evolve/pipeline/signals.js +1 -1
- package/src/evolve/utils.js +1 -1
- package/src/evolve.js +1 -1
- package/src/gep/a2aProtocol.js +1 -1
- package/src/gep/assetStore.js +52 -5
- package/src/gep/candidateEval.js +1 -1
- package/src/gep/candidates.js +1 -1
- package/src/gep/contentHash.js +1 -1
- package/src/gep/crypto.js +1 -1
- package/src/gep/curriculum.js +1 -1
- package/src/gep/deviceId.js +1 -1
- package/src/gep/envFingerprint.js +1 -1
- package/src/gep/epigenetics.js +1 -1
- package/src/gep/explore.js +1 -1
- package/src/gep/hash.js +1 -1
- package/src/gep/hubFetch.js +1 -1
- package/src/gep/hubReview.js +1 -1
- package/src/gep/hubSearch.js +1 -1
- package/src/gep/hubVerify.js +1 -1
- package/src/gep/idleScheduler.js +155 -6
- package/src/gep/learningSignals.js +1 -1
- package/src/gep/memoryGraph.js +1 -1
- package/src/gep/memoryGraphAdapter.js +1 -1
- package/src/gep/mutation.js +1 -1
- package/src/gep/narrativeMemory.js +1 -1
- package/src/gep/openPRRegistry.js +1 -1
- package/src/gep/paths.js +6 -2
- package/src/gep/personality.js +1 -1
- package/src/gep/policyCheck.js +1 -1
- package/src/gep/prompt.js +1 -1
- package/src/gep/recallVerifier.js +1 -1
- package/src/gep/reflection.js +1 -1
- package/src/gep/sanitize.js +57 -3
- package/src/gep/selector.js +1 -1
- package/src/gep/selfPR.js +34 -1
- package/src/gep/skill2gep.js +108 -29
- package/src/gep/skillDistiller.js +1 -1
- package/src/gep/solidify.js +1 -1
- package/src/gep/strategy.js +1 -1
- package/src/gep/workspaceKeychain.js +1 -1
- package/src/proxy/lifecycle/manager.js +97 -37
- package/src/proxy/router/messages_route.js +25 -0
- package/src/proxy/sync/engine.js +68 -31
- package/assets/gep/candidates.jsonl +0 -1
- package/assets/gep/capsules.json +0 -4
- package/assets/gep/events.jsonl +0 -0
- package/assets/gep/failed_capsules.json +0 -4
- package/assets/gep/genes.json +0 -245
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
124
|
-
additionalContext:
|
|
200
|
+
agent_message: out,
|
|
201
|
+
additionalContext: out,
|
|
125
202
|
}));
|
|
126
203
|
}
|
|
127
204
|
|
|
128
|
-
|
|
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
|
+
}
|
package/src/atp/atpExecute.js
CHANGED
|
@@ -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); });
|
package/src/atp/autoBuyer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
258
|
-
// capability gap
|
|
259
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
};
|
package/src/atp/autoDeliver.js
CHANGED
|
@@ -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
|
|
26
|
-
// resolution, read
|
|
27
|
-
// cliAutobuyPrompt
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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 (
|
|
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
|
};
|
package/src/atp/hubClient.js
CHANGED
|
@@ -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
|
|
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) {
|
|
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
|
|
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) {
|
|
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.
|