c8ctl-plugin-nano 1.11.0 → 1.13.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/README.md CHANGED
@@ -281,6 +281,14 @@ a run that outlives `--job-timeout` is force-removed. The envelope is piped on
281
281
  the container's stdin exactly as on the host. (Container-side git provisioning —
282
282
  strong isolation — is a later increment; container jobs don't clone yet.)
283
283
 
284
+ **Host workers inherit your credentials.** A host worker (`--sandbox none`, the
285
+ default) runs as your user and inherits your full environment and `$HOME`, so
286
+ your existing `gh` CLI login (from `gh auth login`) or a `GH_TOKEN`/`GITHUB_TOKEN`
287
+ env var is available to the harness with **no extra setup** — handy when the
288
+ agent command shells out to `gh`. A **container** sandbox is isolated and does
289
+ **not** inherit that host login; provide the token explicitly via
290
+ `setup.secretRefs` / `--secret-resolver host` (see **Secrets** below) instead.
291
+
284
292
  **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
285
293
  (and the repo/PR credential when `task.allowPr` is set — defaulting to
286
294
  `GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
package/c8ctl-plugin.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * c8ctl nano restart [<nodes>] [--purge] ...
29
29
  */
30
30
 
31
- import { spawn, spawnSync } from 'node:child_process';
31
+ import { spawn, spawnSync, execFileSync, execSync } from 'node:child_process';
32
32
  import {
33
33
  existsSync,
34
34
  mkdirSync,
@@ -46,7 +46,7 @@ import {
46
46
  } from 'node:fs';
47
47
  import { randomUUID } from 'node:crypto';
48
48
  import { homedir, platform as osPlatform, devNull } from 'node:os';
49
- import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
49
+ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
50
50
  import { createRequire } from 'node:module';
51
51
  import { fileURLToPath } from 'node:url';
52
52
  import { createInterface } from 'node:readline/promises';
@@ -1639,6 +1639,95 @@ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
1639
1639
  // The result-envelope version is intentionally independent of the task-envelope
1640
1640
  // version so the two contracts can evolve separately without silently coupling.
1641
1641
  const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
1642
+
1643
+ // Structured result channel (agent → harness). A coding CLI streams a lot of
1644
+ // noisy prose/tool output on stdout, so scraping it for the job's structured
1645
+ // result is fragile. Instead the harness hands the agent a private file path in
1646
+ // `AGENT_RESULT_FILE`; the agent writes a JSON object of *job result variables*
1647
+ // there (e.g. `{ "status": "needs_input", "question": "…" }`). The harness reads
1648
+ // it after the run and merges those variables into the job's completion, so the
1649
+ // model sees them as first-class outputs. A `::nano:result:: {json}` stdout
1650
+ // sentinel (or a trailing ```json fence) is honoured as a fallback for agents
1651
+ // that cannot write the file. The harness stays app-agnostic: it merges whatever
1652
+ // object the agent returns; the *app's prompt* owns the field vocabulary.
1653
+ const AGENT_RESULT_FILE_ENV = 'AGENT_RESULT_FILE';
1654
+ const RESULT_SENTINEL = '::nano:result::';
1655
+ // Completion keys the harness owns — an agent's returned result can never
1656
+ // overwrite these (nor anything in the reserved `io.nanobpm.*` namespace), so a
1657
+ // stray `output`/`exitCode`/git field in the agent's JSON can't corrupt the
1658
+ // audit envelope or process bookkeeping.
1659
+ const RESERVED_RESULT_KEYS = new Set([
1660
+ AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
1661
+ 'branch', 'commits', 'pushed', 'pullRequest',
1662
+ ]);
1663
+
1664
+ // Parse `text` as a JSON object, returning it only when it is a plain object.
1665
+ // Never throws — malformed agent output degrades to `null`.
1666
+ function parseAgentResultObject(text) {
1667
+ if (typeof text !== 'string' || !text.trim()) return null;
1668
+ try {
1669
+ const v = JSON.parse(text);
1670
+ return isPlainObject(v) ? v : null;
1671
+ } catch { return null; }
1672
+ }
1673
+
1674
+ // Read + parse the agent's result file, if it wrote one. Best-effort; a missing
1675
+ // or malformed file is treated as "no structured result". The file is
1676
+ // agent-controlled, so guard against a symlink or an oversized payload (DoS):
1677
+ // only a regular file no larger than the cap is read. A well-formed result is a
1678
+ // tiny JSON object, so the cap is generous.
1679
+ const MAX_RESULT_FILE_BYTES = 1_048_576; // 1 MiB
1680
+ function readAgentResultFile(path) {
1681
+ if (!path) return null;
1682
+ try {
1683
+ const st = lstatSync(path); // lstat: never follow a symlink the agent planted
1684
+ if (!st.isFile() || st.size > MAX_RESULT_FILE_BYTES) return null;
1685
+ return parseAgentResultObject(readFileSync(path, 'utf8'));
1686
+ } catch { return null; }
1687
+ }
1688
+
1689
+ // Fallback extraction from stdout, robust to the surrounding transcript: prefer
1690
+ // the LAST `::nano:result:: {json}` sentinel line (cheapest + most explicit),
1691
+ // else the LAST ```json fenced block. "Last wins" so a re-stated result
1692
+ // supersedes an earlier draft.
1693
+ function parseResultFromStdout(stdout) {
1694
+ if (typeof stdout !== 'string' || !stdout) return null;
1695
+ const lines = stdout.split(/\r?\n/);
1696
+ for (let i = lines.length - 1; i >= 0; i--) {
1697
+ const idx = lines[i].indexOf(RESULT_SENTINEL);
1698
+ if (idx === -1) continue;
1699
+ const obj = parseAgentResultObject(lines[i].slice(idx + RESULT_SENTINEL.length).trim());
1700
+ if (obj) return obj;
1701
+ }
1702
+ // Match the opening fence tolerantly (optional language tag, CRLF or LF) so a
1703
+ // Windows agent's `\r\n` output still parses.
1704
+ const fences = [...stdout.matchAll(/```[^\n]*\r?\n([\s\S]*?)```/g)];
1705
+ for (let i = fences.length - 1; i >= 0; i--) {
1706
+ const obj = parseAgentResultObject(fences[i][1].trim());
1707
+ if (obj) return obj;
1708
+ }
1709
+ return null;
1710
+ }
1711
+
1712
+ // The domain result variables an agent may return: the parsed object with the
1713
+ // harness-reserved keys (and the `io.nanobpm.*` namespace) stripped, so it can
1714
+ // never clobber the audit envelope, transcript, or git facts. The agent's output
1715
+ // is untrusted, so build the result on a null-prototype object and drop the
1716
+ // prototype-pollution keys — a merged `__proto__`/`constructor`/`prototype`
1717
+ // must never mutate object prototypes when spread into the job completion.
1718
+ const PROTO_POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
1719
+ function sanitizeResultVars(obj) {
1720
+ if (!isPlainObject(obj)) return {};
1721
+ const out = Object.create(null);
1722
+ for (const [k, v] of Object.entries(obj)) {
1723
+ if (RESERVED_RESULT_KEYS.has(k)) continue;
1724
+ if (PROTO_POLLUTION_KEYS.has(k)) continue;
1725
+ if (k.startsWith('io.nanobpm.')) continue;
1726
+ out[k] = v;
1727
+ }
1728
+ return out;
1729
+ }
1730
+
1642
1731
  const SANDBOXES = ['none', 'docker', 'podman'];
