@evomap/evolver-core 2.0.0-beta.2 → 2.0.0-beta.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 (58) hide show
  1. package/dist/algo/candidateAssembly.js +9 -6
  2. package/dist/algo/cycleEngine.d.ts +11 -0
  3. package/dist/algo/cycleEngine.js +12 -6
  4. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  5. package/dist/algo/geneSelection.d.ts +12 -1
  6. package/dist/algo/geneSelection.js +24 -8
  7. package/dist/algo/index.d.ts +1 -0
  8. package/dist/algo/index.js +1 -0
  9. package/dist/algo/memoryGraph.d.ts +62 -0
  10. package/dist/algo/memoryGraph.js +86 -0
  11. package/dist/algo/orchestrator.d.ts +3 -0
  12. package/dist/algo/orchestrator.js +14 -2
  13. package/dist/assetstore/assetSidecarRecords.d.ts +23 -0
  14. package/dist/assetstore/assetSidecarRecords.js +142 -0
  15. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  16. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  17. package/dist/assetstore/assetStoreHealth.d.ts +75 -0
  18. package/dist/assetstore/assetStoreHealth.js +277 -0
  19. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  20. package/dist/assetstore/assetStoreLayout.js +6 -0
  21. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  22. package/dist/assetstore/assetStoreStorage.js +318 -0
  23. package/dist/assetstore/assetSyncLedger.d.ts +5 -1
  24. package/dist/assetstore/assetSyncLedger.js +44 -64
  25. package/dist/assetstore/index.d.ts +2 -0
  26. package/dist/assetstore/index.js +2 -0
  27. package/dist/assetstore/localJsonl.d.ts +1 -0
  28. package/dist/assetstore/localJsonl.js +36 -32
  29. package/dist/assetstore/provenance.d.ts +13 -0
  30. package/dist/assetstore/provenance.js +60 -84
  31. package/dist/assetstore/provider.d.ts +2 -0
  32. package/dist/assetstore/reviewFilter.js +3 -1
  33. package/dist/assetstore/reviewLedger.d.ts +8 -2
  34. package/dist/assetstore/reviewLedger.js +71 -45
  35. package/dist/benchmark/index.d.ts +2 -1
  36. package/dist/benchmark/index.js +2 -1
  37. package/dist/benchmark/triggerShift.d.ts +62 -0
  38. package/dist/benchmark/triggerShift.js +106 -0
  39. package/dist/events/ingest.d.ts +1 -1
  40. package/dist/events/ingest.js +2 -0
  41. package/dist/events/paths.d.ts +1 -1
  42. package/dist/events/paths.js +2 -2
  43. package/dist/exec/autoExec.d.ts +6 -1
  44. package/dist/exec/autoExec.js +31 -0
  45. package/dist/exec/autonomousCycle.d.ts +2 -0
  46. package/dist/exec/autonomousCycle.js +5 -0
  47. package/dist/exec/claudeBridge.d.ts +7 -2
  48. package/dist/exec/claudeBridge.js +92 -14
  49. package/dist/exec/prompt.js +9 -0
  50. package/dist/exec/runnerRegistry.d.ts +56 -12
  51. package/dist/exec/runnerRegistry.js +272 -22
  52. package/dist/hub/bindings.js +12 -2
  53. package/dist/ops/savingsCore.js +1 -2
  54. package/dist/ops/selfUpdate.d.ts +10 -1
  55. package/dist/ops/selfUpdate.js +64 -15
  56. package/dist/util/fileLock.d.ts +19 -2
  57. package/dist/util/fileLock.js +166 -31
  58. package/package.json +5 -1
@@ -21,6 +21,13 @@ export class UnsupportedCursorSkipPermissionsError extends Error {
21
21
  this.name = 'UnsupportedCursorSkipPermissionsError';
22
22
  }
23
23
  }
