c8ctl-plugin-nano 1.17.0 → 1.18.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
@@ -150,6 +150,9 @@ c8ctl nano hire
150
150
  c8ctl nano hire --name reviewer --rank senior --command copilot \
151
151
  --model gpt-5 --capabilities code-review,testing
152
152
 
153
+ # Give the harness command-line switches (e.g. run copilot with --allow-all)
154
+ c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all
155
+
153
156
  # List profiles
154
157
  c8ctl nano hire --list
155
158
  ```
@@ -327,6 +330,24 @@ envelope layers on top (job-specific tuning wins), and the reserved `AGENT_*`
327
330
  variables and resolved secrets always win over user-supplied env so they can't be
328
331
  shadowed. For **secret** values use `secretRefs`, not `--env`.
329
332
 
333
+ **Command-line switches.** Some harnesses take switches rather than env vars —
334
+ e.g. `copilot --allow-all`. Append them to the harness command with a repeatable
335
+ `--arg` (each `--arg` is one argv token). They are persisted on the profile at
336
+ hire time and can be extended at work time:
337
+
338
+ ```bash
339
+ c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all
340
+ c8ctl nano work coder --arg --verbose # appends to the profile args
341
+ ```
342
+
343
+ The command is spawned through a shell (so `command` still resolves on `PATH`),
344
+ but each `--arg` is shell-quoted as a single literal token, so a value with
345
+ spaces or shell metacharacters can't break out or inject. They apply on the
346
+ container path on any OS, and on the host path on POSIX systems. **On a Windows
347
+ host (`sandbox=none`), `--arg` is rejected** with a clear error — the POSIX
348
+ quoting isn't honoured by `cmd.exe` — so use a container sandbox
349
+ (`--sandbox docker|podman`) or bake the switches into `--command` there.
350
+
330
351
  **Disk hygiene.** Host job **workspaces** and container sandboxes both get
331
352
  automatic cleanup so leaked artifacts can't fill the disk. Workspaces under
332
353
  `<state>/agent-runs` are removed after each job and swept at startup + on
package/c8ctl-plugin.js CHANGED
@@ -1416,6 +1416,39 @@ function parseEnvPairs(input) {
1416
1416
  return { env, errors };
1417
1417
  }
1418
1418
 
1419
+ // Normalize a stored/CLI argument list (string | string[]) into a clean string[]:
1420
+ // each entry is one whole argv token (e.g. "--allow-all"), coerced to a string,
1421
+ // with null/undefined and empty tokens dropped. Interior whitespace is preserved
1422
+ // so a single arg may carry a value like "--foo=a b" intact.
1423
+ function normalizeArgList(input) {
1424
+ const list = input == null ? [] : (Array.isArray(input) ? input : [input]);
1425
+ const out = [];
1426
+ for (const item of list) {
1427
+ if (item == null) continue;
1428
+ const s = String(item);
1429
+ if (s.length === 0) continue;
1430
+ out.push(s);
1431
+ }
1432
+ return out;
1433
+ }
1434
+
1435
+ // POSIX single-quote a string so it survives `sh -c`/shell:true as one literal
1436
+ // argv token, no matter what it contains (spaces, $, quotes, globs). Empty
1437
+ // string → ''. This is what keeps structured `--arg` values injection-safe even
1438
+ // though the harness is spawned through a shell (for PATH resolution).
1439
+ function shQuote(s) {
1440
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
1441
+ }
1442
+
1443
+ // Build the shell command line for the agent harness: the base command followed
1444
+ // by each structured argument, shell-quoted. With no args the command is used
1445
+ // verbatim (preserving pre-existing hires that baked switches into the command).
1446
+ function buildAgentCommandLine(command, args) {
1447
+ const list = normalizeArgList(args);
1448
+ if (list.length === 0) return command;
1449
+ return `${command} ${list.map(shQuote).join(' ')}`;
1450
+ }
1451
+
1419
1452
  // A worker job-type token: rank/capability tokens use `:` (rank↔cap) and `+`
1420
1453
  // (combined caps) as delimiters, and code-first `@nanobpm/workflow` job types
1421
1454
  // are `<flowId>:<taskName>` or an explicit override. The first character must be
@@ -1513,6 +1546,7 @@ function normalizeStoredProfile(name, profile) {
1513
1546
  name,
1514
1547
  rank,
1515
1548
  command,
1549
+ args: normalizeArgList(profile.args),
1516
1550
  model: typeof profile.model === 'string' ? profile.model.trim() : '',
1517
1551
  capabilities: normalizeCapabilities(profile.capabilities),
1518
1552
  sandbox,
@@ -1541,7 +1575,7 @@ async function hireWorker(req, flags) {
1541
1575
  logger.info('Hired agent profiles:');
1542
1576
  for (const name of names.sort()) {
1543
1577
  const p = hires[name];
1544
- logger.info(` ${name} [${p.rank}] ${p.command} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
1578
+ logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
1545
1579
  }
