bare-agent 0.54.0 → 0.54.1

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.
@@ -425,7 +425,7 @@ naming the opt-in — rather than silently ignoring it. Pass `confirmServer(name
425
425
  def) => boolean` to approve each server **before its command is spawned** (return
426
426
  `false` to skip it; a throw fails closed). When no `confirmServer` is set, the
427
427
  bridge still trusts all *discovered* servers and prints a one-time warning naming
428
- every command it is about to spawn — `confirmServer` is how you actually *gate* it.
428
+ every server and command it is about to spawn (an arg count only, never arg or env values) — `confirmServer` is how you actually *gate* it.
429
429
 
430
430
  **RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
431
431
  server that never answers can't hang the bridge or the loop. `opts.timeout`
@@ -435,8 +435,13 @@ timed-out tool call rejects with a labelled `ToolError`
435
435
  (`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
436
436
  stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
437
437
  non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
438
- than blocking forever; a server that breaks its stdin pipe surfaces as a
439
- failed connection, never an uncaught `EPIPE` crash.
438
+ than blocking forever; a handshake timeout (`initialize`/`tools/list`) is labelled the same way
439
+ (`MCP server "s" "method" timed out after Nms`, `context: { timeout: true, server, method, timeoutMs, stderr }`);
440
+ a server that breaks its stdin pipe surfaces as a failed connection, never an uncaught `EPIPE`
441
+ crash; and a server whose `command` cannot start (ENOENT/EACCES, or a sync `spawn()` refusal such as an empty command or NUL byte) rejects with
442
+ `MCP server "s" failed to start: command "<cmd>" (<code>)`, `context: { spawnError: true, server, code }`
443
+ (`code` = Node error code if a string, else `'ESPAWN'`; command only with control chars escaped, never args/env) — `listServerTools` throws it, `allow` mode puts it in `errors` + `missing`,
444
+ bulk mode in `errors` — instead of crashing the host.
440
445
 
441
446
  ## Wiring with bareguard
442
447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/mcp-bridge.js CHANGED
@@ -231,6 +231,23 @@ function resolveEnv(env) {
231
231
 
232
232
  // --- JSON-RPC stdio client ---
233
233
 
234
+ /**
235
+ * Labelled spawn-failure error. Names the command ONLY (args/env can carry tokens); control
236
+ * characters in the command are escaped so a NUL/newline can't corrupt logs. `code` is the Node
237
+ * error code when it is a string, else 'ESPAWN'.
238
+ * @param {string} name
239
+ * @param {any} command
240
+ * @param {any} err
241
+ * @returns {ToolError}
242
+ */
243
+ function spawnFailure(name, command, err) {
244
+ const code = typeof err?.code === 'string' ? err.code : 'ESPAWN';
245
+ const shown = String(command).replace(/[\u0000-\u001f\u007f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
246
+ return new ToolError(`MCP server "${name}" failed to start: command "${shown}" (${code})`, {
247
+ context: { spawnError: true, server: name, code },
248
+ });
249
+ }
250
+
234
251
  /**
235
252
  * @param {string} name
236
253
  * @param {ServerDef} def
@@ -240,11 +257,28 @@ function createRpcClient(name, def) {
240
257
  const { command, args = [], env, cwd } = def;
241
258
  const mergedEnv = { ...process.env, ...resolveEnv(env) };
242
259
 
243
- const child = spawn(command, args, {
244
- stdio: ['pipe', 'pipe', 'pipe'],
245
- env: mergedEnv,
246
- ...(cwd && { cwd }),
247
- });
260
+ /** @type {import('node:child_process').ChildProcessWithoutNullStreams} */
261
+ let child;
262
+ try {
263
+ child = spawn(command, args, {
264
+ stdio: ['pipe', 'pipe', 'pipe'],
265
+ env: mergedEnv,
266
+ ...(cwd && { cwd }),
267
+ });
268
+ } catch (err) {
269
+ // spawn() throws SYNCHRONOUSLY for some bad input (empty command, NUL byte, E2BIG), so no
270
+ // child 'error' event will ever fire. Same labelled shape as the async path; never the args.
271
+ // Dead client: rpc() rejects every call; notify() is a no-op; `child` is a minimal stand-in
272
+ // that killServer() skips (exitCode !== null) — the simplest shape that needs no null-guards
273
+ // in connectAndListTools / killServer / close().
274
+ const dead = spawnFailure(name, command, err);
275
+ return {
276
+ rpc: () => Promise.reject(dead),
277
+ notify: () => {},
278
+ child: /** @type {any} */ ({ exitCode: -1, pid: undefined }),
279
+ stderr: '',
280
+ };
281
+ }
248
282
 
249
283
  /** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
250
284
  const pending = new Map();
@@ -297,9 +331,25 @@ function createRpcClient(name, def) {
297
331
  // and rpc()/notify() guard writability before writing.
298
332
  child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
299
333
 
334
+ // Spawn failure (ENOENT/EACCES/...): with NO 'error' listener Node re-throws it as an uncaught
335
+ // exception and crashes the HOST. Record it once; it WINS over any later pipe/close error and
336
+ // rejects every pending and future rpc. Names the command ONLY (args/env can carry tokens).
337
+ /** @type {ToolError | null} */
338
+ let spawnErr = null;
339
+ child.on('error', (err) => {
340
+ // Only a child that never got a pid is a spawn failure. Node also emits 'error' for a failed
341
+ // kill() or IPC send on a LIVE child; keep the listener (so it can't crash the host) but do
342
+ // not mark the server failed — a live server's lifecycle belongs to the 'close' handler.
343
+ if (child.pid !== undefined || spawnErr) return;
344
+ spawnErr = spawnFailure(name, command, err);
345
+ for (const id of [...pending.keys()]) settle(id, false, spawnErr);
346
+ });
347
+
300
348
  child.on('close', (code) => {
301
349
  for (const id of [...pending.keys()]) {
302
- settle(id, false, new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
350
+ // Belt-and-braces: on Node 22 the observed order is error -> EPIPE -> close, so the 'error'
351
+ // handler has already settled these ids; `spawnErr ||` guards a different ordering elsewhere.
352
+ settle(id, false, spawnErr || new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
303
353
  }
304
354
  });
305
355
 
@@ -318,6 +368,7 @@ function createRpcClient(name, def) {
318
368
  function rpc(method, params = {}, timeoutMs = 0, label) {
319
369
  const id = nextId++;
320
370
  return new Promise((resolve, reject) => {
371
+ if (spawnErr) return reject(spawnErr);
321
372
  if (!child.stdin.writable) {
322
373
  return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
323
374
  }
@@ -331,7 +382,10 @@ function createRpcClient(name, def) {
331
382
  { context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
332
383
  ));
333
384
  } else {
334
- settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
385
+ settle(id, false, new ToolError(
386
+ `MCP server "${name}" "${method}" timed out after ${timeoutMs}ms`,
387
+ { context: { timeout: true, server: name, method, timeoutMs, stderr: stderrBuf.slice(-500) } },
388
+ ));
335
389
  }
336
390
  }, timeoutMs);
337
391
  timer.unref?.();
@@ -340,7 +394,10 @@ function createRpcClient(name, def) {
340
394
  const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
341
395
  child.stdin.write(msg, (err) => {
342
396
  // settle() no-ops if 'close'/timeout already settled this id.
343
- if (err) settle(id, false, new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`));
397
+ // Belt-and-braces (Node 22 order is error -> EPIPE -> close, already settled by 'error').
398
+ // Deferred a tick: a failed spawn's EPIPE/destroyed-stream callback can land before the
399
+ // 'error' event; the recorded spawn error must win, so re-check after it has had its turn.
400
+ if (err) setImmediate(() => settle(id, false, spawnErr || new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`)));
344
401
  });
345
402
  });
346
403
  }
@@ -724,7 +781,8 @@ function makeUnvettedWarner(confirmServer) {
724
781
  return (specs) => {
725
782
  if (confirmServer || warned || specs.length === 0) return;
726
783
  warned = true;
727
- const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
784
+ // Server name + command ONLY: args/env can carry tokens and stderr lands in logs. A count only.
785
+ const cmds = specs.map(s => `${s.name} → ${s.command}${s.args && s.args.length ? ` (${s.args.length} args)` : ''}`);
728
786
  console.warn(
729
787
  `[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
730
788
  cmds.join('\n ') +