agentproc 0.2.1 → 0.4.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/package.json +2 -2
- package/src/cli.js +346 -99
- package/src/conformance.test.js +29 -0
- package/src/hub.js +483 -0
- package/src/hub.test.js +345 -0
- package/src/runner.js +239 -18
package/src/runner.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* protocol-compliant agent invocation.
|
|
5
5
|
*
|
|
6
6
|
* This module is the canonical implementation of the AgentProc bridge-side
|
|
7
|
-
* contract (spec/protocol.md). The CLI (cli.js) is a thin wrapper around it.
|
|
7
|
+
* contract (spec/protocol.md, wire protocol 0.1). The CLI (cli.js) is a thin wrapper around it.
|
|
8
8
|
*
|
|
9
9
|
* Responsibilities:
|
|
10
10
|
* - Parse and validate a profile object
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
const { spawn } = require('node:child_process');
|
|
29
|
+
const fs = require('node:fs');
|
|
29
30
|
const path = require('node:path');
|
|
30
31
|
const os = require('node:os');
|
|
31
32
|
|
|
@@ -74,17 +75,43 @@ function normalizeProfile(raw) {
|
|
|
74
75
|
throw new Error('profile.command must be a non-empty string');
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Per spec: `command` is argv[0]; `args` is argv[1..]. Two mutually
|
|
79
|
+
// exclusive forms:
|
|
80
|
+
// (a) `args` absent + command has whitespace → split command into argv
|
|
81
|
+
// (the legacy shorthand: `command: python3 ./bridge.py`)
|
|
82
|
+
// (b) `args` present (even empty `[]`) → command is a single token,
|
|
83
|
+
// never split. Lets paths with spaces stay whole:
|
|
84
|
+
// command: "/path with spaces/my agent"
|
|
85
|
+
// args: []
|
|
86
|
+
// `args: []` (explicit empty array) is DISTINCT from "args absent": the
|
|
87
|
+
// explicit form means "do not split command"; the absent form falls back
|
|
88
|
+
// to the whitespace-splitting shorthand.
|
|
89
|
+
const argsFieldPresent = raw.agentproc
|
|
90
|
+
? (Object.prototype.hasOwnProperty.call(raw.agentproc, 'args') && raw.agentproc.args != null)
|
|
91
|
+
: (Object.prototype.hasOwnProperty.call(raw, 'args') && raw.args != null);
|
|
92
|
+
const argv = argsFieldPresent ? [p.command.trim()] : p.command.trim().split(/\s+/);
|
|
93
|
+
if (argv.length === 0 || argv[0] === '') {
|
|
80
94
|
throw new Error('profile.command produced empty argv');
|
|
81
95
|
}
|
|
82
96
|
|
|
97
|
+
// env_allowlist (optional): when present, ${VAR} references in the env
|
|
98
|
+
// block whose name is NOT in the list expand to empty + a stderr warning.
|
|
99
|
+
// Absent ⇒ current behaviour (expand against the full bridge environment).
|
|
100
|
+
// Opt-in: existing profiles keep working unchanged.
|
|
101
|
+
let envAllowlist = null;
|
|
102
|
+
if (p.env_allowlist !== undefined && p.env_allowlist !== null) {
|
|
103
|
+
if (!Array.isArray(p.env_allowlist)) {
|
|
104
|
+
throw new Error('profile.env_allowlist must be a list');
|
|
105
|
+
}
|
|
106
|
+
envAllowlist = new Set(p.env_allowlist.map(String));
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
return {
|
|
84
110
|
argv,
|
|
85
111
|
args: Array.isArray(p.args) ? p.args.map(String) : [],
|
|
86
112
|
cwd: p.cwd ? expandPath(String(p.cwd)) : undefined,
|
|
87
113
|
env: p.env && typeof p.env === 'object' ? p.env : {},
|
|
114
|
+
env_allowlist: envAllowlist,
|
|
88
115
|
stdin: p.stdin === 'message' ? 'message' : 'none',
|
|
89
116
|
timeout_secs: Number.isFinite(p.timeout_secs) ? p.timeout_secs : DEFAULT_TIMEOUT_SECS,
|
|
90
117
|
kill_grace_secs: Number.isFinite(p.kill_grace_secs) ? p.kill_grace_secs : DEFAULT_KILL_GRACE_SECS,
|
|
@@ -100,22 +127,147 @@ function expandPath(p) {
|
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
130
|
+
* Best-effort pattern check against the agent's accumulated stderr to spot
|
|
131
|
+
* common "bridge file not found" / "module not found" failures that the
|
|
132
|
+
* wrapped interpreter writes to its own stderr before exiting non-zero.
|
|
133
|
+
* Returns a human-friendly hint, or '' if nothing recognizable.
|
|
134
|
+
*
|
|
135
|
+
* This is intentionally narrow — we only flag high-confidence patterns to
|
|
136
|
+
* avoid mis-diagnosing genuine agent errors.
|
|
137
|
+
*/
|
|
138
|
+
function diagnoseStderrFailure(stderrText, { argv }) {
|
|
139
|
+
if (!stderrText) return '';
|
|
140
|
+
const lower = stderrText.toLowerCase();
|
|
141
|
+
|
|
142
|
+
// python3: "can't open file '/path/x.py': [Errno 2] No such file or directory"
|
|
143
|
+
// Also covers "cannot open file" (localized variants).
|
|
144
|
+
const pyMatch = stderrText.match(/(?:can'?t|cannot) open file '([^']+)': \[Errno 2\] No such file or directory/);
|
|
145
|
+
if (pyMatch) {
|
|
146
|
+
const file = pyMatch[1];
|
|
147
|
+
return `agent script not found: ${file}. Check the profile's command path (likely a {{PROFILE_DIR}} issue or a typo).`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// node: "Error: Cannot find module '/path/x.js'"
|
|
151
|
+
const nodeMatch = stderrText.match(/Cannot find module '([^']+)'/);
|
|
152
|
+
if (nodeMatch) {
|
|
153
|
+
const mod = nodeMatch[1];
|
|
154
|
+
return `agent script not found: ${mod}. Check the profile's command path (likely a {{PROFILE_DIR}} issue or a typo).`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// bash: "bash: line N: ./x.sh: No such file or directory"
|
|
158
|
+
const bashMatch = stderrText.match(/(?:^|\n)[^:]+: line \d+: ([^:]+): No such file or directory/);
|
|
159
|
+
if (bashMatch) {
|
|
160
|
+
const file = bashMatch[1];
|
|
161
|
+
return `agent script not found: ${file}. Check the profile's command path.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Generic Errno 2 sentinel, in case the interpreter phrasing differs.
|
|
165
|
+
if (/errno 2|enoent|no such file or directory/.test(lower)) {
|
|
166
|
+
return `agent reported a missing file. Check the profile's command and cwd.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Produce a human-friendly hint for a spawn ENOENT-style error.
|
|
174
|
+
*
|
|
175
|
+
* Node's spawn attributes the error to argv[0] regardless of whether it was
|
|
176
|
+
* the command itself or a referenced file (e.g. `./bridge.py`) that wasn't
|
|
177
|
+
* found, which is very confusing. We inspect cwd + argv to give a better
|
|
178
|
+
* diagnosis. Returns '' when nothing useful can be said.
|
|
179
|
+
*/
|
|
180
|
+
function diagnoseSpawnError(err, { argv, cwd, env }) {
|
|
181
|
+
const code = err && err.code;
|
|
182
|
+
const message = (err && err.message) || '';
|
|
183
|
+
if (code !== 'ENOENT' && !/ENOENT/.test(message)) return '';
|
|
184
|
+
|
|
185
|
+
// (a) cwd doesn't exist or isn't a directory
|
|
186
|
+
if (cwd) {
|
|
187
|
+
try {
|
|
188
|
+
const stat = fs.statSync(cwd);
|
|
189
|
+
if (!stat.isDirectory()) {
|
|
190
|
+
return `profile.cwd is not a directory: ${cwd}`;
|
|
191
|
+
}
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
|
|
194
|
+
return `profile.cwd is not accessible (permission denied): ${cwd}`;
|
|
195
|
+
}
|
|
196
|
+
return `profile.cwd does not exist: ${cwd}. Pass --cwd <path> to point at a real directory.`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// (b) the command (argv[0]) is not on PATH
|
|
201
|
+
const cmd = argv[0];
|
|
202
|
+
const isPathed = /[\\/]/.test(cmd);
|
|
203
|
+
if (!isPathed) {
|
|
204
|
+
// Bare command like 'python3' or 'claude' — check PATH ourselves.
|
|
205
|
+
const PATH = (env && env.PATH) || '';
|
|
206
|
+
if (PATH) {
|
|
207
|
+
const found = PATH.split(path.delimiter).some(d => {
|
|
208
|
+
try {
|
|
209
|
+
const p = path.join(d, cmd);
|
|
210
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
211
|
+
return true;
|
|
212
|
+
} catch { return false; }
|
|
213
|
+
});
|
|
214
|
+
if (!found) {
|
|
215
|
+
return `'${cmd}' not found on PATH. Install it, or if it's installed, make sure PATH is set correctly when the bridge spawns the agent.`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return `'${cmd}' could not be executed. Verify it is installed and on PATH.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// (c) argv[0] looks like a path — check whether the file itself exists
|
|
222
|
+
try {
|
|
223
|
+
fs.accessSync(cmd, fs.constants.X_OK);
|
|
224
|
+
} catch {
|
|
225
|
+
return `command path does not exist or is not executable: ${cmd}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// (d) Command exists; suspect an argv file argument (e.g. python3 ./bridge.py).
|
|
229
|
+
for (let i = 1; i < argv.length; i++) {
|
|
230
|
+
const a = argv[i];
|
|
231
|
+
if (!a.startsWith('-') && (a.includes('/') || a.includes('\\'))) {
|
|
232
|
+
// Resolve relative to cwd (mirrors spawn's resolution)
|
|
233
|
+
const resolved = path.isAbsolute(a) ? a : (cwd ? path.resolve(cwd, a) : path.resolve(a));
|
|
234
|
+
try {
|
|
235
|
+
fs.accessSync(resolved, fs.constants.R_OK);
|
|
236
|
+
} catch {
|
|
237
|
+
return `argument file not found: ${a} (resolved to ${resolved}). The profile likely needs --cwd or the bundled script path is wrong.`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Substitute {{MESSAGE}}, {{SESSION_ID}}, {{SESSION_NAME}}, {{PROFILE_DIR}}
|
|
247
|
+
* placeholders in a string value. Per spec, no shell is involved.
|
|
105
248
|
*/
|
|
106
249
|
function substitute(value, ctx) {
|
|
107
250
|
return String(value)
|
|
108
251
|
.replace(/\{\{MESSAGE\}\}/g, ctx.message || '')
|
|
109
252
|
.replace(/\{\{SESSION_ID\}\}/g, ctx.sessionId || '')
|
|
110
|
-
.replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '')
|
|
253
|
+
.replace(/\{\{SESSION_NAME\}\}/g, ctx.sessionName || '')
|
|
254
|
+
.replace(/\{\{PROFILE_DIR\}\}/g, ctx.profileDir || '');
|
|
111
255
|
}
|
|
112
256
|
|
|
113
257
|
/**
|
|
114
|
-
* Expand ${VAR} references against
|
|
258
|
+
* Expand ${VAR} references against `env`, like a typical shell would.
|
|
115
259
|
* Unknown variables expand to empty string (POSIX sh behavior).
|
|
260
|
+
*
|
|
261
|
+
* When `allowlist` is a Set of names, references to names NOT in the set
|
|
262
|
+
* expand to empty and `onBlocked` (if given) is called with each blocked
|
|
263
|
+
* name. When `allowlist` is null, all references expand normally.
|
|
116
264
|
*/
|
|
117
|
-
function expandEnvRef(value, env) {
|
|
265
|
+
function expandEnvRef(value, env, allowlist = null, onBlocked = null) {
|
|
118
266
|
return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
267
|
+
if (allowlist && !allowlist.has(name)) {
|
|
268
|
+
if (onBlocked) onBlocked(name);
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
119
271
|
const v = env[name];
|
|
120
272
|
return v !== undefined ? v : '';
|
|
121
273
|
});
|
|
@@ -132,13 +284,18 @@ function expandEnvRef(value, env) {
|
|
|
132
284
|
function decodeJsonValue(raw) {
|
|
133
285
|
const text = raw.trim();
|
|
134
286
|
if (text === '') return '';
|
|
287
|
+
let v;
|
|
135
288
|
try {
|
|
136
|
-
|
|
137
|
-
return typeof v === 'string' ? v : String(v);
|
|
289
|
+
v = JSON.parse(text);
|
|
138
290
|
} catch {
|
|
139
291
|
// Lenient: treat as plain string.
|
|
140
292
|
return text;
|
|
141
293
|
}
|
|
294
|
+
// Only JSON strings are meaningful payloads — a sentinel's value is text
|
|
295
|
+
// for the user. Non-string JSON (number/bool/null/array/object) means the
|
|
296
|
+
// agent misused the API; fall back to the raw text so the result is
|
|
297
|
+
// language-independent (String(true) != str(True) across runtimes).
|
|
298
|
+
return typeof v === 'string' ? v : text;
|
|
142
299
|
}
|
|
143
300
|
|
|
144
301
|
/**
|
|
@@ -211,17 +368,28 @@ async function run(profileRaw, options) {
|
|
|
211
368
|
const sessionName = options.sessionName || 'default';
|
|
212
369
|
const streaming = options.streaming !== undefined ? !!options.streaming : profile.streaming;
|
|
213
370
|
const timeoutSecs = options.timeoutSecs !== undefined ? options.timeoutSecs : profile.timeout_secs;
|
|
214
|
-
|
|
371
|
+
let cwd = options.cwd || profile.cwd;
|
|
372
|
+
// Resolve relative cwd against the profile's directory (if known) so that
|
|
373
|
+
// profiles written as `cwd: .` work no matter where the user invokes from.
|
|
374
|
+
// Absolute paths and `~`-prefixed paths are already absolute post-expand.
|
|
375
|
+
if (cwd && !path.isAbsolute(cwd) && options.profileDir) {
|
|
376
|
+
cwd = path.resolve(options.profileDir, cwd);
|
|
377
|
+
}
|
|
215
378
|
|
|
216
379
|
// Build the substitution context for {{MESSAGE}} etc.
|
|
380
|
+
// {{PROFILE_DIR}} resolves to the directory the profile YAML lives in
|
|
381
|
+
// (passed by the CLI; undefined when run programmatically without it),
|
|
382
|
+
// letting profiles reference bundled scripts via absolute paths while
|
|
383
|
+
// still allowing the agent's cwd to be anywhere.
|
|
217
384
|
const substCtx = {
|
|
218
385
|
message: options.message,
|
|
219
386
|
sessionId,
|
|
220
387
|
sessionName,
|
|
388
|
+
profileDir: options.profileDir || '',
|
|
221
389
|
};
|
|
222
390
|
|
|
223
391
|
// Build argv: command + args (with placeholders substituted).
|
|
224
|
-
const argv =
|
|
392
|
+
const argv = profile.argv.map(a => substitute(a, substCtx));
|
|
225
393
|
for (const a of profile.args) {
|
|
226
394
|
argv.push(substitute(a, substCtx));
|
|
227
395
|
}
|
|
@@ -229,8 +397,13 @@ async function run(profileRaw, options) {
|
|
|
229
397
|
// Build env: start with process.env (so PATH etc. work), add profile.env
|
|
230
398
|
// (with ${VAR} refs expanded against process.env), then add AGENT_* vars.
|
|
231
399
|
const env = { ...process.env };
|
|
400
|
+
const allowlist = profile.env_allowlist;
|
|
232
401
|
for (const [k, v] of Object.entries(profile.env)) {
|
|
233
|
-
env[k] = expandEnvRef(substitute(v, substCtx), process.env)
|
|
402
|
+
env[k] = expandEnvRef(substitute(v, substCtx), process.env, allowlist, (name) => {
|
|
403
|
+
if (options.onStderr) {
|
|
404
|
+
options.onStderr(`[agentproc runner] env_allowlist blocked \${${name}} (not in allowlist); expanded to empty`);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
234
407
|
}
|
|
235
408
|
if (options.extraEnv) {
|
|
236
409
|
for (const [k, v] of Object.entries(options.extraEnv)) {
|
|
@@ -303,8 +476,23 @@ async function run(profileRaw, options) {
|
|
|
303
476
|
|
|
304
477
|
// ---- stderr: forward as debug ----
|
|
305
478
|
let stderrBuf = '';
|
|
479
|
+
// Two views on stderr:
|
|
480
|
+
// - stderrWindow: bounded sliding window (8 KB) — reserved for future
|
|
481
|
+
// UI/display use so a noisy agent cannot exhaust memory.
|
|
482
|
+
// - stderrFull: unbounded capture used for post-mortem pattern
|
|
483
|
+
// diagnosis. Without the full text, a chatty agent can push the real
|
|
484
|
+
// error out of the window and the friendly hint goes missing.
|
|
485
|
+
let stderrWindow = '';
|
|
486
|
+
let stderrFull = '';
|
|
487
|
+
const STDERR_CAP = 8192;
|
|
306
488
|
child.stderr.on('data', chunk => {
|
|
307
|
-
|
|
489
|
+
const text = chunk.toString();
|
|
490
|
+
stderrBuf += text;
|
|
491
|
+
stderrFull += text;
|
|
492
|
+
stderrWindow += text;
|
|
493
|
+
if (stderrWindow.length > STDERR_CAP) {
|
|
494
|
+
stderrWindow = stderrWindow.slice(stderrWindow.length - STDERR_CAP);
|
|
495
|
+
}
|
|
308
496
|
let nl;
|
|
309
497
|
while ((nl = stderrBuf.indexOf('\n')) >= 0) {
|
|
310
498
|
const line = stderrBuf.slice(0, nl);
|
|
@@ -320,6 +508,11 @@ async function run(profileRaw, options) {
|
|
|
320
508
|
}
|
|
321
509
|
|
|
322
510
|
// ---- timeout handling per spec: SIGTERM → grace → SIGKILL ----
|
|
511
|
+
// On POSIX, child.kill('SIGTERM') is a real signal the agent can trap and
|
|
512
|
+
// flush; on Windows, Node translates any signal name to TerminateProcess,
|
|
513
|
+
// so the grace period is effectively a no-op there. The two-step shape is
|
|
514
|
+
// preserved so POSIX behaviour is correct; Windows callers get a hard kill
|
|
515
|
+
// at the deadline (acceptable per the spec's Windows caveat).
|
|
323
516
|
let timer = null;
|
|
324
517
|
if (timeoutSecs > 0) {
|
|
325
518
|
timer = setTimeout(() => {
|
|
@@ -340,8 +533,20 @@ async function run(profileRaw, options) {
|
|
|
340
533
|
const exitCode = await new Promise(resolve => {
|
|
341
534
|
child.on('close', code => resolve(code));
|
|
342
535
|
child.on('error', err => {
|
|
343
|
-
// spawn error
|
|
344
|
-
|
|
536
|
+
// spawn error — usually ENOENT. Node attributes it to argv[0]
|
|
537
|
+
// regardless of whether it was the command or a referenced file that
|
|
538
|
+
// wasn't found, so disambiguate for the user.
|
|
539
|
+
const tip = diagnoseSpawnError(err, { argv, cwd, env });
|
|
540
|
+
if (options.onStderr) {
|
|
541
|
+
options.onStderr(`[agentproc runner] spawn error: ${err.message}`);
|
|
542
|
+
if (tip) options.onStderr(`[agentproc runner] hint: ${tip}`);
|
|
543
|
+
}
|
|
544
|
+
// Surface as an AGENT_ERROR so the user sees it on the bridge too.
|
|
545
|
+
if (options.onError) {
|
|
546
|
+
const msg = tip || err.message;
|
|
547
|
+
options.onError(`failed to start agent: ${msg}`);
|
|
548
|
+
}
|
|
549
|
+
if (!result.error) result.error = tip || err.message;
|
|
345
550
|
resolve(EXIT_ERROR);
|
|
346
551
|
});
|
|
347
552
|
});
|
|
@@ -353,7 +558,23 @@ async function run(profileRaw, options) {
|
|
|
353
558
|
handleLine(stdoutBuf.replace(/\r$/, ''));
|
|
354
559
|
}
|
|
355
560
|
|
|
356
|
-
//
|
|
561
|
+
// Flush any remaining stderr (the chunk handler only emits on newlines).
|
|
562
|
+
if (stderrBuf.length > 0) {
|
|
563
|
+
if (options.onStderr) options.onStderr(stderrBuf.replace(/\r$/, ''));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// If the agent exited non-zero with no AGENT_ERROR, peek at its stderr for
|
|
567
|
+
// common "command/file not found" patterns and surface a friendly hint.
|
|
568
|
+
// Uses the FULL stderr — a noisy agent can fill the 8 KB window with
|
|
569
|
+
// progress junk before the real error lands at the end.
|
|
570
|
+
if (!killed && !result.error && exitCode !== 0) {
|
|
571
|
+
const hint = diagnoseStderrFailure(stderrFull, { argv });
|
|
572
|
+
if (hint) {
|
|
573
|
+
result.error = hint;
|
|
574
|
+
if (options.onError) options.onError(hint);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
357
578
|
result.reply = bodyLines.join('\n');
|
|
358
579
|
if (result.reply.length > profile.max_reply_chars) {
|
|
359
580
|
const suffix = profile.max_reply_chars === DEFAULT_MAX_REPLY_CHARS
|