1546
1580
  logger.info('');
1547
1581
  logger.info('Put one to work with: c8ctl nano work <name>');
@@ -1557,11 +1591,14 @@ async function hireWorker(req, flags) {
1557
1591
  let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
1558
1592
  let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
1559
1593
  let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
1594
+ // Structured command-line switches appended to the command when spawned, e.g.
1595
+ // `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
1596
+ const commandArgs = normalizeArgList(flags?.arg);
1560
1597
  const envFromFlags = flags?.env !== undefined;
1561
1598
  const { env: profileEnv, errors: envErrors } = parseEnvPairs(flags?.env);
1562
1599
  if (envErrors.length > 0) {
1563
1600
  logger.error(envErrors.join('; '));
1564
- logger.info('Example: c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1 --env FOO=bar');
1601
+ logger.info('Example: c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all --env COPILOT_ENABLE_ALL_TOOLS=1');
1565
1602
  process.exit(1);
1566
1603
  }
1567
1604
 
@@ -1659,6 +1696,7 @@ async function hireWorker(req, flags) {
1659
1696
  name,
1660
1697
  rank,
1661
1698
  command,
1699
+ args: commandArgs,
1662
1700
  model: model || '',
1663
1701
  capabilities: normalizeCapabilities(capabilities),
1664
1702
  sandbox,
@@ -1669,9 +1707,10 @@ async function hireWorker(req, flags) {
1669
1707
  writeHire(profile);
1670
1708
 
1671
1709
  const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
1672
- logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
1710
+ logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${buildAgentCommandLine(profile.command, profile.args)}`);
1673
1711
  logger.info(` model: ${profile.model || '(none)'}`);
1674
1712
  logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1713
+ if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
1675
1714
  logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
1676
1715
  const envKeys = Object.keys(profile.env);
1677
1716
  if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
@@ -2450,9 +2489,13 @@ function baseAgentEnv(profile, job) {
2450
2489
  * Both paths resolve to the same result contract.
2451
2490
  */
2452
2491
  function runAgentJob(profile, job, opts = {}) {
2453
- const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr } = opts;
2492
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs } = opts;
2454
2493
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
2455
2494
  const agentEnv = baseAgentEnv(profile, job);
2495
+ // The harness command line: the profile command plus its structured switches
2496
+ // (persisted `--arg`s, possibly extended at work time via opts.args), each
2497
+ // shell-quoted. Spawned through a shell so `command` still resolves on PATH.
2498
+ const commandLine = buildAgentCommandLine(profile.command, commandArgs ?? profile.args);
2456
2499
  // Static, non-secret env for the harness: the worker/profile's env (e.g. a
2457
2500
  // harness's permission toggles) plus the per-job envelope's setup.env
2458
2501
  // (job-specific tuning wins over the profile default). Reserved AGENT_* and
@@ -2461,9 +2504,16 @@ function runAgentJob(profile, job, opts = {}) {
2461
2504
 
2462
2505
  if (!CONTAINER_SANDBOXES.has(sandbox)) {
2463
2506
  // Host: hand the agent the result file by its real path.
2507
+ // Defense in depth: --arg tokens are POSIX single-quoted, which cmd.exe on
2508
+ // a Windows host does not honour, so args would be mis-parsed under the
2509
+ // shell:true spawn. workAgent already rejects this at startup, but guard the
2510
+ // spawn site too so the invariant holds for any direct caller of runAgentJob.
2511
+ if (commandLine !== profile.command && process.platform === 'win32') {
2512
+ return Promise.resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: 'command-line args (--arg) are not supported for host execution on Windows; use a container sandbox or bake switches into the command', truncated: false, stderrTruncated: false });
2513
+ }
2464
2514
  const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
2465
2515
  return spawnCaptureOneShot({
2466
- command: profile.command,
2516
+ command: commandLine,
2467
2517
  shell: true,
2468
2518
  // Own process group so the timeout handler can kill the whole tree.
2469
2519
  detached: process.platform !== 'win32',
@@ -2516,7 +2566,7 @@ function runAgentJob(profile, job, opts = {}) {
2516
2566
  ...mountArgs,
2517
2567
  ...envArgs,
2518
2568
  image,
2519
- 'sh', '-c', profile.command,
2569
+ 'sh', '-c', commandLine,
2520
2570
  ];
2521
2571
 
2522
2572
  return spawnCaptureOneShot({
@@ -2618,6 +2668,11 @@ async function workAgent(req, flags) {
2618
2668
  }
2619
2669
  const profileEnv = { ...profile.env, ...workEnv };
2620
2670
 
2671
+ // Structured command-line switches: the profile's persisted `--arg`s, extended
2672
+ // by any work-time `--arg` (appended). Lets an operator add switches (e.g.
2673
+ // `--allow-all`) at dispatch time without re-hiring.
2674
+ const effectiveArgs = [...profile.args, ...normalizeArgList(flags?.arg)];
2675
+
2621
2676
  const intFlag = (v, dflt) => {
2622
2677
  const n = Number.parseInt(String(v ?? ''), 10);
2623
2678
  return Number.isFinite(n) && n > 0 ? n : dflt;
@@ -2638,6 +2693,17 @@ async function workAgent(req, flags) {
2638
2693
  logger.error(`--sandbox ${sandbox} requires an --image (or hire the profile with --image).`);
2639
2694
  process.exit(1);
2640
2695
  }
2696
+ // Structured --arg tokens are POSIX single-quoted (shQuote) for the harness
2697
+ // shell. On the host path that shell is the platform default — cmd.exe on
2698
+ // Windows, which does not honour single quotes — so the quoting would leak
2699
+ // literal quote characters and mis-parse the switches. The container path
2700
+ // always targets the image's `sh`, so it stays correct regardless of host OS.
2701
+ // Fail fast with actionable guidance rather than silently corrupting argv.
2702
+ if (!isContainer && effectiveArgs.length > 0 && process.platform === 'win32') {
2703
+ logger.error('--arg is not supported for host execution on Windows (cmd.exe does not honour POSIX quoting).');
2704
+ logger.error('Use a container sandbox (--sandbox docker|podman --image <ref>) or bake the switches into --command.');
2705
+ process.exit(1);
2706
+ }
2641
2707
 
2642
2708
  const secretResolver = makeSecretResolver(flags?.['secret-resolver']);
2643
2709
  if (!secretResolver) {
@@ -2713,7 +2779,7 @@ async function workAgent(req, flags) {
2713
2779
  const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
2714
2780
  const camunda = globalThis.c8ctl.createClient();
2715
2781
 
2716
- logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
2782
+ logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
2717
2783
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
2718
2784
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
2719
2785
  const profileEnvKeys = Object.keys(profileEnv);
@@ -2730,7 +2796,7 @@ async function workAgent(req, flags) {
2730
2796
  maxParallelJobs,
2731
2797
  jobTimeoutMs,
2732
2798
  jobHandler: async (job) => {
2733
- logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
2799
+ logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
2734
2800
 
2735
2801
  // Disk-budget admission shed: if the engine data root is below the free
2736
2802
  // floor, don't start a container — fail (retryable) so work sheds until
@@ -2824,6 +2890,7 @@ async function workAgent(req, flags) {
2824
2890
  resultFile,
2825
2891
  stream,
2826
2892
  streamPrefix: `[${jobType} ${job.jobKey}] `,
2893
+ args: effectiveArgs,
2827
2894
  // Route the --stream tee through c8ctl's output-mode-aware logger so
2828
2895
  // spying never corrupts a structured/JSON output mode.
2829
2896
  onStreamOut: stream ? (line) => logger.info(line) : undefined,
@@ -4273,6 +4340,9 @@ export {
4273
4340
  sanitizeResultVars,
4274
4341
  parseEnvPairs,
4275
4342
  normalizeEnvMap,
4343
+ normalizeArgList,
4344
+ shQuote,
4345
+ buildAgentCommandLine,
4276
4346
  reapAgentContainers,
4277
4347
  diskBudgetOk,
4278
4348
  containerEngineAvailable,
@@ -4327,6 +4397,7 @@ export const metadata = {
4327
4397
  { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
4328
4398
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
4329
4399
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
4400
+ { command: 'c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all', description: 'Hire copilot with a command-line switch (copilot --allow-all)' },
4330
4401
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1', description: 'Persist a harness startup env var (e.g. permissions) on the profile' },
4331
4402
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
4332
4403
  { 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' },
@@ -4375,6 +4446,7 @@ export const commands = {
4375
4446
  name: { type: 'string', description: 'hire/work: agent profile name (alt to positional arg)' },
4376
4447
  rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
4377
4448
  command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
4449
+ arg: { type: 'string', multiple: true, description: 'hire/work: command-line switch/arg appended to the harness command (repeatable), e.g. --arg --allow-all. Persisted on hire; work appends more.' },
4378
4450
  model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
4379
4451
  capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
4380
4452
  sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
@@ -4533,8 +4605,8 @@ function printUsage() {
4533
4605
  console.log(' c8ctl nano set <bin|model-dir> <path>');
4534
4606
  console.log(' c8ctl nano config');
4535
4607
  console.log(' c8ctl nano update [--check]');
4536
- 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]');
4537
- console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
4608
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
4609
+ console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--job-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
4538
4610
  console.log('');
4539
4611
  console.log('Subcommands:');
4540
4612
  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.17.0",
3
+ "version": "1.18.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.17.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.17.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.17.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.17.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.17.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.17.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.17.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.18.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.18.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.18.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.18.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.18.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.18.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.18.0"
59
59
  }
60
60
  }