c8ctl-plugin-nano 1.9.0 → 1.10.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.
Files changed (3) hide show
  1. package/README.md +66 -5
  2. package/c8ctl-plugin.js +558 -67
  3. package/package.json +9 -9
package/README.md CHANGED
@@ -197,6 +197,68 @@ config`).
197
197
  > and `AGENT_*` env vars, never interpolated into the command line, so process
198
198
  > variables cannot inject shell commands.
199
199
 
200
+ ### Task envelope, sandboxes & disk hygiene
201
+
202
+ For **agentic** jobs (an agent that clones a repo, works a task, pushes a
203
+ branch) the job carries a structured **task envelope** under the reserved
204
+ `io.nanobpm.agentTask` namespace. It is assembled from the job's static
205
+ `customHeaders` (model-authored defaults) deep-merged with per-instance
206
+ `variables` (**overrides win**), then normalized to schema v1 and included in the
207
+ stdin payload as `task`:
208
+
209
+ ```jsonc
210
+ {
211
+ "io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
212
+ "io.nanobpm.agentTask.repository.ref": "main",
213
+ "io.nanobpm.agentTask.branch.push": "true",
214
+ "io.nanobpm.agentTask.task.allowPr": "false"
215
+ }
216
+ ```
217
+
218
+ Element templates emit flat dotpath header keys (strings); the plugin expands
219
+ them into a nested object and coerces `"true"/"false"` → bool and numeric
220
+ strings → int. The normalized shape is
221
+ `{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
222
+ On completion the plugin writes an **output envelope** back under
223
+ `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`).
224
+
225
+ **Sandbox.** By default the command runs on the host (`--sandbox none`). Pass
226
+ `--sandbox docker` (or `podman`) with an `--image` to run **each job in a
227
+ throwaway container** instead:
228
+
229
+ ```bash
230
+ c8ctl nano hire --name coder --rank senior --command "agent-harness" \
231
+ --sandbox docker --image ghcr.io/acme/agent:1
232
+ c8ctl nano work coder # uses the profile's sandbox/image
233
+ c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or override
234
+ ```
235
+
236
+ Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
237
+ `nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
238
+ a run that outlives `--job-timeout` is force-removed. The envelope is piped on
239
+ the container's stdin exactly as on the host.
240
+
241
+ **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
242
+ (and the repo/PR credential when `task.allowPr` is set — defaulting to
243
+ `GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
244
+ (only `host`, reading `process.env`, is implemented) and forwarded into the
245
+ container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
246
+ A missing required secret fails the job with a clear provisioning message.
247
+
248
+ **Disk hygiene.** Container sandboxes get automatic cleanup so leaked
249
+ containers can't fill the disk: a **label-scoped** reaper runs at worker startup
250
+ and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
251
+ removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
252
+ default `3600000` = 1h) while **skipping any run still in flight** — it never
253
+ touches containers it didn't create and never `system prune`s. A **disk-budget
254
+ admission shed** fails (retryable) new jobs when the engine data root has less
255
+ than `--min-free-mb` MB free (default `1024`).
256
+
257
+ > Git provisioning (clone/branch/push), agent-opened PRs, and the
258
+ > Vercel/Sandcastle provider are **increment 2** — the envelope names above are
259
+ > frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
260
+ > can be built against this contract.
261
+
200
262
  ## Cleaning up disk
201
263
 
202
264
  ```bash
@@ -236,7 +298,7 @@ and the history cap.
236
298
  > ⚠️ With `--in-memory`, restart recovers nothing, and Raft/replicated logs are
237
299
  > not persisted. Use it for stress/throughput testing, not durability testing.
238
300
 
239
- ## Console profile (`--console` / `--profile`)
301
+ ## Console profile (`--console`)
240
302
 
241
303
  The server ships a browser console. Pick how much of it is exposed at runtime:
242
304
 
@@ -246,10 +308,9 @@ c8ctl nano start --console observe # observability views only; authoring refu
246
308
  c8ctl nano start --console off # headless: no console router at all
247
309
  ```
248
310
 
249
- - Values: `studio` (default), `observe`, `off`. `--profile` is an alias for
250
- `--console`, and an inherited `NANOBPMN_CONSOLE` env var is honored when neither
251
- flag is passed. The plugin passes the choice through as `NANOBPMN_CONSOLE` on
252
- every node.
311
+ - Values: `studio` (default), `observe`, `off`. An inherited `NANOBPMN_CONSOLE`
312
+ env var is honored when the flag is not passed. The plugin passes the choice
313
+ through as `NANOBPMN_CONSOLE` on every node.
253
314
 
254
315
  ## Configuration (`set` / `config`)
255
316
 
package/c8ctl-plugin.js CHANGED
@@ -40,7 +40,9 @@ import {
40
40
  chmodSync,
41
41
  renameSync,
42
42
  realpathSync,
43
+ statfsSync,
43
44
  } from 'node:fs';
45
+ import { randomUUID } from 'node:crypto';
44
46
  import { homedir, platform as osPlatform } from 'node:os';
45
47
  import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
46
48
  import { createRequire } from 'node:module';
