@dtlabs/ai-toolkit 0.12.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.
- package/CHANGELOG.md +72 -0
- package/bin/cli.js +851 -9
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +95 -0
- package/lib/agent-registry.js +1018 -0
- package/lib/execution-ledger.js +283 -7
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +219 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/{concurrency-safety-assessment.md → gaia-concurrency-safety-assessment.md} +1 -1
- package/src/claude/agents/{define-feature.md → gaia-define-feature.md} +1 -1
- package/src/claude/agents/{dependency-injection-refactoring.md → gaia-dependency-injection-refactoring.md} +1 -1
- package/src/claude/agents/{dependency-supply-chain-security.md → gaia-dependency-supply-chain-security.md} +1 -1
- package/src/claude/agents/{developer-backend.md → gaia-developer-backend.md} +1 -1
- package/src/claude/agents/{developer-frontend.md → gaia-developer-frontend.md} +1 -1
- package/src/claude/agents/{developer-testing.md → gaia-developer-testing.md} +1 -1
- package/src/claude/agents/{domain-model-refactoring.md → gaia-domain-model-refactoring.md} +1 -1
- package/src/claude/agents/{generate-requirements.md → gaia-generate-requirements.md} +1 -1
- package/src/claude/agents/{generate-tech-spec.md → gaia-generate-tech-spec.md} +1 -1
- package/src/claude/agents/{generate-work-breakdown.md → gaia-generate-work-breakdown.md} +5 -5
- package/src/claude/agents/{generic-software-assessment.md → gaia-generic-software-assessment.md} +1 -1
- package/src/claude/agents/{god-class-decomposition.md → gaia-god-class-decomposition.md} +1 -1
- package/src/claude/agents/{init-agents-md.md → gaia-init-agents-md.md} +1 -1
- package/src/claude/agents/{install-toolkit.md → gaia-install-toolkit.md} +1 -1
- package/src/claude/agents/{intervention-documentation-standard.md → gaia-intervention-documentation-standard.md} +10 -10
- package/src/claude/agents/{layered-architecture-assessment.md → gaia-layered-architecture-assessment.md} +1 -1
- package/src/claude/agents/{review-solution.md → gaia-review-solution.md} +1 -1
- package/src/claude/agents/{security-hardening.md → gaia-security-hardening.md} +1 -1
- package/src/claude/agents/{validate-feature-docs.md → gaia-validate-feature-docs.md} +2 -2
- package/src/claude/agents/{validate-work-breakdown-semantic.md → gaia-validate-work-breakdown-semantic.md} +1 -1
- package/src/claude/commands/next-task.md +4 -4
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/define-feature/SKILL.md +2 -2
- package/src/claude/skills/hi-gaia/SKILL.md +1 -1
- package/src/claude/skills/implement-feature/SKILL.md +131 -38
- package/src/claude/skills/init-agents/SKILL.md +2 -2
- package/src/claude/skills/install-toolkit/SKILL.md +2 -2
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -287
- package/src/claude/workflows/pm-phase2.js +51 -511
- package/src/claude/workflows/pm-phase3.js +5 -767
|
@@ -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
|
+
};
|