1643
1732
  // Only container-based sandboxes need an image / disk hygiene / a runtime bin.
1644
1733
  const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
@@ -2102,10 +2191,11 @@ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
2102
2191
  if (!existsSync(root)) return { reaped };
2103
2192
  const now = Date.now();
2104
2193
  for (const name of readdirSync(root)) {
2105
- // Only reap the `run-*` workspaces this worker creates (see the
2106
- // `mkdtempSync(join(agentRunsRoot(), 'run-'))` in workAgent). Never touch
2107
- // unrelated files/dirs an operator may have placed under agent-runs.
2108
- if (!name.startsWith('run-')) continue;
2194
+ // Only reap the throwaway dirs this worker creates under agent-runs: the
2195
+ // `run-*` job workspaces and the `res-*` structured-result dirs (see the
2196
+ // `mkdtempSync(...)` calls in workAgent). Never touch unrelated files/dirs
2197
+ // an operator may have placed under agent-runs.
2198
+ if (!name.startsWith('run-') && !name.startsWith('res-')) continue;
2109
2199
  const p = join(root, name);
2110
2200
  if (liveRunDirs.has(p)) continue;
2111
2201
  try {
@@ -2128,7 +2218,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
2128
2218
  // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
2129
2219
  // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
2130
2220
  // uniform result. Used by both the host and container executors.
2131
- function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout }) {
2221
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
2132
2222
  return new Promise((resolve) => {
2133
2223
  let child;
2134
2224
  const stdoutChunks = [];
@@ -2140,10 +2230,43 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2140
2230
  let settled = false;
2141
2231
  let timer = null;
2142
2232
 
2233
+ // Live "spy" tee (--stream): mirror the child's output line-by-line to a
2234
+ // caller-supplied emitter (the worker routes these through c8ctl's
2235
+ // output-mode-aware logger so streaming never corrupts a structured/JSON
2236
+ // output mode; falling back to a raw console write only when none is given).
2237
+ // Each complete line is tagged with the job prefix so interleaved jobs stay
2238
+ // legible. A per-stream buffer holds partial lines across chunk boundaries;
2239
+ // it is force-flushed once it exceeds STREAM_TEE_LINE_CAP so a newline-less
2240
+ // torrent (progress bars, binary output) can't grow it without bound.
2241
+ const STREAM_TEE_LINE_CAP = 64 * 1024;
2242
+ const makeTee = (emit) => {
2243
+ if (!stream) return null;
2244
+ const sink = emit || ((line) => process.stdout.write(`${line}\n`));
2245
+ let partial = '';
2246
+ const flush = (text, final) => {
2247
+ partial += text;
2248
+ let nl;
2249
+ while ((nl = partial.indexOf('\n')) !== -1) {
2250
+ sink(`${streamPrefix}${partial.slice(0, nl)}`);
2251
+ partial = partial.slice(nl + 1);
2252
+ }
2253
+ while (partial.length >= STREAM_TEE_LINE_CAP) {
2254
+ sink(`${streamPrefix}${partial.slice(0, STREAM_TEE_LINE_CAP)}`);
2255
+ partial = partial.slice(STREAM_TEE_LINE_CAP);
2256
+ }
2257
+ if (final && partial) { sink(`${streamPrefix}${partial}`); partial = ''; }
2258
+ };
2259
+ return flush;
2260
+ };
2261
+ const teeOut = makeTee(onStreamOut);
2262
+ const teeErr = makeTee(onStreamErr || onStreamOut);
2263
+
2143
2264
  const finish = (result) => {
2144
2265
  if (settled) return;
2145
2266
  settled = true;
2146
2267
  if (timer) clearTimeout(timer);
2268
+ if (teeOut) teeOut('', true);
2269
+ if (teeErr) teeErr('', true);
2147
2270
  resolve(result);
2148
2271
  };
2149
2272
 
@@ -2163,6 +2286,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2163
2286
 
2164
2287
  child.stdout.on('data', (d) => {
2165
2288
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2289
+ if (teeOut) teeOut(buf.toString('utf8'), false);
2166
2290
  const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
2167
2291
  if (remaining <= 0) { stdoutTruncated = true; return; }
2168
2292
  if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
@@ -2170,6 +2294,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2170
2294
  });
2171
2295
  child.stderr.on('data', (d) => {
2172
2296
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2297
+ if (teeErr) teeErr(buf.toString('utf8'), false);
2173
2298
  const remaining = MAX_CAPTURE_BYTES - stderrBytes;
2174
2299
  if (remaining <= 0) { stderrTruncated = true; return; }
2175
2300
  if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
@@ -2233,7 +2358,7 @@ function baseAgentEnv(profile, job) {
2233
2358
  * Both paths resolve to the same result contract.
2234
2359
  */
2235
2360
  function runAgentJob(profile, job, opts = {}) {
2236
- const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {} } = opts;
2361
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr } = opts;
2237
2362
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
2238
2363
  const agentEnv = baseAgentEnv(profile, job);
2239
2364
  // Static, non-secret env for the harness: the worker/profile's env (e.g. a
@@ -2243,6 +2368,8 @@ function runAgentJob(profile, job, opts = {}) {
2243
2368
  const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
2244
2369
 
2245
2370
  if (!CONTAINER_SANDBOXES.has(sandbox)) {
2371
+ // Host: hand the agent the result file by its real path.
2372
+ const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
2246
2373
  return spawnCaptureOneShot({
2247
2374
  command: profile.command,
2248
2375
  shell: true,
@@ -2250,20 +2377,38 @@ function runAgentJob(profile, job, opts = {}) {
2250
2377
  detached: process.platform !== 'win32',
2251
2378
  // When a repository was provisioned, run the harness IN the workspace.
2252
2379
  cwd,
2253
- env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
2380
+ // Reserved harness env (AGENT_* + the result-file path) is layered AFTER
2381
+ // resolved secrets so a task-supplied secret NAME can never shadow it.
2382
+ env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
2254
2383
  stdinData: payload,
2255
2384
  timeoutMs,
2256
2385
  onTimeout: (child) => killTree(child),
2386
+ stream,
2387
+ streamPrefix,
2388
+ onStreamOut,
2389
+ onStreamErr,
2257
2390
  });
2258
2391
  }
2259
2392
 
2260
2393
  const engine = sandbox;
2261
2394
  const containerName = `nano-${runId}`;
2395
+ // Container: bind-mount the result file's directory read-write at a fixed
2396
+ // in-container path and point AGENT_RESULT_FILE at the mounted file, so the
2397
+ // agent writes it inside the sandbox and the harness reads it back on the host.
2398
+ let resultEnv = {};
2399
+ const mountArgs = [];
2400
+ if (resultFile) {
2401
+ const hostDir = dirname(resultFile);
2402
+ const containerPath = `/nano-agent/${basename(resultFile)}`;
2403
+ mountArgs.push('-v', `${hostDir}:/nano-agent`);
2404
+ resultEnv = { [AGENT_RESULT_FILE_ENV]: containerPath };
2405
+ }
2262
2406
  // Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
2263
2407
  // `docker inspect`; docker reads the value from our child's environment.
2264
2408
  const envArgs = [];
2265
2409
  for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
2266
2410
  for (const k of Object.keys(extraEnv)) envArgs.push('-e', k);
2411
+ for (const k of Object.keys(resultEnv)) envArgs.push('-e', k);
2267
2412
  for (const n of passThroughSecretNames) envArgs.push('-e', n);
2268
2413
  for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
2269
2414
 
@@ -2276,6 +2421,7 @@ function runAgentJob(profile, job, opts = {}) {
2276
2421
  '--label', `nano.run=${runId}`,
2277
2422
  '--log-opt', 'max-size=10m',
2278
2423
  '--log-opt', 'max-file=3',
2424
+ ...mountArgs,
2279
2425
  ...envArgs,
2280
2426
  image,
2281
2427
  'sh', '-c', profile.command,
@@ -2285,9 +2431,16 @@ function runAgentJob(profile, job, opts = {}) {
2285
2431
  command: engine,
2286
2432
  args,
2287
2433
  shell: false,
2288
- env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
2434
+ // Reserved harness env (AGENT_* + the result-file path) is layered AFTER
2435
+ // resolved secrets so a task-supplied secret NAME can never shadow it. In
2436
+ // container mode docker reads these values from our child env by NAME.
2437
+ env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
2289
2438
  stdinData: payload,
2290
2439
  timeoutMs,
2440
+ stream,
2441
+ streamPrefix,
2442
+ onStreamOut,
2443
+ onStreamErr,
2291
2444
  onTimeout: (child) => {
2292
2445
  try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
2293
2446
  try { killTree(child); } catch { /* best effort */ }
@@ -2297,7 +2450,7 @@ function runAgentJob(profile, job, opts = {}) {
2297
2450
 
2298
2451
  // Shape the io.nanobpm.agentResult output envelope. When a repository was
2299
2452
  // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
2300
- function buildResultEnvelope(result, { sandbox, image, git } = {}) {
2453
+ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
2301
2454
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
2302
2455
  const env = {
2303
2456
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
@@ -2311,6 +2464,9 @@ function buildResultEnvelope(result, { sandbox, image, git } = {}) {
2311
2464
  signal: result.signal ?? null,
2312
2465
  error: result.error ?? null,
2313
2466
  };
2467
+ // The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
2468
+ // preserved verbatim for auditability even when merged into the completion vars.
2469
+ if (isPlainObject(agentResult)) env.result = agentResult;
2314
2470
  if (git) {
2315
2471
  env.repository = git.remote ?? null;
2316
2472
  env.branch = git.branch ?? null;
@@ -2408,6 +2564,9 @@ async function workAgent(req, flags) {
2408
2564
  // Git provisioning knobs (increment 2a — host harness with a repository).
2409
2565
  const cloneTimeoutMs = intFlag(flags?.['clone-timeout'], 120_000);
2410
2566
  const keepRuns = coerceBool(flags?.['keep-runs'], false);
2567
+ // --stream: tee each agent job's live stdout/stderr to this console (spy/debug),
2568
+ // in addition to the existing byte-capped capture used for the result envelope.
2569
+ const stream = coerceBool(flags?.stream, false);
2411
2570
 
2412
2571
  // Tracks run ids currently executing so the reaper never removes a live
2413
2572
  // container out from under an in-flight job.
@@ -2534,7 +2693,20 @@ async function workAgent(req, flags) {
2534
2693
 
2535
2694
  let result;
2536
2695
  let gitResult = null;
2696
+ // Private structured-result channel: hand the agent a file (outside any
2697
+ // repo clone so it can't be `git add`ed) to write its job-result vars to.
2698
+ let resultDir = null;
2699
+ let resultFile = null;
2537
2700
  try {
2701
+ try {
2702
+ mkdirSync(agentRunsRoot(), { recursive: true });
2703
+ resultDir = mkdtempSync(join(agentRunsRoot(), 'res-'));
2704
+ resultFile = join(resultDir, 'result.json');
2705
+ // Track it so the run-dir reaper skips it while in-flight and reaps it
2706
+ // (as a `res-*` dir) if this worker crashes before the cleanup below.
2707
+ liveRunDirs.add(resultDir);
2708
+ } catch { resultDir = null; resultFile = null; }
2709
+
2538
2710
  result = await runAgentJob(profile, job, {
2539
2711
  timeoutMs: jobTimeoutMs,
2540
2712
  envelope,
@@ -2546,6 +2718,13 @@ async function workAgent(req, flags) {
2546
2718
  cwd,
2547
2719
  extraEnv,
2548
2720
  profileEnv,
2721
+ resultFile,
2722
+ stream,
2723
+ streamPrefix: `[${jobType} ${job.jobKey}] `,
2724
+ // Route the --stream tee through c8ctl's output-mode-aware logger so
2725
+ // spying never corrupts a structured/JSON output mode.
2726
+ onStreamOut: stream ? (line) => logger.info(line) : undefined,
2727
+ onStreamErr: stream ? (line) => logger.warn(line) : undefined,
2549
2728
  });
2550
2729
 
2551
2730
  // Finalize git only when the harness succeeded — never push a
@@ -2572,14 +2751,33 @@ async function workAgent(req, flags) {
2572
2751
  if (runDir) liveRunDirs.delete(runDir);
2573
2752
  }
2574
2753
 
2575
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult });
2754
+ // Read the agent's structured result: the file it wrote, else a stdout
2755
+ // sentinel/`json fence fallback. The raw object is attached to the audit
2756
+ // envelope; the sanitized (reserved-key-stripped) vars are merged into the
2757
+ // job completion so the model sees `status`/`summary`/… as first-class
2758
+ // outputs. Read before deleting the temp dir.
2759
+ const rawResult = readAgentResultFile(resultFile) ?? parseResultFromStdout(result.stdout);
2760
+ if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
2761
+ const resultVars = sanitizeResultVars(rawResult);
2762
+
2763
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
2576
2764
  if (result.ok) {
2577
2765
  const gitNote = gitResult
2578
2766
  ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
2579
2767
  : '';
2580
2768
  logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
2581
2769
  if (gitResult?.pushError) logger.warn(`[${jobType}] job ${job.jobKey}: branch push failed — ${gitResult.pushError}`);
2770
+ // Guard the operator against silent empty escalations: a success that
2771
+ // yields no *effective* result vars (no file/sentinel at all, an empty
2772
+ // `{}`, or only reserved keys that were sanitized away) means the
2773
+ // model's status/decision vars stay unset and any status gateway will
2774
+ // fall through to its default. Warn on the merged-vars emptiness, not
2775
+ // just a missing rawResult.
2776
+ const resultKeys = Object.keys(resultVars);
2777
+ if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
2778
+ else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
2582
2779
  return job.complete({
2780
+ ...resultVars,
2583
2781
  [AGENT_RESULT_KEY]: resultEnvelope,
2584
2782
  output: result.stdout,
2585
2783
  exitCode: 0,
@@ -2679,21 +2877,90 @@ function compareSemver(a, b) {
2679
2877
  return 0;
2680
2878
  }
2681
2879
 
2880
+ /**
2881
+ * Resolve how npm must be spawned on the given platform. Spawning `npm`
2882
+ * directly is not portable: on Windows npm is a `npm.cmd` shim, so bare
2883
+ * `"npm"` fails with ENOENT and `"npm.cmd"` fails with EINVAL under the
2884
+ * CVE-2024-27980 hardening. On Windows the shim is therefore run through
2885
+ * cmd.exe (`shell: true`) with every argument double-quoted, and the two
2886
+ * constructs that survive double quotes — an embedded `"` and a `%VAR%`
2887
+ * reference — are rejected rather than escaped.
2888
+ *
2889
+ * This mirrors the host CLI's own `buildNpmInvocation`; it is the local
2890
+ * fallback for `runNpm` when the host runner (`c8ctl.npm`) is unavailable.
2891
+ * `platform` is a parameter so the Windows branch is unit-testable on POSIX.
2892
+ */
2893
+ function buildNpmInvocation(args, platform = process.platform) {
2894
+ if (platform !== 'win32') {
2895
+ return { command: 'npm', args: [...args], shell: false };
2896
+ }
2897
+ for (const arg of args) {
2898
+ if (/["\r\n\0]/.test(arg)) {
2899
+ throw new Error(
2900
+ `Refusing to run npm: argument contains a quote or line break that cannot be passed safely to cmd.exe: ${JSON.stringify(arg)}`,
2901
+ );
2902
+ }
2903
+ if (/%[A-Z_][^%]*?%/i.test(arg)) {
2904
+ throw new Error(
2905
+ `Refusing to run npm: argument contains a cmd.exe environment variable reference: ${JSON.stringify(arg)}`,
2906
+ );
2907
+ }
2908
+ }
2909
+ return {
2910
+ command: 'npm.cmd',
2911
+ args: args.map((arg) => `"${arg.replace(/(\\+)$/, '$1$1')}"`),
2912
+ shell: true,
2913
+ };
2914
+ }
2915
+
2916
+ /** Local, platform-aware npm runner used when the host `c8ctl.npm` is absent. */
2917
+ function runNpmLocal(args, { stdout = false, stdio } = {}) {
2918
+ const { command, args: resolved, shell } = buildNpmInvocation(args);
2919
+ if (shell) {
2920
+ const cmdLine = [command, ...resolved].join(' ');
2921
+ if (stdout) {
2922
+ return { stdout: execSync(cmdLine, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }) };
2923
+ }
2924
+ execSync(cmdLine, { stdio });
2925
+ return undefined;
2926
+ }
2927
+ if (stdout) {
2928
+ return {
2929
+ stdout: execFileSync(command, resolved, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', shell: false }),
2930
+ };
2931
+ }
2932
+ execFileSync(command, resolved, { stdio, shell: false });
2933
+ return undefined;
2934
+ }
2935
+
2936
+ /**
2937
+ * Run npm portably. Prefers the host CLI's cross-platform runner
2938
+ * (`globalThis.c8ctl.npm`, added in c8ctl's plugin runtime); falls back to the
2939
+ * local platform-aware invocation for older hosts and for the detached update
2940
+ * refresh, which runs without the host runtime. Throws on a nonzero exit.
2941
+ */
2942
+ function runNpm(args, { stdout = false, stdio } = {}) {
2943
+ const host = globalThis.c8ctl;
2944
+ if (host && typeof host.npm === 'function') {
2945
+ return stdout ? host.npm({ args, stdout: true }) : host.npm({ args, stdio });
2946
+ }
2947
+ return runNpmLocal(args, { stdout, stdio });
2948
+ }
2949
+
2682
2950
  /** Latest published version of `name` per the npm registry (throws on failure). */
2683
2951
  function npmLatestVersion(name) {
2684
- const res = spawnSync('npm', ['view', name, 'version'], { encoding: 'utf8' });
2685
- if (res.error) throw new Error(res.error.message);
2686
- if (res.status !== 0) {
2687
- throw new Error((res.stderr || '').trim() || `npm view exited ${res.status}`);
2688
- }
2689
- return res.stdout.trim();
2952
+ const { stdout } = runNpm(['view', name, 'version'], { stdout: true });
2953
+ return stdout.trim();
2690
2954
  }
2691
2955
 
2692
2956
  /** True when this plugin lives under npm's global node_modules (so `-g` updates it). */
2693
2957
  function isGlobalInstall() {
2694
- const res = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
2695
- if (res.status !== 0) return false;
2696
- const root = res.stdout.trim();
2958
+ let root;
2959
+ try {
2960
+ root = runNpm(['root', '-g'], { stdout: true }).stdout.trim();
2961
+ } catch {
2962
+ return false;
2963
+ }
2697
2964
  return Boolean(root) && pluginDir.startsWith(root);
2698
2965
  }
2699
2966
 
@@ -2811,9 +3078,9 @@ function updatePlugin(req) {
2811
3078
  const where = info.mode === 'managed' ? 'the c8ctl plugin store' : "npm's global prefix";
2812
3079
  console.log(`Pulling ${name}@${latest} into ${where}...`);
2813
3080
  console.log('');
2814
- const res = spawnSync('npm', installArgs, { stdio: 'inherit' });
2815
- if (res.error) throw new Error(res.error.message);
2816
- if (res.status !== 0) {
3081
+ try {
3082
+ runNpm(installArgs, { stdio: 'inherit' });
3083
+ } catch (err) {
2817
3084
  let hint;
2818
3085
  if (info.mode === 'managed') {
2819
3086
  hint = `You can also run:\n${manual}`;
@@ -2822,8 +3089,9 @@ function updatePlugin(req) {
2822
3089
  } else {
2823
3090
  hint = `You may need elevated permissions: sudo ${manual.trim()}`;
2824
3091
  }
3092
+ const code = typeof err?.status === 'number' ? ` (exit ${err.status})` : '';
2825
3093
  throw new Error(
2826
- `npm ${installArgs.join(' ')} failed (exit ${res.status}). ${hint}`,
3094
+ `npm ${installArgs.join(' ')} failed${code}. ${hint}`,
2827
3095
  );
2828
3096
  }
2829
3097
  console.log('');
@@ -2883,13 +3151,28 @@ function updateNotifierDisabled() {
2883
3151
  * fresh result is used on the *next* invocation.
2884
3152
  */
2885
3153
  function spawnUpdateRefresh(name, cacheFile) {
3154
+ // The refresh runs in a detached bare-node child that has no host runtime, so
3155
+ // it cannot use c8ctl.npm. Resolve the portable npm invocation here (single
3156
+ // source of truth) and bake the decided command into a generic runner in the
3157
+ // child — the child makes no platform decision of its own.
3158
+ let inv;
3159
+ try {
3160
+ inv = buildNpmInvocation(['view', name, 'version']);
3161
+ } catch {
3162
+ return; /* unsafe argument for cmd.exe; skip this cycle */
3163
+ }
2886
3164
  const script =
2887
- 'const{spawnSync}=require("child_process");' +
3165
+ 'const{execFileSync,execSync}=require("child_process");' +
2888
3166
  'const{readFileSync,writeFileSync}=require("fs");' +
3167
+ `const cmd=${JSON.stringify(inv.command)},args=${JSON.stringify(inv.args)},shell=${JSON.stringify(inv.shell)};` +
2889
3168
  `let prev={};try{prev=JSON.parse(readFileSync(${JSON.stringify(cacheFile)},"utf8"))}catch{}` +
2890
3169
  'const out=Object.assign({},prev,{lastCheck:Date.now()});' +
2891
- `const r=spawnSync("npm",["view",${JSON.stringify(name)},"version"],{encoding:"utf8"});` +
2892
- 'if(r.status===0){out.latest=String(r.stdout||"").trim()}' +
3170
+ 'try{' +
3171
+ 'const o=shell' +
3172
+ '?execSync([cmd,...args].join(" "),{stdio:["ignore","pipe","pipe"],encoding:"utf8"})' +
3173
+ ':execFileSync(cmd,args,{stdio:["ignore","pipe","pipe"],encoding:"utf8",shell:false});' +
3174
+ 'out.latest=String(o||"").trim()' +
3175
+ '}catch{}' +
2893
3176
  `try{writeFileSync(${JSON.stringify(cacheFile)},JSON.stringify(out))}catch{}`;
2894
3177
  try {
2895
3178
  const child = spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' });
@@ -3864,6 +4147,7 @@ function parseProcessosRequest(args, flags) {
3864
4147
  // Internal helpers exported for tests/tooling only. c8ctl consumes just
3865
4148
  // `metadata` and `commands`; these named exports are inert to it.
3866
4149
  export { resolveBinary, findBinary, launcherEnvMarkers };
4150
+ export { buildNpmInvocation };
3867
4151
  export {
3868
4152
  normalizeTaskEnvelope,
3869
4153
  collectEnvelopeFrom,
@@ -3875,6 +4159,10 @@ export {
3875
4159
  hostEnvSecretResolver,
3876
4160
  buildAgentPayload,
3877
4161
  buildResultEnvelope,
4162
+ parseAgentResultObject,
4163
+ readAgentResultFile,
4164
+ parseResultFromStdout,
4165
+ sanitizeResultVars,
3878
4166
  parseEnvPairs,
3879
4167
  normalizeEnvMap,
3880
4168
  reapAgentContainers,
@@ -3893,6 +4181,8 @@ export {
3893
4181
  jobTypeMatrix,
3894
4182
  AGENT_TASK_NS,
3895
4183
  AGENT_RESULT_KEY,
4184
+ RESULT_SENTINEL,
4185
+ RESERVED_RESULT_KEYS,
3896
4186
  SANDBOXES,
3897
4187
  };
3898
4188
 
@@ -3987,6 +4277,7 @@ export const commands = {
3987
4277
  'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
3988
4278
  'clone-timeout': { type: 'string', description: 'work: max time in ms for cloning a task repository on the host (default 120000)' },
3989
4279
  'keep-runs': { type: 'boolean', description: 'work: keep per-job workspaces under <state>/agent-runs instead of deleting them after each job (debug)' },
4280
+ stream: { type: 'boolean', description: 'work: tee each agent job\'s live stdout/stderr to this console, prefixed with the job type + key (spy/debug)' },
3990
4281
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3991
4282
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3992
4283
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
@@ -4133,7 +4424,7 @@ function printUsage() {
4133
4424
  console.log(' c8ctl nano config');
4134
4425
  console.log(' c8ctl nano update [--check]');
4135
4426
  console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
4136
- console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs]');
4427
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
4137
4428
  console.log('');
4138
4429
  console.log('Subcommands:');
4139
4430
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -49,12 +49,12 @@
49
49
  "semantic-release": "^25.0.3"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.11.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.11.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.11.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.11.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.11.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.11.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.11.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.13.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.13.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.13.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.13.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.13.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.13.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.13.0"
59
59
  }
60
60
  }