@@ -396,7 +398,7 @@ function parseRequest(args, flags) {
396
398
  capture: Boolean(flags?.capture),
397
399
  inMemory: Boolean(flags?.['in-memory'] || flags?.['no-journal']),
398
400
  historyMax: intFlag('history-max'),
399
- console: flags?.console ?? flags?.profile,
401
+ console: flags?.console,
400
402
  workspace: Boolean(flags?.workspace),
401
403
  check: Boolean(flags?.check),
402
404
  binary: flags?.binary,
@@ -550,7 +552,7 @@ const CONSOLE_PROFILES = ['off', 'observe', 'studio'];
550
552
 
551
553
  /**
552
554
  * Resolves the runtime console profile to pass through as NANOBPMN_CONSOLE.
553
- * Precedence: --console/--profile flag > inherited NANOBPMN_CONSOLE env >
555
+ * Precedence: --console flag > inherited NANOBPMN_CONSOLE env >
554
556
  * 'studio' (the full IDE, our default). Unknown values are rejected so a typo
555
557
  * fails fast here rather than silently degrading the console in the server.
556
558
  */
@@ -706,7 +708,7 @@ async function startCluster(req) {
706
708
  // outside the per-node data dir so "nano clean" never wipes it.
707
709
  NANOBPMN_WORKSPACE_DIR: workspaceDir,
708
710
  // Runtime console profile (off | observe | studio). Default studio (full
709
- // IDE); pass-through so --console/--profile or an inherited NANOBPMN_CONSOLE
711
+ // IDE); pass-through so --console or an inherited NANOBPMN_CONSOLE
710
712
  // picks the observability-only or headless surface. See nano-bpm ADR 0035 §C.
711
713
  NANOBPMN_CONSOLE: consoleProfile,
712
714
  };
@@ -1369,6 +1371,14 @@ function normalizeStoredProfile(name, profile) {
1369
1371
  if (!command) {
1370
1372
  return { error: `profile "${name}" has no command to run` };
1371
1373
  }
1374
+ const sandbox = String(profile.sandbox || 'none').trim().toLowerCase();
1375
+ if (!SANDBOXES.includes(sandbox)) {
1376
+ return { error: `profile "${name}" has an invalid sandbox "${profile.sandbox}" (expected one of: ${SANDBOXES.join(', ')})` };
1377
+ }
1378
+ const image = typeof profile.image === 'string' ? profile.image.trim() : '';
1379
+ if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
1380
+ return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
1381
+ }
1372
1382
  return {
1373
1383
  profile: {
1374
1384
  name,
@@ -1376,6 +1386,8 @@ function normalizeStoredProfile(name, profile) {
1376
1386
  command,
1377
1387
  model: typeof profile.model === 'string' ? profile.model.trim() : '',
1378
1388
  capabilities: normalizeCapabilities(profile.capabilities),
1389
+ sandbox,
1390
+ image,
1379
1391
  },
1380
1392
  };
1381
1393
  }
@@ -1413,6 +1425,8 @@ async function hireWorker(req, flags) {
1413
1425
  let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
1414
1426
  let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
1415
1427
  let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
1428
+ let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
1429
+ let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
1416
1430
 
1417
1431
  const missingRequired = !name || !rank || !command;
1418
1432
  const missingOptional = model === undefined || capabilities === undefined;
@@ -1461,6 +1475,17 @@ async function hireWorker(req, flags) {
1461
1475
  // Optional fields default to empty when omitted (e.g. scripted invocations).
1462
1476
  if (model === undefined) model = '';
1463
1477
  if (capabilities === undefined) capabilities = '';
1478
+ if (sandbox === undefined || sandbox === '') sandbox = 'none';
1479
+ if (image === undefined) image = '';
1480
+
1481
+ if (!SANDBOXES.includes(sandbox)) {
1482
+ logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
1483
+ process.exit(1);
1484
+ }
1485
+ if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
1486
+ logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
1487
+ process.exit(1);
1488
+ }
1464
1489
 
1465
1490
  if (!isValidProfileName(name)) {
1466
1491
  logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
@@ -1482,6 +1507,8 @@ async function hireWorker(req, flags) {
1482
1507
  command,
1483
1508
  model: model || '',
1484
1509
  capabilities: normalizeCapabilities(capabilities),
1510
+ sandbox,
1511
+ image: image || '',
1485
1512
  createdAt: new Date().toISOString(),
1486
1513
  };
1487
1514
  writeHire(profile);
@@ -1490,6 +1517,7 @@ async function hireWorker(req, flags) {
1490
1517
  logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
1491
1518
  logger.info(` model: ${profile.model || '(none)'}`);
1492
1519
  logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1520
+ logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
1493
1521
  logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
1494
1522
  logger.info(`Put it to work with: c8ctl nano work ${name}`);
1495
1523
  }
@@ -1532,51 +1560,255 @@ function killTree(child) {
1532
1560
  try { child.kill('SIGKILL'); } catch { /* already gone */ }
1533
1561
  }
1534
1562
 
1535
- /**
1536
- * Run a single activated job through the profile's CLI command (one-shot):
1537
- * spawn the command fresh, pipe the job as JSON on stdin, capture stdout, and
1538
- * resolve to a job action. Exit 0 → complete with { output, exitCode }; any
1539
- * other exit (or spawn failure) fail with a decremented retry count.
1540
- * A child that outlives `timeoutMs` is killed and reported as a failure so it
1541
- * never leaks a worker slot.
1542
- */
1543
- function runAgentJob(profile, job, timeoutMs) {
1544
- return new Promise((resolve) => {
1545
- const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
1546
- const payload = {
1547
- jobKey: job.jobKey,
1548
- jobType: job.type,
1549
- processInstanceKey: job.processInstanceKey ?? null,
1550
- elementInstanceKey: job.elementInstanceKey ?? null,
1551
- elementId: job.elementId ?? null,
1552
- bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
1553
- prompt: variables.prompt ?? variables.task ?? null,
1554
- variables,
1555
- customHeaders: job.customHeaders ?? {},
1556
- profile: {
1557
- name: profile.name,
1558
- rank: profile.rank,
1559
- model: profile.model,
1560
- capabilities: profile.capabilities,
1561
- },
1563
+ // ===========================================================================
1564
+ // Agent task envelope + sandboxed execution (issue #8, increment 1)
1565
+ // ===========================================================================
1566
+
1567
+ // Reserved namespaces. The INPUT envelope is assembled from the job's static
1568
+ // customHeaders (model-authored defaults) deep-merged with per-instance
1569
+ // variables (overrides win), then normalized/coerced to schema v1. The OUTPUT
1570
+ // envelope is written back on the job's completion variables.
1571
+ const AGENT_TASK_NS = 'io.nanobpm.agentTask';
1572
+ const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
1573
+ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
1574
+ // The result-envelope version is intentionally independent of the task-envelope
1575
+ // version so the two contracts can evolve separately without silently coupling.
1576
+ const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
1577
+ const SANDBOXES = ['none', 'docker', 'podman'];
1578
+ // Only container-based sandboxes need an image / disk hygiene / a runtime bin.
1579
+ const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
1580
+
1581
+ function coerceBool(v, dflt = false) {
1582
+ if (typeof v === 'boolean') return v;
1583
+ if (v == null) return dflt;
1584
+ const s = String(v).trim().toLowerCase();
1585
+ if (['true', '1', 'yes', 'on'].includes(s)) return true;
1586
+ if (['false', '0', 'no', 'off', ''].includes(s)) return false;
1587
+ return dflt;
1588
+ }
1589
+
1590
+ function coerceInt(v, dflt) {
1591
+ if (v == null || v === '') return dflt;
1592
+ const n = Number.parseInt(String(v), 10);
1593
+ return Number.isFinite(n) ? n : dflt;
1594
+ }
1595
+
1596
+ function isPlainObject(v) {
1597
+ return v != null && typeof v === 'object' && !Array.isArray(v);
1598
+ }
1599
+
1600
+ function deepMerge(base, over) {
1601
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
1602
+ const out = { ...base };
1603
+ if (!isPlainObject(over)) return out;
1604
+ for (const [k, v] of Object.entries(over)) {
1605
+ if (v === undefined) continue;
1606
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
1607
+ }
1608
+ return out;
1609
+ }
1610
+
1611
+ function setPath(obj, path, value) {
1612
+ let cur = obj;
1613
+ for (let i = 0; i < path.length - 1; i++) {
1614
+ const k = path[i];
1615
+ if (!isPlainObject(cur[k])) cur[k] = {};
1616
+ cur = cur[k];
1617
+ }
1618
+ cur[path[path.length - 1]] = value;
1619
+ }
1620
+
1621
+ // Collect the reserved namespace out of a flat key→value map (customHeaders or
1622
+ // variables). Supports both a single `io.nanobpm.agentTask` key whose value is
1623
+ // a JSON string/object, AND flattened dotpath keys like
1624
+ // `io.nanobpm.agentTask.repository.ref` (element templates emit the latter).
1625
+ function collectEnvelopeFrom(source) {
1626
+ const out = {};
1627
+ if (!isPlainObject(source)) return out;
1628
+ const whole = source[AGENT_TASK_NS];
1629
+ if (whole != null) {
1630
+ let val = whole;
1631
+ if (typeof whole === 'string') {
1632
+ try { val = JSON.parse(whole); } catch { val = undefined; }
1633
+ }
1634
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
1635
+ }
1636
+ const prefix = `${AGENT_TASK_NS}.`;
1637
+ for (const [key, value] of Object.entries(source)) {
1638
+ if (!key.startsWith(prefix)) continue;
1639
+ const rest = key.slice(prefix.length);
1640
+ if (!rest) continue;
1641
+ setPath(out, rest.split('.'), value);
1642
+ }
1643
+ return out;
1644
+ }
1645
+
1646
+ // Normalize the assembled envelope to schema v1, coercing header string values
1647
+ // (element templates write everything as strings) into bool/int as needed.
1648
+ function normalizeTaskEnvelope(customHeaders, variables) {
1649
+ const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
1650
+ const str = (v) => (v == null ? undefined : String(v));
1651
+ const env = {
1652
+ // Normalization always emits the v1 shape, so the version is forced to v1
1653
+ // (the raw input version is only a hint about how the author authored it).
1654
+ schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
1655
+ };
1656
+
1657
+ const repo = raw.repository;
1658
+ if (isPlainObject(repo) && str(repo.url)) {
1659
+ env.repository = {
1660
+ provider: str(repo.provider) || 'github',
1661
+ url: str(repo.url),
1662
+ ref: str(repo.ref),
1663
+ depth: coerceInt(repo.depth, undefined),
1664
+ submodules: coerceBool(repo.submodules, false),
1665
+ authRef: str(repo.authRef),
1562
1666
  };
1667
+ }
1563
1668
 
1564
- const child = spawn(profile.command, {
1565
- shell: true,
1566
- // Run the shell in its own process group so the timeout handler can kill
1567
- // the whole tree (shell + harness), not just the shell wrapper PID.
1568
- detached: process.platform !== 'win32',
1569
- stdio: ['pipe', 'pipe', 'pipe'],
1570
- env: {
1571
- ...process.env,
1572
- AGENT_PROFILE: profile.name,
1573
- AGENT_RANK: profile.rank,
1574
- AGENT_MODEL: profile.model || '',
1575
- AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
1576
- AGENT_JOB_TYPE: String(job.type ?? ''),
1577
- },
1578
- });
1669
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
1670
+ env.branch = {
1671
+ base: str(branch.base),
1672
+ create: str(branch.create),
1673
+ push: coerceBool(branch.push, true),
1674
+ };
1675
+
1676
+ const setup = isPlainObject(raw.setup) ? raw.setup : {};
1677
+ env.setup = {
1678
+ commands: Array.isArray(setup.commands) ? setup.commands.map(String) : [],
1679
+ env: isPlainObject(setup.env) ? setup.env : {},
1680
+ secretRefs: Array.isArray(setup.secretRefs) ? setup.secretRefs.map(String) : [],
1681
+ };
1682
+
1683
+ const task = isPlainObject(raw.task) ? raw.task : {};
1684
+ env.task = {
1685
+ prompt: str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task),
1686
+ promptFile: str(task.promptFile),
1687
+ maxIterations: coerceInt(task.maxIterations, undefined),
1688
+ timeoutMs: coerceInt(task.timeoutMs, undefined),
1689
+ allowPr: coerceBool(task.allowPr, false),
1690
+ prBase: str(task.prBase),
1691
+ };
1692
+
1693
+ return env;
1694
+ }
1695
+
1696
+ // ---- Secret resolution (pluggable; only host-env implemented for now) ------
1697
+ // Secrets are referenced by NAME, never by value, in the model. A resolver maps
1698
+ // a name → value at run time. The wrapper injects them into the child ENV, so
1699
+ // values never appear in argv or `docker inspect`.
1700
+ const hostEnvSecretResolver = {
1701
+ kind: 'host',
1702
+ resolve(name) {
1703
+ const v = process.env[name];
1704
+ return v == null || v === '' ? undefined : v;
1705
+ },
1706
+ };
1707
+
1708
+ function makeSecretResolver(kind) {
1709
+ const k = (kind || 'host').trim().toLowerCase();
1710
+ if (k === 'host' || k === '') return hostEnvSecretResolver;
1711
+ return null; // unknown → caller reports a clear error
1712
+ }
1713
+
1714
+ // Resolve the names a job needs (setup.secretRefs, plus the repo/PR credential
1715
+ // when allowPr). Returns resolved values + a list of names that were missing so
1716
+ // the caller can fail the job with a clear provisioning error.
1717
+ function resolveJobSecrets(resolver, envelope) {
1718
+ const names = new Set();
1719
+ for (const n of envelope.setup?.secretRefs || []) if (n) names.add(n);
1720
+ if (envelope.task?.allowPr) {
1721
+ const provider = envelope.repository?.provider || 'github';
1722
+ const authRef = envelope.repository?.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : undefined);
1723
+ if (authRef) names.add(authRef);
1724
+ }
1725
+ const resolved = {};
1726
+ const missing = [];
1727
+ for (const name of names) {
1728
+ const v = resolver.resolve(name);
1729
+ if (v === undefined) missing.push(name);
1730
+ else resolved[name] = v;
1731
+ }
1732
+ return { resolved, missing, names: [...names] };
1733
+ }
1734
+
1735
+ // ---- Disk hygiene (container sandboxes only) -------------------------------
1736
+ const CONTAINER_LABEL = 'nano.managed=1';
1737
+
1738
+ function containerEngineAvailable(engine) {
1739
+ try {
1740
+ const r = spawnSync(engine, ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8', timeout: 10_000 });
1741
+ return r.status === 0;
1742
+ } catch {
1743
+ return false;
1744
+ }
1745
+ }
1746
+
1747
+ // Resolve the container engine's data root. Returns null when it can't be
1748
+ // determined so the caller can fail OPEN (never fall back to an unrelated path
1749
+ // like the OS temp dir, which would shed on the wrong filesystem's free space).
1750
+ function dockerRootDir(engine) {
1751
+ try {
1752
+ const r = spawnSync(engine, ['info', '-f', '{{.DockerRootDir}}'], { encoding: 'utf8', timeout: 10_000 });
1753
+ const dir = (r.stdout || '').trim();
1754
+ if (r.status === 0 && dir) return dir;
1755
+ } catch { /* fall through */ }
1756
+ return null;
1757
+ }
1758
+
1759
+ // Fail-open disk-budget check: shed work when free space on the engine's data
1760
+ // root drops below the configured floor (mirrors nano's admission-shed pattern).
1761
+ function diskBudgetOk(engine, minFreeBytes) {
1762
+ if (!minFreeBytes || minFreeBytes <= 0) return { ok: true, free: null };
1763
+ try {
1764
+ if (typeof statfsSync !== 'function') return { ok: true, free: null };
1765
+ const root = dockerRootDir(engine);
1766
+ if (!root) return { ok: true, free: null }; // can't resolve the real root → fail open
1767
+ const st = statfsSync(root);
1768
+ const free = st.bavail * st.bsize;
1769
+ return { ok: free >= minFreeBytes, free };
1770
+ } catch {
1771
+ return { ok: true, free: null }; // never block work on a stat failure
1772
+ }
1773
+ }
1774
+
1775
+ // Reap our own leaked/finished containers. Label-scoped (never touches anything
1776
+ // we didn't create — safe on shared hosts, NEVER `system prune -a`), age-gated,
1777
+ // and skips any run id still in flight.
1778
+ function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } = {}) {
1779
+ let reaped = 0;
1780
+ try {
1781
+ const fmt = '{{.ID}}\t{{.Label "nano.run"}}\t{{.State}}\t{{.CreatedAt}}';
1782
+ const r = spawnSync(engine, ['ps', '-a', '--filter', `label=${CONTAINER_LABEL}`, '--format', fmt], { encoding: 'utf8', timeout: 15_000 });
1783
+ if (r.status !== 0) return { reaped, error: (r.stderr || '').trim() || 'ps failed' };
1784
+ const now = Date.now();
1785
+ for (const line of (r.stdout || '').split('\n')) {
1786
+ if (!line.trim()) continue;
1787
+ const [id, run, state, created] = line.split('\t');
1788
+ if (run && liveRunIds.has(run)) continue; // in-flight; leave it
1789
+ if (!/exited|dead|created/i.test(state || '')) continue; // only finished/stuck
1790
+ if (maxAgeMs > 0) {
1791
+ const createdMs = Date.parse(created || '');
1792
+ if (Number.isFinite(createdMs) && now - createdMs < maxAgeMs) continue;
1793
+ }
1794
+ const rm = spawnSync(engine, ['rm', '-f', id], { timeout: 15_000 });
1795
+ if (rm.status === 0) reaped++;
1796
+ }
1797
+ } catch (err) {
1798
+ return { reaped, error: err.message };
1799
+ }
1800
+ return { reaped };
1801
+ }
1579
1802
 
1803
+ // ---- One-shot capture (shared by host + container executors) ---------------
1804
+ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1805
+
1806
+ // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
1807
+ // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
1808
+ // uniform result. Used by both the host and container executors.
1809
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
1810
+ return new Promise((resolve) => {
1811
+ let child;
1580
1812
  const stdoutChunks = [];
1581
1813
  const stderrChunks = [];
1582
1814
  let stdoutBytes = 0;
@@ -1584,9 +1816,8 @@ function runAgentJob(profile, job, timeoutMs) {
1584
1816
  let stdoutTruncated = false;
1585
1817
  let stderrTruncated = false;
1586
1818
  let settled = false;
1587
- // Bound captured output (by BYTES, not string length) so a noisy/runaway
1588
- // harness can't grow memory without limit and crash the worker.
1589
- const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1819
+ let timer = null;
1820
+
1590
1821
  const finish = (result) => {
1591
1822
  if (settled) return;
1592
1823
  settled = true;
@@ -1594,12 +1825,17 @@ function runAgentJob(profile, job, timeoutMs) {
1594
1825
  resolve(result);
1595
1826
  };
1596
1827
 
1597
- // Kill (and fail) a child that runs longer than the job's timeout so it can
1598
- // never permanently hold a worker slot or leak the process.
1599
- const timer = timeoutMs && timeoutMs > 0
1828
+ try {
1829
+ child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
1830
+ } catch (err) {
1831
+ finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
1832
+ return;
1833
+ }
1834
+
1835
+ timer = timeoutMs && timeoutMs > 0
1600
1836
  ? setTimeout(() => {
1601
- killTree(child);
1602
- finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, truncated: stdoutTruncated, stderrTruncated });
1837
+ try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
1838
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: stdoutTruncated, stderrTruncated });
1603
1839
  }, timeoutMs)
1604
1840
  : null;
1605
1841
 
@@ -1625,19 +1861,130 @@ function runAgentJob(profile, job, timeoutMs) {
1625
1861
  finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
1626
1862
  });
1627
1863
 
1628
- // The child may exit before reading stdin; swallow the async EPIPE so it
1629
- // doesn't crash the whole worker process (only the `child` close/error
1630
- // handlers above decide the job outcome).
1631
1864
  child.stdin.on('error', () => {});
1632
1865
  try {
1633
- child.stdin.write(JSON.stringify(payload));
1866
+ if (stdinData != null) child.stdin.write(stdinData);
1634
1867
  child.stdin.end();
1635
- } catch {
1636
- // 'error' handler above resolves the promise on spawn failure.
1637
- }
1868
+ } catch { /* 'error' handler resolves on failure */ }
1869
+ });
1870
+ }
1871
+
1872
+ function buildAgentPayload(profile, job, envelope) {
1873
+ const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
1874
+ return {
1875
+ jobKey: job.jobKey,
1876
+ jobType: job.type,
1877
+ processInstanceKey: job.processInstanceKey ?? null,
1878
+ elementInstanceKey: job.elementInstanceKey ?? null,
1879
+ elementId: job.elementId ?? null,
1880
+ bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
1881
+ prompt: envelope?.task?.prompt ?? variables.prompt ?? variables.task ?? null,
1882
+ task: envelope || null,
1883
+ variables,
1884
+ customHeaders: job.customHeaders ?? {},
1885
+ profile: {
1886
+ name: profile.name,
1887
+ rank: profile.rank,
1888
+ model: profile.model,
1889
+ capabilities: profile.capabilities,
1890
+ },
1891
+ };
1892
+ }
1893
+
1894
+ function baseAgentEnv(profile, job) {
1895
+ return {
1896
+ AGENT_PROFILE: profile.name,
1897
+ AGENT_RANK: profile.rank,
1898
+ AGENT_MODEL: profile.model || '',
1899
+ AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
1900
+ AGENT_JOB_TYPE: String(job.type ?? ''),
1901
+ };
1902
+ }
1903
+
1904
+ /**
1905
+ * Run a single activated job through the profile's CLI command (one-shot),
1906
+ * dispatching on the profile's sandbox:
1907
+ * - none → spawn the command on the host (legacy behaviour).
1908
+ * - docker | podman → `run --rm` a labelled, log-capped container, piping the
1909
+ * task envelope on stdin; a run that outlives the timeout
1910
+ * is force-removed so it never leaks a slot or disk.
1911
+ * Both paths resolve to the same result contract.
1912
+ */
1913
+ function runAgentJob(profile, job, opts = {}) {
1914
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
1915
+ const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
1916
+ const agentEnv = baseAgentEnv(profile, job);
1917
+
1918
+ if (!CONTAINER_SANDBOXES.has(sandbox)) {
1919
+ return spawnCaptureOneShot({
1920
+ command: profile.command,
1921
+ shell: true,
1922
+ // Own process group so the timeout handler can kill the whole tree.
1923
+ detached: process.platform !== 'win32',
1924
+ env: { ...process.env, ...agentEnv, ...secretEnv },
1925
+ stdinData: payload,
1926
+ timeoutMs,
1927
+ onTimeout: (child) => killTree(child),
1928
+ });
1929
+ }
1930
+
1931
+ const engine = sandbox;
1932
+ const containerName = `nano-${runId}`;
1933
+ // Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
1934
+ // `docker inspect`; docker reads the value from our child's environment.
1935
+ const envArgs = [];
1936
+ for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
1937
+ for (const n of passThroughSecretNames) envArgs.push('-e', n);
1938
+ const setupEnv = isPlainObject(envelope?.setup?.env) ? envelope.setup.env : {};
1939
+ const setupEnvValues = {};
1940
+ for (const [k, v] of Object.entries(setupEnv)) { envArgs.push('-e', k); setupEnvValues[k] = String(v); }
1941
+
1942
+ const args = [
1943
+ 'run', '--rm', '-i',
1944
+ '--name', containerName,
1945
+ '--label', CONTAINER_LABEL,
1946
+ '--label', `nano.worker=${profile.name}`,
1947
+ '--label', `nano.jobKey=${job.jobKey}`,
1948
+ '--label', `nano.run=${runId}`,
1949
+ '--log-opt', 'max-size=10m',
1950
+ '--log-opt', 'max-file=3',
1951
+ ...envArgs,
1952
+ image,
1953
+ 'sh', '-c', profile.command,
1954
+ ];
1955
+
1956
+ return spawnCaptureOneShot({
1957
+ command: engine,
1958
+ args,
1959
+ shell: false,
1960
+ env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
1961
+ stdinData: payload,
1962
+ timeoutMs,
1963
+ onTimeout: (child) => {
1964
+ try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
1965
+ try { killTree(child); } catch { /* best effort */ }
1966
+ },
1638
1967
  });
1639
1968
  }
