@dtlabs/ai-toolkit 0.13.0 → 0.14.0

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.
@@ -0,0 +1,563 @@
1
+ 'use strict';
2
+
3
+ // Claude subprocess adapter (FTR-018).
4
+ //
5
+ // spawnClaudeAgent (US-03-TASK-BE-01) implements the SPAWN MECHANICS ONLY,
6
+ // per FTR-018-Tech-Spec.md section 7:
7
+ // - asynchronous child_process.spawn, shell:false explicitly, argv array
8
+ // (never a string-built shell command);
9
+ // - the executable path is validated (exists, is a file) BEFORE spawning,
10
+ // so a bad path fails closed with a clear error instead of a confusing
11
+ // bare ENOENT from spawn() itself;
12
+ // - the task prompt is fed through stdin (write + end), never appended to
13
+ // argv or a shell command — argv/logs must never carry the prompt or
14
+ // credentials;
15
+ // - stdout/stderr are drained with bounded buffering (maxBufferBytes per
16
+ // stream) and only assembled into a result AFTER the process has fully
17
+ // exited — this function never attempts to parse partial/streaming
18
+ // output;
19
+ // - exit 0 is necessary but not sufficient: once the process has exited,
20
+ // stdout is parsed as JSON and checked for the minimal stable envelope
21
+ // shape (`is_error` boolean) the CLI's --output-format json contract
22
+ // guarantees; malformed or schema-shaped-wrong output is reported via
23
+ // `parseError`, never silently coerced into a truthy result;
24
+ // - an optional taskTimeoutMs kills the process tree if exceeded, using
25
+ // the technique proven in the E-02 evidence spike (Windows-qualified):
26
+ // `taskkill /PID <pid> /T /F` (see
27
+ // internal_docs/features/FTR-018-deterministic-task-execution-checkpoints-and-resume/evidence/E-02-process-supervision/).
28
+ // Non-Windows platforms are not yet E-02-qualified (Gate-1 binding
29
+ // constraint #3: "no implicit cross-platform guarantee"), so the
30
+ // fallback there kills only the direct child best-effort and reports
31
+ // `terminationConfirmed: 'unknown'` rather than claiming a proof it does
32
+ // not have.
33
+ //
34
+ // Explicitly OUT of scope for this task (later Work Breakdown tasks own
35
+ // these):
36
+ // - agent provenance verification / `agents resolve --require-verified`
37
+ // (US-03-TASK-BE-02, verifyAgentIdentity);
38
+ // - task dispatch orchestration, ownership/lease coordination, and ledger
39
+ // activity persistence (US-03-TASK-BE-03, dispatchTaskAttempt — see
40
+ // lib/task-executor/index.js);
41
+ // - full validation of the CLI's structured output against a caller
42
+ // --json-schema, and any business-level "did the task actually
43
+ // succeed" judgment (verification/review stages, later tasks).
44
+ // spawnClaudeAgent returns raw ingredients (exit code, parsed/raw
45
+ // stdout+stderr, timing, termination evidence) for those later stages to
46
+ // build on — it does not itself decide whether a run "passed".
47
+ //
48
+ // US-03-TASK-BE-03 note: this module's own leftover `dispatchTask`
49
+ // NOT_IMPLEMENTED stub (a naming artifact from the INFRA-TASK-BE-03
50
+ // skeleton task) has been removed. The Work Breakdown's actual outcome for
51
+ // task dispatch orchestration ("dispatches single task per attempt; persists
52
+ // run/task/attempt identity and ledger activity before invocation") lives in
53
+ // lib/task-executor/index.js's dispatchTaskAttempt, not here — this module's
54
+ // job stays subprocess mechanics (spawnClaudeAgent) and identity
55
+ // verification (verifyAgentIdentity) only.
56
+
57
+ const fs = require('fs');
58
+ const path = require('path');
59
+ const crypto = require('crypto');
60
+ const { spawn, spawnSync } = require('child_process');
61
+
62
+ const IS_WINDOWS = process.platform === 'win32';
63
+ const DEFAULT_MAX_BUFFER_BYTES = 20 * 1024 * 1024; // 20 MiB per stream
64
+
65
+ // Absolute path to this toolkit's own CLI entry point. verifyAgentIdentity
66
+ // shells out to `ai-toolkit agents resolve --require-verified` via this
67
+ // exact file (never a globally-installed `ai-toolkit` on PATH), so identity
68
+ // verification is deterministic regardless of how the toolkit is installed
69
+ // in the caller's environment.
70
+ const CLI_PATH = path.join(__dirname, '..', '..', 'bin', 'cli.js');
71
+
72
+ // Per-process cache of verified agent identities, keyed by agentId.
73
+ // Policy: no TTL, no automatic invalidation — a resolution is trusted for
74
+ // the lifetime of the process once obtained, EXCEPT that the on-disk
75
+ // definition hash is always rechecked on every verifyAgentIdentity() call
76
+ // (see _verifyDefinitionHashMatches), cache hit or not. Callers that need a
77
+ // fresh CLI resolution (e.g. after an explicit reinstall) pass
78
+ // `bypassCache: true`.
79
+ const _identityCache = new Map();
80
+
81
+ function _err(code, message) {
82
+ const e = new Error(message);
83
+ e.code = code;
84
+ return e;
85
+ }
86
+
87
+ function _requireNonEmptyString(value, label) {
88
+ if (typeof value !== 'string' || value.length === 0) {
89
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', label + ' must be a non-empty string');
90
+ }
91
+ }
92
+
93
+ // Validates that `claudePath` exists and is a regular file before spawning,
94
+ // so a bad path fails closed here with a clear, attributable error rather
95
+ // than surfacing as spawn()'s own easily-confused-with-other-causes ENOENT.
96
+ function _validateExecutableSync(claudePath) {
97
+ _requireNonEmptyString(claudePath, 'spawnClaudeAgent: options.claudePath');
98
+
99
+ let stat;
100
+ try {
101
+ stat = fs.statSync(claudePath);
102
+ } catch (err) {
103
+ throw _err(
104
+ 'CLAUDE_EXECUTABLE_NOT_FOUND',
105
+ 'spawnClaudeAgent: executable not found at "' + claudePath + '" (' + err.code + ')'
106
+ );
107
+ }
108
+ if (!stat.isFile()) {
109
+ throw _err(
110
+ 'CLAUDE_EXECUTABLE_NOT_FOUND',
111
+ 'spawnClaudeAgent: path "' + claudePath + '" exists but is not a file'
112
+ );
113
+ }
114
+ }
115
+
116
+ function _validateCwdSync(cwd) {
117
+ _requireNonEmptyString(cwd, 'spawnClaudeAgent: options.cwd');
118
+ let stat;
119
+ try {
120
+ stat = fs.statSync(cwd);
121
+ } catch (err) {
122
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.cwd "' + cwd + '" does not exist (' + err.code + ')');
123
+ }
124
+ if (!stat.isDirectory()) {
125
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.cwd "' + cwd + '" is not a directory');
126
+ }
127
+ }
128
+
129
+ // Tree-kill via the exact E-02-qualified technique: `taskkill /PID <pid> /T
130
+ // /F` (Windows only). Runs synchronously so the caller knows the
131
+ // confirmed-terminated/not result before the timeout handler returns —
132
+ // there is no async race between "did the kill land" and the child's
133
+ // eventual 'close' event.
134
+ //
135
+ // Returns:
136
+ // true — Windows, taskkill exited 0 (subtree confirmed terminated,
137
+ // per the E-02 evidence spike).
138
+ // false — Windows, taskkill ran but did not report success.
139
+ // 'unknown' — non-Windows: this platform has no qualified tree-kill proof
140
+ // yet (Gate-1 binding constraint: "no implicit cross-platform
141
+ // guarantee"); a best-effort direct-child kill is attempted,
142
+ // but termination of the whole tree is never claimed.
143
+ function _killProcessTreeSync(child) {
144
+ if (IS_WINDOWS) {
145
+ const res = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
146
+ stdio: 'ignore',
147
+ windowsHide: true,
148
+ });
149
+ return !res.error && res.status === 0;
150
+ }
151
+ try {
152
+ child.kill('SIGKILL');
153
+ } catch (_) {
154
+ // best-effort only
155
+ }
156
+ return 'unknown';
157
+ }
158
+
159
+ // Validates the minimal, stable envelope the Claude CLI's
160
+ // `--output-format json` contract guarantees (per the E-01 evidence dossier
161
+ // real-run captures: `is_error` is always present as a boolean). This is
162
+ // NOT full schema validation against a caller-supplied --json-schema —
163
+ // that belongs to a later verification stage. Only ever called on fully
164
+ // buffered, post-exit stdout — never on partial output.
165
+ function _validateEnvelope(stdoutStr) {
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(stdoutStr);
169
+ } catch (err) {
170
+ return { result: null, parseError: 'stdout is not valid JSON: ' + err.message };
171
+ }
172
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
173
+ return { result: null, parseError: 'parsed stdout is not a JSON object' };
174
+ }
175
+ if (typeof parsed.is_error !== 'boolean') {
176
+ return { result: null, parseError: 'parsed stdout is missing the required "is_error" boolean field' };
177
+ }
178
+ return { result: parsed, parseError: null };
179
+ }
180
+
181
+ /**
182
+ * Spawns the Claude CLI executable as an asynchronous, non-shell subprocess
183
+ * and resolves once it has fully exited (or been killed after
184
+ * `taskTimeoutMs`). See the file-level comment for the full design and its
185
+ * explicit scope boundary.
186
+ *
187
+ * @param {object} options
188
+ * @param {string} options.claudePath - Absolute path to the qualified Claude
189
+ * CLI executable. Validated to exist and be a file before spawning.
190
+ * @param {string[]} [options.args] - CLI flags only (e.g. `--print
191
+ * --output-format json --agent <name>`). NEVER include the prompt here —
192
+ * it is fed via stdin.
193
+ * @param {string} options.prompt - Task prompt content, written to the
194
+ * child's stdin and then stdin is ended. Never placed in argv.
195
+ * @param {string} [options.cwd] - Explicit working directory for the child
196
+ * process. Defaults to `process.cwd()` if omitted, but is always passed
197
+ * explicitly to `spawn()`. Validated to exist and be a directory.
198
+ * @param {object} [options.env] - Explicit environment for the child.
199
+ * Defaults to `process.env`.
200
+ * @param {number} [options.taskTimeoutMs] - Optional per-task timeout
201
+ * budget. If exceeded before the process exits, the process tree is
202
+ * killed (see `_killProcessTreeSync`) and the result reports
203
+ * `timedOut: true` / `terminationConfirmed`.
204
+ * @param {number} [options.maxBufferBytes] - Bounded buffering ceiling per
205
+ * stream (default 20 MiB). If exceeded, further chunks on that stream are
206
+ * discarded, the process tree is killed to bound resource usage, and the
207
+ * result reports a `parseError` describing the overflow.
208
+ * @returns {Promise<{
209
+ * exitCode: ?number,
210
+ * signal: ?string,
211
+ * stdout: string,
212
+ * stderr: string,
213
+ * result: ?object,
214
+ * parseError: ?string,
215
+ * startedAt: string,
216
+ * endedAt: string,
217
+ * durationMs: number,
218
+ * timedOut: boolean,
219
+ * terminationConfirmed: (true|false|'unknown'|null)
220
+ * }>}
221
+ */
222
+ async function spawnClaudeAgent(options) {
223
+ options = options || {};
224
+
225
+ _validateExecutableSync(options.claudePath);
226
+
227
+ const args = options.args === undefined ? [] : options.args;
228
+ if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
229
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.args must be an array of strings when provided');
230
+ }
231
+
232
+ if (typeof options.prompt !== 'string') {
233
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.prompt must be a string (fed via stdin)');
234
+ }
235
+ const prompt = options.prompt;
236
+
237
+ const cwd = options.cwd === undefined ? process.cwd() : options.cwd;
238
+ _validateCwdSync(cwd);
239
+
240
+ const env = options.env === undefined ? process.env : options.env;
241
+
242
+ let taskTimeoutMs = null;
243
+ if (options.taskTimeoutMs !== undefined && options.taskTimeoutMs !== null) {
244
+ if (typeof options.taskTimeoutMs !== 'number' || !Number.isFinite(options.taskTimeoutMs) || options.taskTimeoutMs <= 0) {
245
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.taskTimeoutMs must be a positive finite number when provided');
246
+ }
247
+ taskTimeoutMs = options.taskTimeoutMs;
248
+ }
249
+
250
+ let maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES;
251
+ if (options.maxBufferBytes !== undefined && options.maxBufferBytes !== null) {
252
+ if (!Number.isInteger(options.maxBufferBytes) || options.maxBufferBytes <= 0) {
253
+ throw _err('CLAUDE_PROCESS_VALIDATION_ERROR', 'spawnClaudeAgent: options.maxBufferBytes must be a positive integer when provided');
254
+ }
255
+ maxBufferBytes = options.maxBufferBytes;
256
+ }
257
+
258
+ const startedAt = new Date();
259
+ const startedHr = process.hrtime.bigint();
260
+
261
+ return new Promise((resolve, reject) => {
262
+ let child;
263
+ try {
264
+ child = spawn(options.claudePath, args, {
265
+ cwd,
266
+ env,
267
+ shell: false, // never true: shell:true would risk command injection via args/prompt
268
+ stdio: ['pipe', 'pipe', 'pipe'],
269
+ windowsHide: true,
270
+ });
271
+ } catch (err) {
272
+ reject(_err('CLAUDE_SPAWN_FAILED', 'spawnClaudeAgent: failed to spawn executable: ' + err.message));
273
+ return;
274
+ }
275
+
276
+ let stdoutChunks = [];
277
+ let stderrChunks = [];
278
+ let stdoutBytes = 0;
279
+ let stderrBytes = 0;
280
+ let stdoutOverflowed = false;
281
+ let stderrOverflowed = false;
282
+ let overflowKillTriggered = false;
283
+
284
+ let settled = false;
285
+ let timedOut = false;
286
+ let terminationConfirmed = null;
287
+ let timeoutHandle = null;
288
+
289
+ function clearTimer() {
290
+ if (timeoutHandle) {
291
+ clearTimeout(timeoutHandle);
292
+ timeoutHandle = null;
293
+ }
294
+ }
295
+
296
+ function killForOverflow() {
297
+ if (overflowKillTriggered) return;
298
+ overflowKillTriggered = true;
299
+ _killProcessTreeSync(child);
300
+ }
301
+
302
+ function finalize(exitCode, signal) {
303
+ if (settled) return;
304
+ settled = true;
305
+ clearTimer();
306
+
307
+ const endedAt = new Date();
308
+ const durationMs = Number(process.hrtime.bigint() - startedHr) / 1e6;
309
+
310
+ const stdoutStr = Buffer.concat(stdoutChunks).toString('utf8');
311
+ const stderrStr = Buffer.concat(stderrChunks).toString('utf8');
312
+
313
+ let result = null;
314
+ let parseError = null;
315
+ if (stdoutOverflowed || stderrOverflowed) {
316
+ parseError =
317
+ (stdoutOverflowed ? 'stdout' : 'stderr') +
318
+ ' exceeded maxBufferBytes (' + maxBufferBytes + ') before the process exited';
319
+ } else {
320
+ const validated = _validateEnvelope(stdoutStr);
321
+ result = validated.result;
322
+ parseError = validated.parseError;
323
+ }
324
+
325
+ resolve({
326
+ exitCode: exitCode === undefined ? null : exitCode,
327
+ signal: signal === undefined ? null : signal,
328
+ stdout: stdoutStr,
329
+ stderr: stderrStr,
330
+ result,
331
+ parseError,
332
+ startedAt: startedAt.toISOString(),
333
+ endedAt: endedAt.toISOString(),
334
+ durationMs,
335
+ timedOut,
336
+ terminationConfirmed,
337
+ });
338
+ }
339
+
340
+ child.stdout.on('data', (chunk) => {
341
+ if (stdoutOverflowed) return;
342
+ stdoutBytes += chunk.length;
343
+ if (stdoutBytes > maxBufferBytes) {
344
+ stdoutOverflowed = true;
345
+ killForOverflow();
346
+ return;
347
+ }
348
+ stdoutChunks.push(chunk);
349
+ });
350
+
351
+ child.stderr.on('data', (chunk) => {
352
+ if (stderrOverflowed) return;
353
+ stderrBytes += chunk.length;
354
+ if (stderrBytes > maxBufferBytes) {
355
+ stderrOverflowed = true;
356
+ killForOverflow();
357
+ return;
358
+ }
359
+ stderrChunks.push(chunk);
360
+ });
361
+
362
+ child.on('error', (err) => {
363
+ if (settled) return;
364
+ settled = true;
365
+ clearTimer();
366
+ reject(_err('CLAUDE_SPAWN_FAILED', 'spawnClaudeAgent: child process error: ' + err.message));
367
+ });
368
+
369
+ child.on('close', (code, signal) => {
370
+ finalize(code, signal);
371
+ });
372
+
373
+ if (taskTimeoutMs !== null) {
374
+ timeoutHandle = setTimeout(() => {
375
+ if (settled) return;
376
+ timedOut = true;
377
+ terminationConfirmed = _killProcessTreeSync(child);
378
+ }, taskTimeoutMs);
379
+ }
380
+
381
+ // Feed the prompt via stdin, then end the stream — never as a CLI arg
382
+ // or shell-interpolated string (per Tech-Spec section 7).
383
+ child.stdin.on('error', () => {
384
+ // Swallow EPIPE/EOF races when the child exits before stdin is fully
385
+ // drained; 'close' is the source of truth for the result, not
386
+ // whether this write succeeded.
387
+ });
388
+ child.stdin.write(prompt, 'utf8');
389
+ child.stdin.end();
390
+ });
391
+ }
392
+
393
+ // Rereads the definition file at `identity.path` and recomputes its SHA-256,
394
+ // comparing it against `identity.sha256` (the hash the `agents resolve` CLI
395
+ // call reported). This is the TOCTOU defense described in Tech-Spec section
396
+ // 7 ("reread definition hash"): a resolution performed moments ago (or
397
+ // served from cache) is not proof of what is on disk right now. Runs on
398
+ // EVERY verifyAgentIdentity() call, cache hit or not — the cache only skips
399
+ // the expensive CLI shell-out, never this cheap local recheck.
400
+ function _verifyDefinitionHashMatches(identity) {
401
+ let computed;
402
+ try {
403
+ computed = 'sha256:' + crypto.createHash('sha256').update(fs.readFileSync(identity.path)).digest('hex');
404
+ } catch (err) {
405
+ throw _err(
406
+ 'DEFINITION_HASH_MISMATCH',
407
+ 'verifyAgentIdentity: failed to reread definition file at "' + identity.path +
408
+ '" for TOCTOU recheck (' + err.code + ')'
409
+ );
410
+ }
411
+ if (computed !== identity.sha256) {
412
+ throw _err(
413
+ 'DEFINITION_HASH_MISMATCH',
414
+ 'verifyAgentIdentity: definition file at "' + identity.path + '" no longer matches the hash ' +
415
+ 'reported by "agents resolve" — expected ' + identity.sha256 + ', recomputed ' + computed +
416
+ '. The file changed between resolution and use; refusing to treat it as verified.'
417
+ );
418
+ }
419
+ }
420
+
421
+ // Shells out to this toolkit's own `ai-toolkit agents resolve --require-verified`
422
+ // CLI (real child process, shell:false, argv array — never a shell-interpolated
423
+ // string) and parses its JSON envelope. Fails closed (AGENT_NOT_VERIFIED) on
424
+ // any non-zero exit, spawn failure, non-JSON stdout, or a parsed status other
425
+ // than "verified" — this is the only path by which an identity enters the
426
+ // cache, so nothing unverified can ever be cached or returned.
427
+ function _resolveAgentIdentityViaCli(projectDir, agentId, home) {
428
+ const args = [CLI_PATH, 'agents', 'resolve', '--project', projectDir, '--id', agentId, '--require-verified'];
429
+ if (home !== undefined && home !== null) {
430
+ args.push('--home', home);
431
+ }
432
+
433
+ const res = spawnSync(process.execPath, args, { shell: false, encoding: 'utf8' });
434
+
435
+ if (res.error) {
436
+ throw _err(
437
+ 'AGENT_NOT_VERIFIED',
438
+ 'verifyAgentIdentity: failed to invoke "agents resolve" for "' + agentId + '": ' + res.error.message
439
+ );
440
+ }
441
+
442
+ if (res.status !== 0) {
443
+ const stderrText = (res.stderr || '').trim();
444
+ let parsedError = null;
445
+ try {
446
+ parsedError = JSON.parse(stderrText);
447
+ } catch (_) {
448
+ // stderr wasn't the structured JSON error record; fall through with raw text.
449
+ }
450
+ const status = parsedError && parsedError.status ? parsedError.status : 'unknown';
451
+ const reason = parsedError && parsedError.error ? parsedError.error : (stderrText || 'no stderr detail');
452
+ throw _err(
453
+ 'AGENT_NOT_VERIFIED',
454
+ 'verifyAgentIdentity: agent "' + agentId + '" is not verified (status: ' + status + '): ' + reason
455
+ );
456
+ }
457
+
458
+ let record;
459
+ try {
460
+ record = JSON.parse(res.stdout);
461
+ } catch (err) {
462
+ throw _err(
463
+ 'AGENT_NOT_VERIFIED',
464
+ 'verifyAgentIdentity: "agents resolve" exited 0 but stdout was not valid JSON: ' + err.message
465
+ );
466
+ }
467
+
468
+ const expected = require('../agent-registry').CATALOG.find(e => e.agentId === agentId && e.role !== 'orchestrator');
469
+ if (!expected || !record || record.status !== 'verified' || record.agentId !== agentId || record.nativeName !== expected.nativeNames.claude) {
470
+ throw _err(
471
+ 'AGENT_NOT_VERIFIED',
472
+ 'verifyAgentIdentity: agent "' + agentId + '" resolution status is "' +
473
+ (record && record.status) + '", not "verified"'
474
+ );
475
+ }
476
+
477
+ return {
478
+ agentId: record.agentId,
479
+ nativeName: record.nativeName,
480
+ sha256: record.sha256,
481
+ path: record.path,
482
+ manifestPath: record.manifestPath,
483
+ toolkitVersion: record.toolkitVersion,
484
+ scope: record.scope,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Verifies agent provenance per the FTR-017 contract before a later dispatch
490
+ * call (US-03-TASK-BE-03, not implemented yet) is allowed to treat an agent
491
+ * as safe to invoke. This function performs ONLY the identity/provenance
492
+ * verification precondition — it never dispatches or spawns the agent
493
+ * itself (see spawnClaudeAgent for that).
494
+ *
495
+ * Design (Tech-Spec section 7 — "operational resolveAgent for canonical ID,
496
+ * record nativeName/path/sha256/manifestPath/toolkitVersion/scope, reread
497
+ * definition hash"):
498
+ * 1. Resolve the agent's identity by shelling out to the real
499
+ * `ai-toolkit agents resolve --project <projectDir> --id <agentId>
500
+ * --require-verified` CLI (never mocked/faked — it is a cheap,
501
+ * deterministic, local, no-LLM-cost call), UNLESS an identity for this
502
+ * agentId is already cached and `bypassCache` was not requested.
503
+ * 2. Fail closed with AGENT_NOT_VERIFIED on a non-zero exit, a spawn
504
+ * failure, non-JSON stdout, or any resolved status other than
505
+ * "verified" — this function never returns an identity for an
506
+ * unverified agent.
507
+ * 3. Cache policy: per-process, keyed by agentId, no TTL. A resolved
508
+ * identity is reused across calls for the same agentId to avoid
509
+ * repeated shell-outs; pass `options.bypassCache: true` to force a
510
+ * fresh CLI resolution (e.g. after a reinstall).
511
+ * 4. Regardless of cache hit/miss, reread the definition file at the
512
+ * resolved `path` from disk and recompute its SHA-256, comparing it to
513
+ * the resolved `sha256` — this defends against a TOCTOU where the file
514
+ * changes between resolution and actual use. Throws
515
+ * DEFINITION_HASH_MISMATCH if the file no longer matches.
516
+ *
517
+ * @param {object} options
518
+ * @param {string} options.projectDir - project root passed to `--project`.
519
+ * @param {string} options.agentId - canonical agent ID passed to `--id`.
520
+ * @param {string} [options.home] - optional override passed to `--home`
521
+ * (used by tests for scope isolation; omitted in production so the CLI
522
+ * resolves against the real user home).
523
+ * @param {boolean} [options.bypassCache] - when true, always re-invokes the
524
+ * CLI even if this agentId's identity is already cached.
525
+ * @returns {{
526
+ * agentId: string,
527
+ * nativeName: string,
528
+ * sha256: string,
529
+ * path: string,
530
+ * manifestPath: string,
531
+ * toolkitVersion: string,
532
+ * scope: string
533
+ * }} the verified identity record.
534
+ * @throws {Error} code AGENT_NOT_VERIFIED when the CLI resolution does not
535
+ * yield status "verified"; code DEFINITION_HASH_MISMATCH when the on-disk
536
+ * definition no longer matches the resolved hash.
537
+ */
538
+ function verifyAgentIdentity(options) {
539
+ options = options || {};
540
+ _requireNonEmptyString(options.projectDir, 'verifyAgentIdentity: options.projectDir');
541
+ _requireNonEmptyString(options.agentId, 'verifyAgentIdentity: options.agentId');
542
+
543
+ const expected = require('../agent-registry').CATALOG.find(e => e.agentId === options.agentId && e.role !== 'orchestrator');
544
+ if (!expected) throw _err('AGENT_NOT_VERIFIED', 'Unknown or non-worker canonical ID: ' + options.agentId);
545
+ const cacheKey = JSON.stringify([path.resolve(options.projectDir), options.home ? path.resolve(options.home) : require('os').homedir(), options.agentId]);
546
+ const bypassCache = options.bypassCache === true;
547
+
548
+ let identity = bypassCache ? undefined : _identityCache.get(cacheKey);
549
+
550
+ if (!identity) {
551
+ identity = _resolveAgentIdentityViaCli(options.projectDir, options.agentId, options.home);
552
+ _identityCache.set(cacheKey, identity);
553
+ }
554
+
555
+ _verifyDefinitionHashMatches(identity);
556
+
557
+ return identity;
558
+ }
559
+
560
+ module.exports = {
561
+ spawnClaudeAgent,
562
+ verifyAgentIdentity,
563
+ };