24
+ /** Thrown when Gemini permission options cannot be mapped to a verified bounded CLI contract. */
25
+ export class UnsupportedGeminiPermissionOptionsError extends Error {
26
+ constructor() {
27
+ super('gemini runner does not support skipPermissions or allowedTools: --yolo is unbounded and --allowed-tools is deprecated; the verified runner uses --approval-mode auto_edit only');
28
+ this.name = 'UnsupportedGeminiPermissionOptionsError';
29
+ }
30
+ }
24
31
  /** Thrown when Cursor's Windows installation cannot be reduced to a shell-free node.exe + index.js launch. */
25
32
  export class UnsupportedCursorWindowsRunnerError extends Error {
26
33
  constructor() {
@@ -114,41 +121,190 @@ function cursorVersionKey(version) {
114
121
  const second = match[6] ?? '0';
115
122
  return [year, month, day, hour, minute, second].map((part, index) => index === 0 ? part : part.padStart(2, '0')).join('');
116
123
  }
124
+ const WINDOWS_TREE_KILL_TIMEOUT_MS = 5_000;
125
+ /** Build the shell-free taskkill invocation used for Windows process-tree termination. */
126
+ export function windowsTreeKillCommand(pid) {
127
+ if (!Number.isSafeInteger(pid) || pid <= 0)
128
+ throw new RangeError(`invalid process id: ${pid}`);
129
+ return { command: 'taskkill.exe', args: ['/PID', String(pid), '/T', '/F'] };
130
+ }
131
+ /** Run taskkill and report whether Windows accepted the process-tree termination request. */
132
+ export function killWindowsProcessTree(pid, spawnCommand = spawn, timeoutMs = WINDOWS_TREE_KILL_TIMEOUT_MS) {
133
+ const { command, args } = windowsTreeKillCommand(pid);
134
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
135
+ throw new RangeError(`invalid taskkill timeout: ${timeoutMs}`);
136
+ return new Promise((resolve) => {
137
+ let settled = false;
138
+ let killer;
139
+ const finish = (ok, terminateKiller = false) => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ clearTimeout(timer);
144
+ if (terminateKiller) {
145
+ try {
146
+ killer?.kill?.('SIGKILL');
147
+ }
148
+ catch { /* best-effort watchdog cleanup */ }
149
+ }
150
+ resolve(ok);
151
+ };
152
+ const timer = setTimeout(() => finish(false, true), timeoutMs);
153
+ timer.unref?.();
154
+ try {
155
+ killer = spawnCommand(command, args, { shell: false, windowsHide: true, stdio: 'ignore' });
156
+ killer.once('error', () => finish(false));
157
+ killer.once('close', (code) => finish(code === 0));
158
+ }
159
+ catch {
160
+ finish(false);
161
+ }
162
+ });
163
+ }
117
164
  /**
118
165
  * Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
119
166
  * On timeout the WHOLE process group is killed, not just the direct child (finding #39.5): an agent spawns
120
167
  * tool subprocesses (grandchildren) that would otherwise orphan and leak. On POSIX we spawn detached (the
121
- * child becomes its own group leader) and SIGKILL the group via the negative pid; Windows falls back to a
122
- * direct kill (different process-group semantics).
168
+ * child becomes its own group leader) and SIGKILL the group via the negative pid. Windows runs
169
+ * `taskkill.exe /PID <pid> /T /F` without a shell and waits for that command before resolving.
123
170
  */
124
171
  export function spawnCapture(cmd, args, opts) {
125
172
  return new Promise((resolve, reject) => {
126
- const detached = process.platform !== 'win32';
173
+ if (opts.signal?.aborted) {
174
+ resolve({ code: null, stdout: '', stderr: '', termination: 'cancelled' });
175
+ return;
176
+ }
177
+ const platform = opts.processPlatform ?? process.platform;
178
+ const detached = platform !== 'win32';
127
179
  const r = resolveSpawnCommand(cmd, args, opts.env, opts.resolvePlatform ?? process.platform);
128
180
  const child = spawn(r.cmd, r.args, { cwd: opts.cwd, shell: false, detached, ...(opts.env ? { env: opts.env } : {}) });
129
181
  let stdout = '';
130
182
  let stderr = '';
183
+ let termination = 'exit';
184
+ let killPromise;
185
+ let settled = false;
131
186
  const killTree = () => {
132
- if (detached && typeof child.pid === 'number') {
133
- try {
134
- process.kill(-child.pid, 'SIGKILL');
135
- return;
187
+ if (killPromise)
188
+ return killPromise;
189
+ killPromise = (async () => {
190
+ if (platform === 'win32' && typeof child.pid === 'number') {
191
+ let killed = false;
192
+ try {
193
+ killed = await (opts.windowsProcessTreeKiller ?? killWindowsProcessTree)(child.pid);
194
+ }
195
+ catch {
196
+ // Treat an injected/custom killer rejection like taskkill failure and fall back to the direct child.
197
+ }
198
+ if (killed)
199
+ return;
136
200
  }
137
- catch { /* group gone; fall back */ }
201
+ if (detached && typeof child.pid === 'number') {
202
+ try {
203
+ process.kill(-child.pid, 'SIGKILL');
204
+ return;
205
+ }
206
+ catch { /* group gone; fall back */ }
207
+ }
208
+ child.kill('SIGKILL');
209
+ })();
210
+ return killPromise;
211
+ };
212
+ const cancel = () => {
213
+ if (termination !== 'exit')
214
+ return;
215
+ termination = 'cancelled';
216
+ void killTree();
217
+ };
218
+ const timeout = () => {
219
+ if (termination !== 'exit')
220
+ return;
221
+ termination = 'timeout';
222
+ void killTree();
223
+ };
224
+ const ignoreProcessSignal = () => { };
225
+ const cleanup = () => {
226
+ clearTimeout(timer);
227
+ opts.signal?.removeEventListener('abort', cancel);
228
+ if (opts.processSignalMode === 'ignore') {
229
+ process.removeListener('SIGINT', ignoreProcessSignal);
230
+ process.removeListener('SIGTERM', ignoreProcessSignal);
138
231
  }
139
- child.kill('SIGKILL');
232
+ else {
233
+ process.removeListener('SIGINT', cancel);
234
+ process.removeListener('SIGTERM', cancel);
235
+ }
236
+ };
237
+ const settle = async (finish) => {
238
+ if (settled)
239
+ return;
240
+ settled = true;
241
+ if (killPromise)
242
+ await killPromise;
243
+ cleanup();
244
+ finish();
140
245
  };
141
- const timer = setTimeout(killTree, opts.timeoutMs);
246
+ const timer = setTimeout(timeout, opts.timeoutMs);
247
+ opts.signal?.addEventListener('abort', cancel, { once: true });
248
+ // A detached POSIX child would otherwise survive Ctrl-C/SIGTERM. Cancel first so the bridge can clean its
249
+ // worktree and return a failure instead of leaking an agent or tool subprocess.
250
+ if (opts.processSignalMode === 'ignore') {
251
+ process.on('SIGINT', ignoreProcessSignal);
252
+ process.on('SIGTERM', ignoreProcessSignal);
253
+ }
254
+ else {
255
+ process.once('SIGINT', cancel);
256
+ process.once('SIGTERM', cancel);
257
+ }
142
258
  child.stdout?.on('data', (d) => { stdout += d.toString(); });
143
259
  child.stderr?.on('data', (d) => { stderr += d.toString(); });
144
- child.on('error', (e) => { clearTimeout(timer); reject(e); });
145
- child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
260
+ child.on('error', (e) => { void settle(() => reject(e)); });
261
+ child.on('close', (code) => { void settle(() => resolve({ code, stdout, stderr, termination })); });
146
262
  if (opts.input !== undefined) {
147
263
  child.stdin?.write(opts.input);
148
264
  child.stdin?.end();
149
265
  }
150
266
  });
151
267
  }
268
+ /** Map the shared process result into the failure taxonomy used by plain-text runners. */
269
+ export function classifyBasicRunnerResult(runner, result, timeoutMs) {
270
+ if (result.termination === 'timeout') {
271
+ return {
272
+ ok: false,
273
+ output: result.stdout,
274
+ error: `${runner} timed out after ${timeoutMs}ms`,
275
+ failureKind: 'timeout',
276
+ exitCode: result.code,
277
+ };
278
+ }
279
+ if (result.termination === 'cancelled') {
280
+ return {
281
+ ok: false,
282
+ output: result.stdout,
283
+ error: `${runner} execution cancelled`,
284
+ failureKind: 'cancelled',
285
+ exitCode: result.code,
286
+ };
287
+ }
288
+ if (result.code !== 0) {
289
+ return {
290
+ ok: false,
291
+ output: result.stdout,
292
+ error: result.stderr || `${runner} exited with code ${String(result.code)}`,
293
+ failureKind: 'non_zero_exit',
294
+ exitCode: result.code,
295
+ };
296
+ }
297
+ return { ok: true, output: result.stdout };
298
+ }
299
+ function spawnFailureResult(error) {
300
+ return {
301
+ ok: false,
302
+ output: '',
303
+ error: error instanceof Error ? error.message : String(error),
304
+ failureKind: 'spawn_failed',
305
+ exitCode: null,
306
+ };
307
+ }
152
308
  /**
153
309
  * Build the `claude -p` argv for the given options (pure — testable without spawning).
154
310
  * Safety invariant: skipPermissions (bypassing prompts) is only allowed together with a non-empty
@@ -175,12 +331,13 @@ export function claudeRunnerArgs(opts = {}) {
175
331
  export function makeClaudeHeadlessRunner(opts = {}) {
176
332
  const args = claudeRunnerArgs(opts);
177
333
  return async (prompt, ctx) => {
334
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
178
335
  try {
179
- const r = await spawnCapture('claude', args, { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, input: prompt, ...(ctx.env ? { env: ctx.env } : {}) });
180
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
336
+ const result = await spawnCapture('claude', args, { cwd: ctx.cwd, timeoutMs, input: prompt, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
337
+ return classifyBasicRunnerResult('claude', result, timeoutMs);
181
338
  }
182
339
  catch (e) {
183
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
340
+ return spawnFailureResult(e);
184
341
  }
185
342
  };
186
343
  }
@@ -213,12 +370,95 @@ export function codexRunnerArgs(opts = {}) {
213
370
  export function makeCodexHeadlessRunner(opts = {}) {
214
371
  const args = codexRunnerArgs(opts);
215
372
  return async (prompt, ctx) => {
373
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
216
374
  try {
217
- const r = await spawnCapture('codex', [...args, '--cd', ctx.cwd, prompt], { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(ctx.env ? { env: ctx.env } : {}) });
218
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
375
+ const result = await spawnCapture('codex', [...args, '--cd', ctx.cwd, prompt], { cwd: ctx.cwd, timeoutMs, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
376
+ return classifyBasicRunnerResult('codex', result, timeoutMs);
219
377
  }
220
378
  catch (e) {
221
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
379
+ return spawnFailureResult(e);
380
+ }
381
+ };
382
+ }
383
+ const GEMINI_PERMISSION_DENIAL_RE = /agent execution blocked|permission denied|approval required|not approved|denied by (?:policy|user|admin)|tool (?:call )?(?:was )?denied/i;
384
+ const GEMINI_TERMINATION_WARNINGS = [
385
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Agent execution stopped\b/i, error: 'gemini agent execution stopped' },
386
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Loop detected\b/i, error: 'gemini loop detected' },
387
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Maximum session turns exceeded\b/i, error: 'gemini maximum session turns exceeded' },
388
+ ];
389
+ function geminiMessage(value) {
390
+ if (typeof value === 'string')
391
+ return value;
392
+ if (value && typeof value === 'object') {
393
+ const record = value;
394
+ const type = typeof record['type'] === 'string' ? record['type'] : '';
395
+ const message = typeof record['message'] === 'string' ? record['message'] : '';
396
+ return [type, message].filter(Boolean).join(': ');
397
+ }
398
+ return value === undefined ? '' : String(value);
399
+ }
400
+ function geminiWarnings(value) {
401
+ return Array.isArray(value) ? value.map(geminiMessage).filter(Boolean) : [];
402
+ }
403
+ /** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
404
+ export function geminiRunnerArgs(opts = {}) {
405
+ if (opts.skipPermissions || (opts.allowedTools?.length ?? 0) > 0)
406
+ throw new UnsupportedGeminiPermissionOptionsError();
407
+ const args = ['--output-format', 'json', '--approval-mode', 'auto_edit', '--skip-trust'];
408
+ if (opts.model)
409
+ args.push('--model', opts.model);
410
+ return args;
411
+ }
412
+ /** Headless Gemini runner with structured failure classification; stdout text alone never proves execution success. */
413
+ export function makeGeminiHeadlessRunner(opts = {}) {
414
+ const args = geminiRunnerArgs(opts);
415
+ return async (prompt, ctx) => {
416
+ try {
417
+ const result = await spawnCapture('gemini', [...args, '--prompt', prompt], {
418
+ cwd: ctx.cwd,
419
+ timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS,
420
+ ...(ctx.env ? { env: ctx.env } : {}),
421
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
422
+ });
423
+ if (result.termination === 'timeout') {
424
+ return { ok: false, output: result.stdout, error: `gemini timed out after ${ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, failureKind: 'timeout', exitCode: result.code };
425
+ }
426
+ if (result.termination === 'cancelled') {
427
+ return { ok: false, output: result.stdout, error: 'gemini execution cancelled', failureKind: 'cancelled', exitCode: result.code };
428
+ }
429
+ let envelope;
430
+ try {
431
+ const parsed = JSON.parse(result.stdout);
432
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
433
+ throw new Error('JSON object required');
434
+ envelope = parsed;
435
+ }
436
+ catch {
437
+ const error = result.stderr || (result.code === 0 ? 'gemini returned invalid JSON output' : `gemini exited with code ${String(result.code)}`);
438
+ return { ok: false, output: result.stdout, error, failureKind: result.code === 0 ? 'invalid_output' : 'non_zero_exit', exitCode: result.code };
439
+ }
440
+ const structuredError = geminiMessage(envelope.error);
441
+ const warnings = geminiWarnings(envelope.warnings);
442
+ const terminationError = result.code === 0
443
+ ? GEMINI_TERMINATION_WARNINGS.find(({ pattern }) => warnings.some((warning) => pattern.test(warning)))?.error
444
+ : undefined;
445
+ if (terminationError) {
446
+ return { ok: false, output: geminiMessage(envelope.response), error: terminationError, failureKind: 'runtime_error', exitCode: result.code };
447
+ }
448
+ const denial = [structuredError, ...warnings, result.stderr].find((message) => GEMINI_PERMISSION_DENIAL_RE.test(message));
449
+ if (denial) {
450
+ return { ok: false, output: geminiMessage(envelope.response), error: denial, failureKind: 'permission_denied', exitCode: result.code };
451
+ }
452
+ if (result.code !== 0) {
453
+ return { ok: false, output: geminiMessage(envelope.response), error: structuredError || result.stderr || `gemini exited with code ${String(result.code)}`, failureKind: 'non_zero_exit', exitCode: result.code };
454
+ }
455
+ if (structuredError) {
456
+ return { ok: false, output: geminiMessage(envelope.response), error: structuredError, failureKind: 'runtime_error', exitCode: result.code };
457
+ }
458
+ return { ok: true, output: geminiMessage(envelope.response), exitCode: result.code };
459
+ }
460
+ catch (error) {
461
+ return { ok: false, output: '', error: error instanceof Error ? error.message : String(error), failureKind: 'spawn_failed', exitCode: null };
222
462
  }
223
463
  };
224
464
  }
@@ -264,17 +504,19 @@ export function makeCursorHeadlessRunner(opts = {}, platform = process.platform)
264
504
  const args = cursorRunnerArgs(opts);
265
505
  return async (prompt, ctx) => {
266
506
  assertCursorRunnerPlatformSupported(platform, ctx.env ?? process.env);
507
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
267
508
  try {
268
- const r = await spawnCapture('cursor-agent', [...args, prompt], {
509
+ const result = await spawnCapture('cursor-agent', [...args, prompt], {
269
510
  cwd: ctx.cwd,
270
- timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS,
511
+ timeoutMs,
271
512
  resolvePlatform: platform,
272
513
  ...(ctx.env ? { env: ctx.env } : {}),
514
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
273
515
  });
274
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
516
+ return classifyBasicRunnerResult('cursor', result, timeoutMs);
275
517
  }
276
518
  catch (e) {
277
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
519
+ return spawnFailureResult(e);
278
520
  }
279
521
  };
280
522
  }
@@ -283,6 +525,14 @@ const RUNNER_SPECS = {
283
525
  codex: { name: 'codex', makeRunner: makeCodexHeadlessRunner, envAllow: { prefixes: ['OPENAI_', 'CODEX_'] } },
284
526
  // cursor keeps only its OWN auth env (CURSOR_); like every runner it never inherits another's vendor key.
285
527
  cursor: { name: 'cursor', makeRunner: makeCursorHeadlessRunner, envAllow: { prefixes: ['CURSOR_'] } },
528
+ gemini: {
529
+ name: 'gemini',
530
+ makeRunner: makeGeminiHeadlessRunner,
531
+ envAllow: {
532
+ prefixes: ['GEMINI_'],
533
+ keys: ['GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_GENAI_USE_VERTEXAI'],
534
+ },
535
+ },
286
536
  };
287
537
  /** Resolve a runner spec by name (default 'claude' — byte-identical to the pre-registry behavior). */
288
538
  export function getRunnerSpec(name = 'claude') {
@@ -67,7 +67,10 @@ export function makeHubBindings(cap, options = {}) {
67
67
  const p = e.payload;
68
68
  const taskId = firstString(p.taskId, p.task_id);
69
69
  const assetId = firstString(p.assetId, p.asset_id);
70
- const claimId = firstString(p.claimId, p.claim_id) ?? taskId ?? '';
70
+ const claimId = firstString(p.claimId, p.claim_id);
71
+ if (!claimId) {
72
+ throw new PublishRejectedError('invalid_task_complete', true, 'claim_id is required because task claim handles are opaque');
73
+ }
71
74
  const context = taskId && assetId ? { taskId, assetId } : undefined;
72
75
  return cap.task.complete(claimId, p.result, context);
73
76
  }
@@ -96,5 +99,12 @@ export function makeHubBindings(cap, options = {}) {
96
99
  };
97
100
  }
98
101
  function firstString(...values) {
99
- return values.find((value) => typeof value === 'string' && value.length > 0);
102
+ for (const value of values) {
103
+ if (typeof value !== 'string')
104
+ continue;
105
+ const trimmed = value.trim();
106
+ if (trimmed)
107
+ return trimmed;
108
+ }
109
+ return undefined;
100
110
  }
@@ -6,8 +6,7 @@
6
6
  // gate (it reads the vendored files at runtime, so this module stays free of cross-rootDir JSON
7
7
  // imports), and savings-core's daily drift-check locks the vendored copies to upstream. To change a
8
8
  // coefficient: change savings-core first, regenerate vectors there, bump spec_version, then
9
- // re-vendor here AND in evomap-hub / evomap-private / evox-online-deck / evomap-desktop / evox /
10
- // evolver-private-dev / evolver-v2-enterprise-dev.
9
+ // re-vendor it into each consuming distribution.
11
10
  //
12
11
  // Core purity contract holds: no I/O, no Date.now/Math.random — pure functions over numbers.
13
12
  export const SAVINGS_SPEC_VERSION = '0.3.0';
@@ -103,4 +103,13 @@ export interface DownloadedArtifact {
103
103
  *
104
104
  * Fail-closed everywhere: any doubt (bad structure, hash gap, signature failure, thrown crypto error) → reject.
105
105
  */
106
- export declare function verifyManifest(manifest: unknown, downloaded: readonly DownloadedArtifact[], publicKey?: string | KeyObject): VerifyResult;
106
+ export declare function verifyManifest(manifest: unknown, downloaded: readonly DownloadedArtifact[], publicKey?: string | KeyObject): VerifyResult;
107
+ /**
108
+ * Verify exactly one explicitly selected download against a complete release manifest.
109
+ *
110
+ * Unlike verifyManifest, this verifier does not require downloading every platform artifact. When a public key is
111
+ * configured it first verifies the Ed25519 signature over the complete canonical manifest, then requires exactly
112
+ * one downloaded artifact and exactly one manifest entry with the same path, and finally checks that artifact's
113
+ * sha256. The complete manifest is never filtered or reconstructed before signature verification.
114
+ */
115
+ export declare function verifySelectedManifestArtifact(manifest: unknown, downloaded: readonly DownloadedArtifact[], publicKey?: string | KeyObject): VerifyResult;
@@ -238,6 +238,25 @@ function toPublicKey(publicKey) {
238
238
  const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]);
239
239
  return createPublicKey({ key: der, format: 'der', type: 'spki' });
240
240
  }
241
+ /** Verify the signature over the complete canonical manifest when a public key is configured. */
242
+ function verifyConfiguredManifestSignature(manifest, publicKey) {
243
+ if (!publicKey)
244
+ return { ok: true, reason: 'signature_not_required' };
245
+ if (!manifest.signature)
246
+ return { ok: false, reason: 'signature_required_but_missing' };
247
+ if (manifest.signatureAlg && manifest.signatureAlg !== 'ed25519') {
248
+ return { ok: false, reason: 'signature_alg_unsupported' };
249
+ }
250
+ try {
251
+ const key = toPublicKey(publicKey);
252
+ const ok = cryptoVerify(null, // Ed25519 ignores the digest-name arg
253
+ canonicalManifestBytes(manifest), key, Buffer.from(manifest.signature, 'base64'));
254
+ return ok ? { ok: true, reason: 'signature_verified' } : { ok: false, reason: 'signature_invalid' };
255
+ }
256
+ catch (err) {
257
+ return { ok: false, reason: `signature_verify_error: ${err instanceof Error ? err.message : String(err)}` };
258
+ }
259
+ }
241
260
  /**
242
261
  * Verify downloaded artifacts against the manifest. PURE + deterministic (takes already-read bytes/hashes; does
243
262
  * NO I/O). Two layers:
@@ -274,21 +293,51 @@ export function verifyManifest(manifest, downloaded, publicKey) {
274
293
  return { ok: false, reason: `sha256_mismatch: ${art.path}` };
275
294
  }
276
295
  // Layer 2: Ed25519 signature — enforced ONLY when a key is configured (then fail-closed).
277
- if (publicKey) {
278
- if (!m.signature)
279
- return { ok: false, reason: 'signature_required_but_missing' };
280
- if (m.signatureAlg && m.signatureAlg !== 'ed25519')
281
- return { ok: false, reason: 'signature_alg_unsupported' };
282
- try {
283
- const key = toPublicKey(publicKey);
284
- const ok = cryptoVerify(null, // Ed25519 ignores the digest-name arg
285
- canonicalManifestBytes(m), key, Buffer.from(m.signature, 'base64'));
286
- if (!ok)
287
- return { ok: false, reason: 'signature_invalid' };
288
- }
289
- catch (err) {
290
- return { ok: false, reason: `signature_verify_error: ${err instanceof Error ? err.message : String(err)}` };
291
- }
296
+ const signature = verifyConfiguredManifestSignature(m, publicKey);
297
+ if (!signature.ok)
298
+ return signature;
299
+ return { ok: true, reason: 'verified' };
300
+ }
301
+ /**
302
+ * Verify exactly one explicitly selected download against a complete release manifest.
303
+ *
304
+ * Unlike verifyManifest, this verifier does not require downloading every platform artifact. When a public key is
305
+ * configured it first verifies the Ed25519 signature over the complete canonical manifest, then requires exactly
306
+ * one downloaded artifact and exactly one manifest entry with the same path, and finally checks that artifact's
307
+ * sha256. The complete manifest is never filtered or reconstructed before signature verification.
308
+ */
309
+ export function verifySelectedManifestArtifact(manifest, downloaded, publicKey) {
310
+ const structErr = manifestStructuralError(manifest);
311
+ if (structErr)
312
+ return { ok: false, reason: structErr };
313
+ const m = manifest;
314
+ const signature = verifyConfiguredManifestSignature(m, publicKey);
315
+ if (!signature.ok)
316
+ return signature;
317
+ if (downloaded.length !== 1) {
318
+ return { ok: false, reason: `downloaded_artifact_count_invalid: ${downloaded.length}` };
319
+ }
320
+ const selected = downloaded[0];
321
+ if (!selected || typeof selected.path !== 'string' || !selected.path) {
322
+ return { ok: false, reason: 'downloaded_artifact_path_invalid' };
323
+ }
324
+ const matches = m.artifacts.filter((artifact) => artifact.path === selected.path);
325
+ if (matches.length === 0)
326
+ return { ok: false, reason: `artifact_missing: ${selected.path}` };
327
+ if (matches.length !== 1)
328
+ return { ok: false, reason: `artifact_duplicate: ${selected.path}` };
329
+ const expected = matches[0];
330
+ if (!expected)
331
+ return { ok: false, reason: `artifact_missing: ${selected.path}` };
332
+ let digest;
333
+ if (selected.sha256)
334
+ digest = selected.sha256.toLowerCase();
335
+ else if (selected.bytes)
336
+ digest = sha256Hex(selected.bytes);
337
+ else
338
+ return { ok: false, reason: `artifact_no_content: ${selected.path}` };
339
+ if (digest !== expected.sha256.toLowerCase()) {
340
+ return { ok: false, reason: `sha256_mismatch: ${selected.path}` };
292
341
  }
293
342
  return { ok: true, reason: 'verified' };
294
343
  }
@@ -1,8 +1,15 @@
1
1
  export declare function syncSleep(ms: number): void;
2
2
  export declare class LockTimeoutError extends Error {
3
3
  readonly code = "LOCK_TIMEOUT";
4
- constructor(lockPath: string);
4
+ constructor(_lockPath?: string);
5
5
  }
6
+ export type UnsafeLockPathReason = 'symlink' | 'not_regular_file' | 'owner_too_large' | 'path_changed' | 'permission_denied' | 'invalid_owner';
7
+ export declare class UnsafeLockPathError extends Error {
8
+ readonly reason: UnsafeLockPathReason;
9
+ readonly code = "UNSAFE_LOCK_PATH";
10
+ constructor(reason: UnsafeLockPathReason);
11
+ }
12
+ export declare const MAX_LOCK_OWNER_BYTES = 4096;
6
13
  export interface AcquireLockOptions {
7
14
  maxTries?: number;
8
15
  waitMs?: number;
@@ -21,4 +28,14 @@ export interface AcquireLockOptions {
21
28
  * synchronous critical sections (append-only writes).
22
29
  */
23
30
  export declare function acquireLock(lockPath: string, opts?: AcquireLockOptions): void;
24
- export declare function releaseLock(lockPath: string): void;
31
+ export type ReleaseLockReason = 'released' | 'missing' | 'ownership_changed' | 'not_owned' | 'released_with_cleanup_error' | UnsafeLockPathReason | 'release_failed';
32
+ export interface ReleaseLockResult {
33
+ released: boolean;
34
+ reason: ReleaseLockReason;
35
+ }
36
+ export declare function releaseLock(lockPath: string): ReleaseLockResult;
37
+ export declare class LockReleaseError extends Error {
38
+ readonly reason: ReleaseLockReason;
39
+ readonly code = "LOCK_RELEASE_FAILED";
40
+ constructor(reason: ReleaseLockReason);
41
+ }