1640
1969
 
1970
+ // Shape the io.nanobpm.agentResult output envelope. branch/commits/pr are
1971
+ // reserved for increment 2 (git provisioning); increment 1 reports execution.
1972
+ function buildResultEnvelope(result, { sandbox, image }) {
1973
+ const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
1974
+ return {
1975
+ schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
1976
+ status,
1977
+ sandbox,
1978
+ image: image || null,
1979
+ output: result.stdout ?? '',
1980
+ truncated: !!result.truncated,
1981
+ stderrTruncated: !!result.stderrTruncated,
1982
+ exitCode: result.exitCode ?? null,
1983
+ signal: result.signal ?? null,
1984
+ error: result.error ?? null,
1985
+ };
1986
+ }
1987
+
1641
1988
  /**
1642
1989
  * work — turn a hire profile into live Nano job workers (one per job-type in
1643
1990
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -1680,11 +2027,63 @@ async function workAgent(req, flags) {
1680
2027
  const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
1681
2028
  const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
1682
2029
 
2030
+ // Sandbox: flag overrides the stored profile default. `none` runs on the host
2031
+ // (legacy); `docker`/`podman` run each job in a throwaway labelled container.
2032
+ const sandbox = String(flags?.sandbox ?? profile.sandbox ?? 'none').trim().toLowerCase();
2033
+ if (!SANDBOXES.includes(sandbox)) {
2034
+ logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
2035
+ process.exit(1);
2036
+ }
2037
+ const image = flags?.image ? String(flags.image).trim() : (profile.image || '');
2038
+ const isContainer = CONTAINER_SANDBOXES.has(sandbox);
2039
+ if (isContainer && !image) {
2040
+ logger.error(`--sandbox ${sandbox} requires an --image (or hire the profile with --image).`);
2041
+ process.exit(1);
2042
+ }
2043
+
2044
+ const secretResolver = makeSecretResolver(flags?.['secret-resolver']);
2045
+ if (!secretResolver) {
2046
+ logger.error(`Unknown --secret-resolver "${flags?.['secret-resolver']}". Only "host" is supported.`);
2047
+ process.exit(1);
2048
+ }
2049
+
2050
+ // Disk-hygiene knobs (container sandboxes only). Reaper age + interval and the
2051
+ // free-space admission floor mirror nano's own disk-budget/reaper patterns.
2052
+ const reapAgeMs = intFlag(flags?.['reap-age'], 60 * 60_000); // 1h
2053
+ const reapIntervalMs = intFlag(flags?.['reap-interval'], 5 * 60_000); // 5m
2054
+ const minFreeBytes = flags?.['min-free-mb'] != null
2055
+ ? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
2056
+ : 1_073_741_824; // 1 GiB default floor
2057
+
2058
+ // Tracks run ids currently executing so the reaper never removes a live
2059
+ // container out from under an in-flight job.
2060
+ const liveRunIds = new Set();
2061
+ let reaperTimer = null;
2062
+
2063
+ if (isContainer) {
2064
+ if (!containerEngineAvailable(sandbox)) {
2065
+ logger.error(`--sandbox ${sandbox} selected but "${sandbox}" is not available/running on this host.`);
2066
+ process.exit(1);
2067
+ }
2068
+ // Age-gate the startup sweep too (not maxAgeMs:0): on a shared host other
2069
+ // worker processes may have just-created containers not yet in liveRunIds,
2070
+ // so only reap ones older than --reap-age, matching the interval reaper.
2071
+ const initial = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
2072
+ if (initial.reaped > 0) logger.info(`Reaped ${initial.reaped} leftover agent container(s) at startup.`);
2073
+ if (initial.error) logger.warn(`Startup reap warning: ${initial.error}`);
2074
+ reaperTimer = setInterval(() => {
2075
+ const r = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
2076
+ if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished agent container(s).`);
2077
+ }, reapIntervalMs);
2078
+ if (typeof reaperTimer.unref === 'function') reaperTimer.unref();
2079
+ }
2080
+
1683
2081
  const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
1684
2082
  const camunda = globalThis.c8ctl.createClient();
1685
2083
 
1686
2084
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
1687
2085
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
2086
+ logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
1688
2087
  logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
1689
2088
  logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
1690
2089
  logger.info('Polling for work — press Ctrl-C to stop.');
@@ -1697,10 +2096,58 @@ async function workAgent(req, flags) {
1697
2096
  jobTimeoutMs,
1698
2097
  jobHandler: async (job) => {
1699
2098
  logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
1700
- const result = await runAgentJob(profile, job, jobTimeoutMs);
2099
+
2100
+ // Disk-budget admission shed: if the engine data root is below the free
2101
+ // floor, don't start a container — fail (retryable) so work sheds until
2102
+ // the reaper/host frees space.
2103
+ if (isContainer) {
2104
+ const budget = diskBudgetOk(sandbox, minFreeBytes);
2105
+ if (!budget.ok) {
2106
+ const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
2107
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2108
+ logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
2109
+ return job.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
2110
+ }
2111
+ }
2112
+
2113
+ // Assemble + normalize the task envelope from headers (defaults) and
2114
+ // variables (overrides), then resolve any secrets it references.
2115
+ const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
2116
+ const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
2117
+ if (missing.length > 0) {
2118
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2119
+ const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
2120
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
2121
+ return job.fail({ errorMessage: msg, retries });
2122
+ }
2123
+
2124
+ const runId = randomUUID();
2125
+ if (isContainer) liveRunIds.add(runId);
2126
+ let result;
2127
+ try {
2128
+ result = await runAgentJob(profile, job, {
2129
+ timeoutMs: jobTimeoutMs,
2130
+ envelope,
2131
+ sandbox,
2132
+ image,
2133
+ runId,
2134
+ secretEnv: resolved,
2135
+ passThroughSecretNames: names,
2136
+ });
2137
+ } finally {
2138
+ if (isContainer) liveRunIds.delete(runId);
2139
+ }
2140
+
2141
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
1701
2142
  if (result.ok) {
1702
2143
  logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
1703
- return job.complete({ output: result.stdout, exitCode: 0, agent: profile.name, truncated: Boolean(result.truncated) });
2144
+ return job.complete({
2145
+ [AGENT_RESULT_KEY]: resultEnvelope,
2146
+ output: result.stdout,
2147
+ exitCode: 0,
2148
+ agent: profile.name,
2149
+ truncated: Boolean(result.truncated),
2150
+ });
1704
2151
  }
1705
2152
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
1706
2153
  const detail = result.error
@@ -1710,6 +2157,7 @@ async function workAgent(req, flags) {
1710
2157
  return job.fail({
1711
2158
  errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
1712
2159
  retries,
2160
+ variables: { [AGENT_RESULT_KEY]: resultEnvelope },
1713
2161
  });
1714
2162
  },
1715
2163
  }),
@@ -1722,6 +2170,7 @@ async function workAgent(req, flags) {
1722
2170
  if (stopping) return;
1723
2171
  stopping = true;
1724
2172
  logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
2173
+ if (reaperTimer) clearInterval(reaperTimer);
1725
2174
  let stopFailures = 0;
1726
2175
  await Promise.all(
1727
2176
  workers.map(async (w) => {
@@ -2973,6 +3422,27 @@ function parseProcessosRequest(args, flags) {
2973
3422
  // Internal helpers exported for tests/tooling only. c8ctl consumes just
2974
3423
  // `metadata` and `commands`; these named exports are inert to it.
2975
3424
  export { resolveBinary, findBinary, launcherEnvMarkers };
3425
+ export {
3426
+ normalizeTaskEnvelope,
3427
+ collectEnvelopeFrom,
3428
+ coerceBool,
3429
+ coerceInt,
3430
+ deepMerge,
3431
+ resolveJobSecrets,
3432
+ makeSecretResolver,
3433
+ hostEnvSecretResolver,
3434
+ buildAgentPayload,
3435
+ buildResultEnvelope,
3436
+ reapAgentContainers,
3437
+ diskBudgetOk,
3438
+ containerEngineAvailable,
3439
+ runAgentJob,
3440
+ normalizeStoredProfile,
3441
+ jobTypeMatrix,
3442
+ AGENT_TASK_NS,
3443
+ AGENT_RESULT_KEY,
3444
+ SANDBOXES,
3445
+ };
2976
3446
 
2977
3447
  export const metadata = {
2978
3448
  name: 'c8ctl-plugin-nano',
@@ -3007,7 +3477,9 @@ export const metadata = {
3007
3477
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
3008
3478
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
3009
3479
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
3480
+ { command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
3010
3481
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
3482
+ { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
3011
3483
  ],
3012
3484
  },
3013
3485
  processos: {
@@ -3041,8 +3513,7 @@ export const commands = {
3041
3513
  'in-memory': { type: 'boolean', description: 'start: run with NO on-disk journal/read-model (in-memory engine; state lost on restart). Alias: --no-journal' },
3042
3514
  'no-journal': { type: 'boolean', description: 'start: alias for --in-memory' },
3043
3515
  'history-max': { type: 'string', description: 'start: cap retained terminal instances in the read model (NANOBPMN_HISTORY_MAX_INSTANCES; 0/unset = unbounded)' },
3044
- console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio). Alias: --profile' },
3045
- profile: { type: 'string', description: 'start: alias for --console (off|observe|studio; default studio)' },
3516
+ console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio)' },
3046
3517
  follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
3047
3518
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
3048
3519
  force: { type: 'boolean', description: 'start: stop any existing cluster first' },
@@ -3054,6 +3525,12 @@ export const commands = {
3054
3525
  command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
3055
3526
  model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
3056
3527
  capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
3528
+ sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
3529
+ image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
3530
+ 'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
3531
+ 'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container is reaped (default 3600000)' },
3532
+ 'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers in ms (default 300000)' },
3533
+ 'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
3057
3534
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3058
3535
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3059
3536
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
@@ -3199,8 +3676,8 @@ function printUsage() {
3199
3676
  console.log(' c8ctl nano set <bin|model-dir> <path>');
3200
3677
  console.log(' c8ctl nano config');
3201
3678
  console.log(' c8ctl nano update [--check]');
3202
- console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--list]');
3203
- console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>]');
3679
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--list]');
3680
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--secret-resolver host] [--min-free-mb <n>]');
3204
3681
  console.log('');
3205
3682
  console.log('Subcommands:');
3206
3683
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -3226,11 +3703,25 @@ function printUsage() {
3226
3703
  console.log(' --capture start: enable trace capture (recorded-input replay) on every node');
3227
3704
  console.log(' --in-memory start: run with NO on-disk journal/read-model (alias --no-journal; state lost on restart)');
3228
3705
  console.log(' --history-max <n> start: cap retained terminal instances in the read model (0/unset = unbounded)');
3229
- console.log(' --console <profile> start: runtime console profile off|observe|studio (alias --profile; default studio)');
3706
+ console.log(' --console <profile> start: runtime console profile off|observe|studio (default studio)');
3230
3707
  console.log(' --binary <path> Path to the nanobpmn server binary (overrides "set bin")');
3231
3708
  console.log(' --purge stop: also delete per-node engine data');
3232
3709
  console.log(' --force start: stop any existing cluster first');
3233
3710
  console.log(' --workspace clean: also delete the workspace (models + workers)');
3711
+ console.log(' --name <n> hire/work: agent profile name (alt to positional arg)');
3712
+ console.log(' --rank <r> hire: agent rank (principal|senior|junior|decider)');
3713
+ console.log(' --command <c> hire: CLI command that runs the agent harness');
3714
+ console.log(' --model <m> hire: model name passed to the harness (AGENT_MODEL)');
3715
+ console.log(' --capabilities <a,b> hire: comma-separated capability list');
3716
+ console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
3717
+ console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
3718
+ console.log(' --list hire: list existing agent profiles instead of creating one');
3719
+ console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
3720
+ console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
3721
+ console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
3722
+ console.log(' --reap-age <ms> work: age before a finished agent container is reaped (default 3600000)');
3723
+ console.log(' --reap-interval <ms> work: how often to sweep finished agent containers (default 300000)');
3724
+ console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
3234
3725
  console.log('');
3235
3726
  console.log('Persistent assets:');
3236
3727
  console.log(' Models and workers live in the workspace dir (NANOBPMN_WORKSPACE_DIR),');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "lint": "node --check c8ctl-plugin.js",
30
- "test": "node --check c8ctl-plugin.js"
30
+ "test": "node --check c8ctl-plugin.js && node --test"
31
31
  },
32
32
  "license": "MIT",
33
33
  "engines": {
@@ -49,12 +49,12 @@
49
49
  "semantic-release": "^25.0.3"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.9.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.9.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.9.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.9.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.9.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.9.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.9.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.10.1",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.10.1",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.10.1",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.10.1",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.10.1",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.10.1",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.10.1"
59
59
  }
60
60
  }