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 +8 -0
- package/c8ctl-plugin.js +320 -29
- package/package.json +8 -8
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
|
|
2106
|
-
// `
|
|
2107
|
-
//
|
|
2108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2685
|
-
|
|
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
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
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
|
|
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{
|
|
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
|
-
|
|
2892
|
-
'
|
|
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.
|
|
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.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
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
|
}
|