c8ctl-plugin-nano 1.10.1 → 1.12.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.
Files changed (3) hide show
  1. package/README.md +80 -8
  2. package/c8ctl-plugin.js +687 -23
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -23,7 +23,8 @@ platform so there is nothing to compile.
23
23
  It adds a single `nano` command:
24
24
 
25
25
  ```bash
26
- c8ctl nano start|status|stop|restart|logs|clean|set|config
26
+ c8ctl nano start|status|stop|restart|logs|pause|resume|clean|set|config|update
27
+ c8ctl nano hire|work # turn a CLI agent harness into a Nano job worker
27
28
  ```
28
29
 
29
30
  `nano start N` spawns **N** nanobpmn node processes wired to talk to each other
@@ -220,7 +221,48 @@ them into a nested object and coerces `"true"/"false"` → bool and numeric
220
221
  strings → int. The normalized shape is
221
222
  `{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
222
223
  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
+ `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`). When a repository was
225
+ provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, gitError?, pr?}`.
226
+
227
+ **Git provisioning (host).** When `--sandbox none` (the default) and the envelope
228
+ carries a `repository.url`, the plugin provisions a workspace on the host around
229
+ the harness:
230
+
231
+ 1. resolve the optional repo credential (`repository.authRef`, or `GITHUB_TOKEN`
232
+ for GitHub) — absent ⇒ anonymous clone;
233
+ 2. `git clone` (honouring `depth`/`submodules`, and `repository.ref`/`branch.base`
234
+ as the checkout target) into a throwaway workspace under
235
+ `<state>/agent-runs/run-*`;
236
+ 3. create `branch.create` (if set) off that target;
237
+ 4. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
238
+ `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF` exported and the job
239
+ envelope on stdin;
240
+ 5. on success, enumerate new commits, `git push` the branch when `branch.push`
241
+ (default true), and — when `task.allowPr` — **reconcile the PR the agent
242
+ opened** for the branch (`gh pr list --head <branch>`; `openedBy` reports the
243
+ PR's actual author login, or `null` when none is found).
244
+
245
+ The token is delivered to git via `GIT_ASKPASS` (env), never on argv or in the
246
+ remote URL, and is redacted from all logs/results. Credential helpers are
247
+ **always** disabled for the clone/fetch/push (`-c credential.helper=`), even when
248
+ a token is present, so a helper like `store`/keychain can never persist the
249
+ job's token to disk — `GIT_ASKPASS` supplies the secret directly. When **no**
250
+ token is resolved the clone is *additionally* anonymous **for HTTPS remotes**:
251
+ inherited `GIT_ASKPASS`/`SSH_ASKPASS` are cleared, and the operator's global git
252
+ config is neutralized (`GIT_CONFIG_GLOBAL` → the platform null device,
253
+ `/dev/null` or `NUL` on Windows) so knobs like `http.*.extraHeader`
254
+ or `url.*.insteadOf` can't silently inject operator credentials. (An **SSH**
255
+ remote — `git@…`/`ssh://…` — can still authenticate via the host's SSH
256
+ agent/config; use HTTPS URLs if you need a guaranteed-anonymous clone.)
257
+ Token-backed jobs keep global config (e.g. `http.proxy`). A push failure is
258
+ reported as `pushError` (the job still completes) so a later BPMN step can drive
259
+ the merge; a clone/checkout failure sheds the job (retryable). Workspaces are
260
+ deleted after each job (keep them with `--keep-runs`).
261
+
262
+ ```bash
263
+ # The harness sees a cloned repo at $AGENT_WORKSPACE; branch/push/PR are handled for it.
264
+ c8ctl nano work coder # sandbox=none: repository-bearing jobs are provisioned on the host
265
+ ```
224
266
 
225
267
  **Sandbox.** By default the command runs on the host (`--sandbox none`). Pass
226
268
  `--sandbox docker` (or `podman`) with an `--image` to run **each job in a
@@ -236,7 +278,16 @@ c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or overr
236
278
  Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
237
279
  `nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
238
280
  a run that outlives `--job-timeout` is force-removed. The envelope is piped on
239
- the container's stdin exactly as on the host.
281
+ the container's stdin exactly as on the host. (Container-side git provisioning —
282
+ strong isolation — is a later increment; container jobs don't clone yet.)
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.
240
291
 
241
292
  **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
242
293
  (and the repo/PR credential when `task.allowPr` is set — defaulting to
@@ -245,8 +296,29 @@ the container's stdin exactly as on the host.
245
296
  container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
246
297
  A missing required secret fails the job with a clear provisioning message.
247
298
 
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
299
+ **Harness env (non-secret).** A harness often needs static startup configuration
300
+ e.g. a permission toggle to start a coding CLI with its tools enabled. Persist
301
+ these on the profile at hire time and/or add them at work time (repeatable
302
+ `--env NAME=VALUE`); work-time values extend/override the profile's:
303
+
304
+ ```bash
305
+ c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1
306
+ c8ctl nano work coder --env EXTRA_FLAG=on # extends/overrides the profile env
307
+ ```
308
+
309
+ Interactive `hire` (no `--env`) prompts for these one `NAME=VALUE` at a time
310
+ (blank to finish), so values may safely contain `=` or `,`.
311
+
312
+ They apply on both the host and container paths. Per-job `setup.env` from the
313
+ envelope layers on top (job-specific tuning wins), and the reserved `AGENT_*`
314
+ variables and resolved secrets always win over user-supplied env so they can't be
315
+ shadowed. For **secret** values use `secretRefs`, not `--env`.
316
+
317
+ **Disk hygiene.** Host job **workspaces** and container sandboxes both get
318
+ automatic cleanup so leaked artifacts can't fill the disk. Workspaces under
319
+ `<state>/agent-runs` are removed after each job and swept at startup + on
320
+ `--reap-interval` (leftovers older than `--reap-age`, in-flight dirs skipped).
321
+ For container sandboxes a **label-scoped** reaper runs at worker startup
250
322
  and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
251
323
  removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
252
324
  default `3600000` = 1h) while **skipping any run still in flight** — it never
@@ -254,9 +326,9 @@ touches containers it didn't create and never `system prune`s. A **disk-budget
254
326
  admission shed** fails (retryable) new jobs when the engine data root has less
255
327
  than `--min-free-mb` MB free (default `1024`).
256
328
 
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)
329
+ > Container-side git provisioning (strong isolation) and the
330
+ > Vercel/Sandcastle provider are **later increments** — the envelope names above
331
+ > are frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
260
332
  > can be built against this contract.
261
333
 
262
334
  ## Cleaning up disk
package/c8ctl-plugin.js CHANGED
@@ -41,10 +41,12 @@ import {
41
41
  renameSync,
42
42
  realpathSync,
43
43
  statfsSync,
44
+ lstatSync,
45
+ mkdtempSync,
44
46
  } from 'node:fs';
45
47
  import { randomUUID } from 'node:crypto';
46
- import { homedir, platform as osPlatform } from 'node:os';
47
- import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
48
+ import { homedir, platform as osPlatform, devNull } from 'node:os';
49
+ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
48
50
  import { createRequire } from 'node:module';
49
51
  import { fileURLToPath } from 'node:url';
50
52
  import { createInterface } from 'node:readline/promises';
@@ -1316,6 +1318,41 @@ function normalizeCapabilities(input) {
1316
1318
  return [...new Set(raw.map((c) => String(c).trim().toLowerCase()).filter(Boolean))].sort();
1317
1319
  }
1318
1320
 
1321
+ // A conventional (POSIX-ish) environment variable name.
1322
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1323
+
1324
+ // Normalize a stored/model env map into a clean { NAME: "value" } object:
1325
+ // drops entries with an invalid name, coerces values to strings. Used for the
1326
+ // profile's static env and the per-job envelope's setup.env.
1327
+ function normalizeEnvMap(input) {
1328
+ const out = {};
1329
+ if (!isPlainObject(input)) return out;
1330
+ for (const [k, v] of Object.entries(input)) {
1331
+ if (!ENV_NAME_RE.test(k)) continue;
1332
+ if (v == null) continue;
1333
+ out[k] = String(v);
1334
+ }
1335
+ return out;
1336
+ }
1337
+
1338
+ // Parse repeatable `--env NAME=VALUE` CLI input (string | string[]) into a map.
1339
+ // The value may contain `=`; only the first `=` splits. Returns { env, errors }.
1340
+ function parseEnvPairs(input) {
1341
+ const list = input == null ? [] : (Array.isArray(input) ? input : [input]);
1342
+ const env = {};
1343
+ const errors = [];
1344
+ for (const item of list) {
1345
+ const s = String(item);
1346
+ const eq = s.indexOf('=');
1347
+ // Never echo the value in diagnostics — a user may pass a secret via --env.
1348
+ if (eq <= 0) { errors.push(`--env entry ${eq === 0 ? 'has an empty name' : 'must be NAME=VALUE'} (value hidden)`); continue; }
1349
+ const name = s.slice(0, eq);
1350
+ if (!ENV_NAME_RE.test(name)) { errors.push(`--env name "${name}" is invalid (must match ${ENV_NAME_RE.source})`); continue; }
1351
+ env[name] = s.slice(eq + 1);
1352
+ }
1353
+ return { env, errors };
1354
+ }
1355
+
1319
1356
  /** A profile name must be a safe, filesystem/token-friendly slug. */
1320
1357
  function isValidProfileName(name) {
1321
1358
  return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
@@ -1388,6 +1425,7 @@ function normalizeStoredProfile(name, profile) {
1388
1425
  capabilities: normalizeCapabilities(profile.capabilities),
1389
1426
  sandbox,
1390
1427
  image,
1428
+ env: normalizeEnvMap(profile.env),
1391
1429
  },
1392
1430
  };
1393
1431
  }
@@ -1427,8 +1465,19 @@ async function hireWorker(req, flags) {
1427
1465
  let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
1428
1466
  let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
1429
1467
  let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
1468
+ const envFromFlags = flags?.env !== undefined;
1469
+ const { env: profileEnv, errors: envErrors } = parseEnvPairs(flags?.env);
1470
+ if (envErrors.length > 0) {
1471
+ logger.error(envErrors.join('; '));
1472
+ logger.info('Example: c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1 --env FOO=bar');
1473
+ process.exit(1);
1474
+ }
1430
1475
 
1431
1476
  const missingRequired = !name || !rank || !command;
1477
+ // NOTE: --env is deliberately NOT part of missingOptional — a fully-specified
1478
+ // scripted hire must not be forced into interactive mode just to skip env.
1479
+ // The env prompt below is gated separately on !envFromFlags, so it only runs
1480
+ // when we're already interactive for another reason.
1432
1481
  const missingOptional = model === undefined || capabilities === undefined;
1433
1482
  const interactive = process.stdin.isTTY && process.stdout.isTTY;
1434
1483
 
@@ -1467,6 +1516,19 @@ async function hireWorker(req, flags) {
1467
1516
  if (capabilities === undefined) {
1468
1517
  capabilities = (await rl.question('Capabilities (comma-separated, optional): ')).trim();
1469
1518
  }
1519
+ // Static harness env (permission toggles, etc.). Prompted one NAME=VALUE at
1520
+ // a time — blank finishes — so values may safely contain '=' and ','.
1521
+ // Skipped when --env was supplied on the command line.
1522
+ if (!envFromFlags) {
1523
+ console.log('Harness env vars — NAME=VALUE, blank to finish (optional):');
1524
+ for (;;) {
1525
+ const ans = (await rl.question(' env (NAME=VALUE): ')).trim();
1526
+ if (!ans) break;
1527
+ const { env: one, errors } = parseEnvPairs([ans]);
1528
+ if (errors.length > 0) { console.log(` ${errors.join('; ')}`); continue; }
1529
+ Object.assign(profileEnv, one);
1530
+ }
1531
+ }
1470
1532
  } finally {
1471
1533
  rl.close();
1472
1534
  }
@@ -1509,6 +1571,7 @@ async function hireWorker(req, flags) {
1509
1571
  capabilities: normalizeCapabilities(capabilities),
1510
1572
  sandbox,
1511
1573
  image: image || '',
1574
+ env: profileEnv,
1512
1575
  createdAt: new Date().toISOString(),
1513
1576
  };
1514
1577
  writeHire(profile);
@@ -1518,6 +1581,8 @@ async function hireWorker(req, flags) {
1518
1581
  logger.info(` model: ${profile.model || '(none)'}`);
1519
1582
  logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1520
1583
  logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
1584
+ const envKeys = Object.keys(profile.env);
1585
+ if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
1521
1586
  logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
1522
1587
  logger.info(`Put it to work with: c8ctl nano work ${name}`);
1523
1588
  }
@@ -1574,6 +1639,95 @@ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
1574
1639
  // The result-envelope version is intentionally independent of the task-envelope
1575
1640
  // version so the two contracts can evolve separately without silently coupling.
1576
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
+
1577
1731
  const SANDBOXES = ['none', 'docker', 'podman'];
1578
1732
  // Only container-based sandboxes need an image / disk hygiene / a runtime bin.
1579
1733
  const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
@@ -1657,7 +1811,7 @@ function normalizeTaskEnvelope(customHeaders, variables) {
1657
1811
  const repo = raw.repository;
1658
1812
  if (isPlainObject(repo) && str(repo.url)) {
1659
1813
  env.repository = {
1660
- provider: str(repo.provider) || 'github',
1814
+ provider: (str(repo.provider) || 'github').toLowerCase(),
1661
1815
  url: str(repo.url),
1662
1816
  ref: str(repo.ref),
1663
1817
  depth: coerceInt(repo.depth, undefined),
@@ -1800,13 +1954,271 @@ function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } =
1800
1954
  return { reaped };
1801
1955
  }
1802
1956
 
1957
+ // ---- Git provisioning (issue #8, increment 2a — host harness) --------------
1958
+ // A repository-bearing task is provisioned on the HOST: clone into a throwaway
1959
+ // run dir, check out / create the working branch, run the harness with the
1960
+ // workspace as CWD, then push the branch + reconcile the agent-opened PR.
1961
+ // Container-side provisioning (strong isolation) is a later increment.
1962
+
1963
+ function agentRunsRoot() {
1964
+ return join(getStateHome(), 'agent-runs');
1965
+ }
1966
+
1967
+ // Redact a token that may have been embedded in a URL or surfaced in git output,
1968
+ // plus any https userinfo (x-access-token:secret@host), before it hits a log or
1969
+ // the result envelope.
1970
+ function redactToken(text, token) {
1971
+ let s = String(text ?? '');
1972
+ if (token) s = s.split(token).join('***');
1973
+ return s.replace(/(https?:\/\/)[^@/\s]+@/gi, '$1');
1974
+ }
1975
+
1976
+ function runGit(args, { cwd, env, timeoutMs = 120_000 } = {}) {
1977
+ try {
1978
+ const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: timeoutMs });
1979
+ return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr: r.stderr || '', signal: r.signal || null };
1980
+ } catch (err) {
1981
+ return { status: null, stdout: '', stderr: err.message, signal: null };
1982
+ }
1983
+ }
1984
+
1985
+ // Write a GIT_ASKPASS helper that echoes $GIT_TOKEN, so the token reaches git
1986
+ // via the child's ENV — never on argv or in the remote URL. Uses a Node helper
1987
+ // (askpass.js reads GIT_TOKEN and writes it verbatim), launched by a per-OS
1988
+ // shim: git can't exec a POSIX `.sh` on Windows, and a raw `.cmd` would let
1989
+ // cmd.exe re-parse token metacharacters (&, |, ^). The shim keeps the token in
1990
+ // env only and never expands it in a shell.
1991
+ function writeAskpass(dir, token) {
1992
+ if (!token) return null;
1993
+ const js = join(dir, 'askpass.js');
1994
+ writeFileSync(js, 'process.stdout.write(process.env.GIT_TOKEN || "");\n', { mode: 0o600 });
1995
+ // Launch via this process's own Node (process.execPath) rather than bare
1996
+ // `node`, which may not be on PATH when Node was invoked by absolute path.
1997
+ const node = process.execPath;
1998
+ if (process.platform === 'win32') {
1999
+ const p = join(dir, 'askpass.cmd');
2000
+ writeFileSync(p, `@"${node}" "%~dp0askpass.js"\r\n`, { mode: 0o700 });
2001
+ return p;
2002
+ }
2003
+ const p = join(dir, 'askpass.sh');
2004
+ writeFileSync(p, `#!/bin/sh\nexec "${node}" "$(dirname "$0")/askpass.js"\n`, { mode: 0o700 });
2005
+ try { chmodSync(p, 0o700); } catch { /* best effort */ }
2006
+ return p;
2007
+ }
2008
+
2009
+ // For https URLs, embed a username (no secret) so git asks GIT_ASKPASS for the
2010
+ // password. Non-https URLs and author-supplied credentials are left untouched.
2011
+ function authUrl(url, provider, hasToken) {
2012
+ if (!hasToken) return url;
2013
+ try {
2014
+ const u = new URL(url);
2015
+ if (u.protocol !== 'https:') return url;
2016
+ if (u.username || u.password) return url; // author already embedded creds
2017
+ u.username = provider === 'github' ? 'x-access-token' : 'git';
2018
+ return u.toString();
2019
+ } catch {
2020
+ return url;
2021
+ }
2022
+ }
2023
+
2024
+ class ProvisionError extends Error {}
2025
+
2026
+ // Never let git invoke the host's configured credential helper for our clone/
2027
+ // push. Reset the helper list ("") so no helper runs — even when we DO have a
2028
+ // token, because helpers like `store`/keychain would persist the job's repo
2029
+ // token to disk. GIT_ASKPASS supplies the secret directly, so no helper is
2030
+ // needed. Combined with GIT_TERMINAL_PROMPT=0 this keeps tokens ephemeral and
2031
+ // an absent-token clone genuinely anonymous.
2032
+ function credArgs() {
2033
+ return ['-c', 'credential.helper='];
2034
+ }
2035
+
2036
+ // Clone repo into <runDir>/workspace and check out / create the working branch.
2037
+ // Returns { workspaceDir, gitEnv, startSha, workingBranch, remote }. Throws a
2038
+ // ProvisionError (token-redacted) on any git failure so the caller can shed.
2039
+ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2040
+ const repo = envelope.repository;
2041
+ if (!repo || !repo.url) throw new ProvisionError('repository.url is required to provision a workspace');
2042
+ const workspaceDir = join(runDir, 'workspace');
2043
+ const askpass = writeAskpass(runDir, token);
2044
+ const gitEnv = {
2045
+ ...process.env,
2046
+ GIT_TERMINAL_PROMPT: '0',
2047
+ GIT_CONFIG_NOSYSTEM: '1',
2048
+ };
2049
+ // Drop any inherited askpass helpers so a no-token ("anonymous") clone can't
2050
+ // authenticate with host-provided credentials. We re-set GIT_ASKPASS below
2051
+ // only when we minted our own token-backed helper.
2052
+ delete gitEnv.GIT_ASKPASS;
2053
+ delete gitEnv.SSH_ASKPASS;
2054
+ if (askpass) {
2055
+ gitEnv.GIT_ASKPASS = askpass;
2056
+ gitEnv.GIT_TOKEN = token;
2057
+ } else {
2058
+ // No token ⇒ honor the documented "anonymous" guarantee strictly: neutralize
2059
+ // the user's global git config too, so knobs like http.<url>.extraHeader
2060
+ // (added by `gh auth setup-git`) or url.<...>.insteadOf can't silently inject
2061
+ // operator credentials. Only done on the anonymous path — token-backed jobs
2062
+ // keep global config (e.g. http.proxy for reaching the remote).
2063
+ gitEnv.GIT_CONFIG_GLOBAL = devNull;
2064
+ }
2065
+
2066
+ const target = repo.ref || envelope.branch?.base || '';
2067
+ // `git clone --branch` accepts a branch or tag name but NOT a raw commit SHA.
2068
+ // For a SHA we clone the default branch, then fetch + check it out below.
2069
+ const isSha = !!target && /^[0-9a-f]{7,40}$/i.test(target);
2070
+ const cloneArgs = [...credArgs(), 'clone', '--no-tags'];
2071
+ if (repo.depth && repo.depth > 0) cloneArgs.push('--depth', String(repo.depth));
2072
+ if (repo.submodules) cloneArgs.push('--recurse-submodules');
2073
+ if (target && !isSha) cloneArgs.push('--branch', target);
2074
+ const remote = authUrl(repo.url, repo.provider || 'github', !!token);
2075
+ cloneArgs.push(remote, workspaceDir);
2076
+
2077
+ const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs });
2078
+ if (clone.status !== 0) {
2079
+ throw new ProvisionError(`git clone failed: ${redactToken(clone.stderr || clone.stdout, token).trim().slice(0, 500) || `exit ${clone.status}`}`);
2080
+ }
2081
+
2082
+ if (isSha) {
2083
+ // The SHA may not be present under a shallow clone of the default branch —
2084
+ // fetch it explicitly (best effort), then check it out (detached HEAD).
2085
+ const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', target], { cwd: workspaceDir, env: gitEnv, timeoutMs });
2086
+ const co = runGit(['checkout', '--detach', target], { cwd: workspaceDir, env: gitEnv });
2087
+ if (co.status !== 0) {
2088
+ const why = redactToken(co.stderr || fetch.stderr, token).trim().slice(0, 300);
2089
+ throw new ProvisionError(`git checkout ${target} failed: ${why || `exit ${co.status}`}`);
2090
+ }
2091
+ }
2092
+
2093
+ // Give the harness a committer identity in case it commits (many do).
2094
+ runGit(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'nano-agent'], { cwd: workspaceDir, env: gitEnv });
2095
+ runGit(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'nano-agent@users.noreply.github.com'], { cwd: workspaceDir, env: gitEnv });
2096
+
2097
+ // Determine the working branch. With branch.create we make a real branch.
2098
+ // Otherwise we're on whatever the clone checked out: a branch only if
2099
+ // rev-parse resolves a symbolic name — a tag/sha leaves detached HEAD, in
2100
+ // which case there is NO branch to push and workingBranch stays null so
2101
+ // finalizeGit skips the push/PR reconcile instead of pushing a bogus ref.
2102
+ let workingBranch = null;
2103
+ if (envelope.branch?.create) {
2104
+ const cb = runGit(['checkout', '-B', envelope.branch.create], { cwd: workspaceDir, env: gitEnv });
2105
+ if (cb.status !== 0) throw new ProvisionError(`git checkout -B ${envelope.branch.create} failed: ${redactToken(cb.stderr, token).trim().slice(0, 300)}`);
2106
+ workingBranch = envelope.branch.create;
2107
+ } else {
2108
+ const head = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2109
+ const name = (head.stdout || '').trim();
2110
+ workingBranch = (name && name !== 'HEAD') ? name : null; // null ⇒ detached HEAD
2111
+ }
2112
+ const sha = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2113
+ // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
2114
+ // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
2115
+ // commit" (empty startSha) rather than a bogus revision.
2116
+ return { workspaceDir, gitEnv, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
2117
+ }
2118
+
2119
+ // Look up a PR for this branch (2a does NOT open it — the harness does, driven
2120
+ // by the prompt). Uses gh with the resolved token so it works headless. Reports
2121
+ // the PR's ACTUAL author login in `openedBy` (null when unknown/not found) —
2122
+ // gh returns whatever PR is open for the head branch, which may not be ours.
2123
+ function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
2124
+ if (provider && provider !== 'github') return { openedBy: null, found: false, error: `PR reconcile unsupported for provider "${provider}"` };
2125
+ const env = { ...process.env };
2126
+ if (token) {
2127
+ env.GH_TOKEN = token;
2128
+ } else {
2129
+ // No job token ⇒ honor the anonymous guarantee: never let gh fall back to an
2130
+ // operator-provided token in the ambient env. Scrub every gh auth source so
2131
+ // PR reconcile can only use credentials we were explicitly handed.
2132
+ for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
2133
+ }
2134
+ try {
2135
+ const r = spawnSync('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state,isDraft,title,author', '--limit', '1'],
2136
+ { cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
2137
+ if (r.error) return { openedBy: null, found: false, error: `gh not runnable: ${redactToken(r.error.message, token).trim().slice(0, 200)}` };
2138
+ if (r.status !== 0) return { openedBy: null, found: false, error: redactToken(r.stderr, token).trim().slice(0, 200) || `gh pr list failed (exit ${r.status ?? 'null'}${r.signal ? `, signal ${r.signal}` : ''})` };
2139
+ const arr = JSON.parse((r.stdout || '[]').trim() || '[]');
2140
+ if (!Array.isArray(arr) || arr.length === 0) return { openedBy: null, found: false };
2141
+ const pr = arr[0];
2142
+ return { openedBy: pr.author?.login || null, found: true, number: pr.number, url: pr.url, state: pr.state, isDraft: !!pr.isDraft, title: pr.title };
2143
+ } catch (err) {
2144
+ return { openedBy: null, found: false, error: err.message };
2145
+ }
2146
+ }
2147
+
2148
+ // After the harness runs: enumerate new commits, push the branch (when
2149
+ // branch.push), and reconcile the agent-opened PR (when task.allowPr). A push
2150
+ // failure is reported (pushError) rather than thrown — the process model decides
2151
+ // what to do next, and re-running the agent would be non-idempotent.
2152
+ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope, token }) {
2153
+ const out = { branch: workingBranch, baseSha: startSha || null, headSha: null, commits: [], pushed: false, remote: null, pr: null };
2154
+ const rem = runGit(['remote', 'get-url', 'origin'], { cwd: workspaceDir, env: gitEnv });
2155
+ if (rem.status === 0) out.remote = redactToken(rem.stdout.trim(), token);
2156
+ const headNow = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2157
+ out.headSha = headNow.status === 0 ? ((headNow.stdout || '').trim() || null) : null;
2158
+ if (startSha) {
2159
+ const log = runGit(['rev-list', `${startSha}..HEAD`], { cwd: workspaceDir, env: gitEnv });
2160
+ if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
2161
+ } else if (out.headSha) {
2162
+ // Empty-repo case: provisionRepo found no initial commit (unborn branch), so
2163
+ // there is no base to diff against — every commit now on HEAD is new. Without
2164
+ // this, a harness that makes the repo's first commit would enumerate as "0
2165
+ // commits" and the branch would never be pushed.
2166
+ const log = runGit(['rev-list', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2167
+ if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
2168
+ }
2169
+
2170
+ if (!workingBranch) {
2171
+ out.detached = true; // clone landed on a tag/sha ⇒ no branch to push
2172
+ } else if (coerceBool(envelope.branch?.push, true) && out.commits.length > 0) {
2173
+ const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', workingBranch], { cwd: workspaceDir, env: gitEnv });
2174
+ if (push.status === 0) out.pushed = true;
2175
+ else out.pushError = redactToken(push.stderr || push.stdout, token).trim().slice(0, 300) || `push exit ${push.status}`;
2176
+ }
2177
+
2178
+ if (workingBranch && envelope.task?.allowPr) {
2179
+ out.pr = reconcileAgentPr({ workspaceDir, token, branch: workingBranch, provider: envelope.repository?.provider || 'github' });
2180
+ }
2181
+ return out;
2182
+ }
2183
+
2184
+ // Reap leftover job workspaces under the runs root. Age-gated, skips in-flight
2185
+ // run dirs, best-effort, bounded to our own directory (never touches anything we
2186
+ // did not create).
2187
+ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
2188
+ let reaped = 0;
2189
+ const root = agentRunsRoot();
2190
+ try {
2191
+ if (!existsSync(root)) return { reaped };
2192
+ const now = Date.now();
2193
+ for (const name of readdirSync(root)) {
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;
2199
+ const p = join(root, name);
2200
+ if (liveRunDirs.has(p)) continue;
2201
+ try {
2202
+ const st = lstatSync(p);
2203
+ if (!st.isDirectory()) continue; // lstat: a symlink is not a dir ⇒ skipped, never followed
2204
+ if (maxAgeMs > 0 && now - st.mtimeMs < maxAgeMs) continue;
2205
+ rmSync(p, { recursive: true, force: true });
2206
+ reaped++;
2207
+ } catch { /* skip */ }
2208
+ }
2209
+ } catch (err) {
2210
+ return { reaped, error: err.message };
2211
+ }
2212
+ return { reaped };
2213
+ }
2214
+
1803
2215
  // ---- One-shot capture (shared by host + container executors) ---------------
1804
2216
  const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1805
2217
 
1806
2218
  // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
1807
2219
  // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
1808
2220
  // uniform result. Used by both the host and container executors.
1809
- function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
2221
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
1810
2222
  return new Promise((resolve) => {
1811
2223
  let child;
1812
2224
  const stdoutChunks = [];
@@ -1818,15 +2230,48 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
1818
2230
  let settled = false;
1819
2231
  let timer = null;
1820
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
+
1821
2264
  const finish = (result) => {
1822
2265
  if (settled) return;
1823
2266
  settled = true;
1824
2267
  if (timer) clearTimeout(timer);
2268
+ if (teeOut) teeOut('', true);
2269
+ if (teeErr) teeErr('', true);
1825
2270
  resolve(result);
1826
2271
  };
1827
2272
 
1828
2273
  try {
1829
- child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
2274
+ child = spawn(command, args, { shell, detached, cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
1830
2275
  } catch (err) {
1831
2276
  finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
1832
2277
  return;
@@ -1841,6 +2286,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
1841
2286
 
1842
2287
  child.stdout.on('data', (d) => {
1843
2288
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2289
+ if (teeOut) teeOut(buf.toString('utf8'), false);
1844
2290
  const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
1845
2291
  if (remaining <= 0) { stdoutTruncated = true; return; }
1846
2292
  if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
@@ -1848,6 +2294,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
1848
2294
  });
1849
2295
  child.stderr.on('data', (d) => {
1850
2296
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2297
+ if (teeErr) teeErr(buf.toString('utf8'), false);
1851
2298
  const remaining = MAX_CAPTURE_BYTES - stderrBytes;
1852
2299
  if (remaining <= 0) { stderrTruncated = true; return; }
1853
2300
  if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
@@ -1911,33 +2358,59 @@ function baseAgentEnv(profile, job) {
1911
2358
  * Both paths resolve to the same result contract.
1912
2359
  */
1913
2360
  function runAgentJob(profile, job, opts = {}) {
1914
- const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
2361
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr } = opts;
1915
2362
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
1916
2363
  const agentEnv = baseAgentEnv(profile, job);
2364
+ // Static, non-secret env for the harness: the worker/profile's env (e.g. a
2365
+ // harness's permission toggles) plus the per-job envelope's setup.env
2366
+ // (job-specific tuning wins over the profile default). Reserved AGENT_* and
2367
+ // resolved secrets are layered on top so user env can never shadow them.
2368
+ const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
1917
2369
 
1918
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 } : {};
1919
2373
  return spawnCaptureOneShot({
1920
2374
  command: profile.command,
1921
2375
  shell: true,
1922
2376
  // Own process group so the timeout handler can kill the whole tree.
1923
2377
  detached: process.platform !== 'win32',
1924
- env: { ...process.env, ...agentEnv, ...secretEnv },
2378
+ // When a repository was provisioned, run the harness IN the workspace.
2379
+ cwd,
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 },
1925
2383
  stdinData: payload,
1926
2384
  timeoutMs,
1927
2385
  onTimeout: (child) => killTree(child),
2386
+ stream,
2387
+ streamPrefix,
2388
+ onStreamOut,
2389
+ onStreamErr,
1928
2390
  });
1929
2391
  }
1930
2392
 
1931
2393
  const engine = sandbox;
1932
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
+ }
1933
2406
  // Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
1934
2407
  // `docker inspect`; docker reads the value from our child's environment.
1935
2408
  const envArgs = [];
1936
2409
  for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
2410
+ for (const k of Object.keys(extraEnv)) envArgs.push('-e', k);
2411
+ for (const k of Object.keys(resultEnv)) envArgs.push('-e', k);
1937
2412
  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); }
2413
+ for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
1941
2414
 
1942
2415
  const args = [
1943
2416
  'run', '--rm', '-i',
@@ -1948,6 +2421,7 @@ function runAgentJob(profile, job, opts = {}) {
1948
2421
  '--label', `nano.run=${runId}`,
1949
2422
  '--log-opt', 'max-size=10m',
1950
2423
  '--log-opt', 'max-file=3',
2424
+ ...mountArgs,
1951
2425
  ...envArgs,
1952
2426
  image,
1953
2427
  'sh', '-c', profile.command,
@@ -1957,9 +2431,16 @@ function runAgentJob(profile, job, opts = {}) {
1957
2431
  command: engine,
1958
2432
  args,
1959
2433
  shell: false,
1960
- env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
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 },
1961
2438
  stdinData: payload,
1962
2439
  timeoutMs,
2440
+ stream,
2441
+ streamPrefix,
2442
+ onStreamOut,
2443
+ onStreamErr,
1963
2444
  onTimeout: (child) => {
1964
2445
  try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
1965
2446
  try { killTree(child); } catch { /* best effort */ }
@@ -1967,11 +2448,11 @@ function runAgentJob(profile, job, opts = {}) {
1967
2448
  });
1968
2449
  }
1969
2450
 
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 }) {
2451
+ // Shape the io.nanobpm.agentResult output envelope. When a repository was
2452
+ // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
2453
+ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
1973
2454
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
1974
- return {
2455
+ const env = {
1975
2456
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
1976
2457
  status,
1977
2458
  sandbox,
@@ -1983,6 +2464,21 @@ function buildResultEnvelope(result, { sandbox, image }) {
1983
2464
  signal: result.signal ?? null,
1984
2465
  error: result.error ?? null,
1985
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;
2470
+ if (git) {
2471
+ env.repository = git.remote ?? null;
2472
+ env.branch = git.branch ?? null;
2473
+ env.baseSha = git.baseSha ?? null;
2474
+ env.headSha = git.headSha ?? null;
2475
+ env.commits = git.commits ?? [];
2476
+ env.pushed = !!git.pushed;
2477
+ if (git.pushError) env.pushError = git.pushError;
2478
+ if (git.pr) env.pr = git.pr;
2479
+ if (git.error) env.gitError = git.error;
2480
+ }
2481
+ return env;
1986
2482
  }
1987
2483
 
1988
2484
  /**
@@ -2020,6 +2516,16 @@ async function workAgent(req, flags) {
2020
2516
  process.exit(1);
2021
2517
  }
2022
2518
 
2519
+ // Static env for the harness: the profile's persisted env, extended/overridden
2520
+ // by any work-time `--env NAME=VALUE` (repeatable). These carry harness startup
2521
+ // config such as permission toggles (e.g. a coder CLI started with tools enabled).
2522
+ const { env: workEnv, errors: workEnvErrors } = parseEnvPairs(flags?.env);
2523
+ if (workEnvErrors.length > 0) {
2524
+ logger.error(workEnvErrors.join('; '));
2525
+ process.exit(1);
2526
+ }
2527
+ const profileEnv = { ...profile.env, ...workEnv };
2528
+
2023
2529
  const intFlag = (v, dflt) => {
2024
2530
  const n = Number.parseInt(String(v ?? ''), 10);
2025
2531
  return Number.isFinite(n) && n > 0 ? n : dflt;
@@ -2055,10 +2561,34 @@ async function workAgent(req, flags) {
2055
2561
  ? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
2056
2562
  : 1_073_741_824; // 1 GiB default floor
2057
2563
 
2564
+ // Git provisioning knobs (increment 2a — host harness with a repository).
2565
+ const cloneTimeoutMs = intFlag(flags?.['clone-timeout'], 120_000);
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);
2570
+
2058
2571
  // Tracks run ids currently executing so the reaper never removes a live
2059
2572
  // container out from under an in-flight job.
2060
2573
  const liveRunIds = new Set();
2574
+ // Tracks per-job workspace dirs currently in use so the run-dir reaper never
2575
+ // deletes a workspace out from under an in-flight host job.
2576
+ const liveRunDirs = new Set();
2061
2577
  let reaperTimer = null;
2578
+ let runDirTimer = null;
2579
+
2580
+ // Run-dir hygiene runs regardless of sandbox: any sandbox=none job that carries
2581
+ // a repository clones a throwaway workspace under the runs root, and a crashed
2582
+ // worker can leave one behind. Bounded to our own directory, age-gated.
2583
+ {
2584
+ const initialRuns = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
2585
+ if (initialRuns.reaped > 0) logger.info(`Reaped ${initialRuns.reaped} leftover job workspace(s) at startup.`);
2586
+ runDirTimer = setInterval(() => {
2587
+ const r = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
2588
+ if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished job workspace(s).`);
2589
+ }, reapIntervalMs);
2590
+ if (typeof runDirTimer.unref === 'function') runDirTimer.unref();
2591
+ }
2062
2592
 
2063
2593
  if (isContainer) {
2064
2594
  if (!containerEngineAvailable(sandbox)) {
@@ -2084,6 +2614,8 @@ async function workAgent(req, flags) {
2084
2614
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
2085
2615
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
2086
2616
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
2617
+ const profileEnvKeys = Object.keys(profileEnv);
2618
+ if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
2087
2619
  logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
2088
2620
  logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
2089
2621
  logger.info('Polling for work — press Ctrl-C to stop.');
@@ -2123,8 +2655,58 @@ async function workAgent(req, flags) {
2123
2655
 
2124
2656
  const runId = randomUUID();
2125
2657
  if (isContainer) liveRunIds.add(runId);
2658
+
2659
+ // Host git provisioning (increment 2a): sandbox=none + a repository →
2660
+ // clone into a throwaway workspace, run the harness there, then push +
2661
+ // reconcile the agent PR. Container-side cloning is a later increment.
2662
+ const hasRepo = !isContainer && !!envelope.repository?.url;
2663
+ let runDir = null;
2664
+ let provisioned = null;
2665
+ let cwd;
2666
+ let extraEnv;
2667
+ let repoToken = null;
2668
+ if (hasRepo) {
2669
+ const provider = envelope.repository.provider || 'github';
2670
+ const authRef = envelope.repository.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : null);
2671
+ if (authRef) repoToken = secretResolver.resolve(authRef) || null; // optional: absent → anonymous clone
2672
+ try {
2673
+ mkdirSync(agentRunsRoot(), { recursive: true });
2674
+ runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
2675
+ liveRunDirs.add(runDir);
2676
+ provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
2677
+ cwd = provisioned.workspaceDir;
2678
+ extraEnv = {
2679
+ AGENT_WORKSPACE: provisioned.workspaceDir,
2680
+ AGENT_REPO_URL: provisioned.remote,
2681
+ AGENT_REPO_BRANCH: provisioned.workingBranch || '',
2682
+ AGENT_REPO_REF: provisioned.ref || '',
2683
+ };
2684
+ } catch (err) {
2685
+ if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
2686
+ if (isContainer) liveRunIds.delete(runId);
2687
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2688
+ const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
2689
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
2690
+ return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
2691
+ }
2692
+ }
2693
+
2126
2694
  let result;
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;
2127
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
+
2128
2710
  result = await runAgentJob(profile, job, {
2129
2711
  timeoutMs: jobTimeoutMs,
2130
2712
  envelope,
@@ -2133,20 +2715,77 @@ async function workAgent(req, flags) {
2133
2715
  runId,
2134
2716
  secretEnv: resolved,
2135
2717
  passThroughSecretNames: names,
2718
+ cwd,
2719
+ extraEnv,
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,
2136
2728
  });
2729
+
2730
+ // Finalize git only when the harness succeeded — never push a
2731
+ // half-finished workspace.
2732
+ if (provisioned && result.ok) {
2733
+ try {
2734
+ gitResult = finalizeGit({
2735
+ workspaceDir: provisioned.workspaceDir,
2736
+ gitEnv: provisioned.gitEnv,
2737
+ startSha: provisioned.startSha,
2738
+ workingBranch: provisioned.workingBranch,
2739
+ envelope,
2740
+ token: repoToken,
2741
+ });
2742
+ } catch (err) {
2743
+ gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false, error: redactToken(err.message, repoToken) };
2744
+ }
2745
+ } else if (provisioned) {
2746
+ gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false };
2747
+ }
2137
2748
  } finally {
2138
2749
  if (isContainer) liveRunIds.delete(runId);
2750
+ if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
2751
+ if (runDir) liveRunDirs.delete(runDir);
2139
2752
  }
2140
2753
 
2141
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
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 });
2142
2764
  if (result.ok) {
2143
- logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
2765
+ const gitNote = gitResult
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}` : ''}]`
2767
+ : '';
2768
+ logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
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(', ')}]`);
2144
2779
  return job.complete({
2780
+ ...resultVars,
2145
2781
  [AGENT_RESULT_KEY]: resultEnvelope,
2146
2782
  output: result.stdout,
2147
2783
  exitCode: 0,
2148
2784
  agent: profile.name,
2149
2785
  truncated: Boolean(result.truncated),
2786
+ ...(gitResult
2787
+ ? { branch: gitResult.branch, commits: gitResult.commits, pushed: gitResult.pushed, pullRequest: gitResult.pr || null }
2788
+ : {}),
2150
2789
  });
2151
2790
  }
2152
2791
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
@@ -2171,6 +2810,7 @@ async function workAgent(req, flags) {
2171
2810
  stopping = true;
2172
2811
  logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
2173
2812
  if (reaperTimer) clearInterval(reaperTimer);
2813
+ if (runDirTimer) clearInterval(runDirTimer);
2174
2814
  let stopFailures = 0;
2175
2815
  await Promise.all(
2176
2816
  workers.map(async (w) => {
@@ -3433,14 +4073,30 @@ export {
3433
4073
  hostEnvSecretResolver,
3434
4074
  buildAgentPayload,
3435
4075
  buildResultEnvelope,
4076
+ parseAgentResultObject,
4077
+ readAgentResultFile,
4078
+ parseResultFromStdout,
4079
+ sanitizeResultVars,
4080
+ parseEnvPairs,
4081
+ normalizeEnvMap,
3436
4082
  reapAgentContainers,
3437
4083
  diskBudgetOk,
3438
4084
  containerEngineAvailable,
3439
4085
  runAgentJob,
4086
+ provisionRepo,
4087
+ finalizeGit,
4088
+ reconcileAgentPr,
4089
+ reapAgentRunDirs,
4090
+ authUrl,
4091
+ redactToken,
4092
+ agentRunsRoot,
4093
+ ProvisionError,
3440
4094
  normalizeStoredProfile,
3441
4095
  jobTypeMatrix,
3442
4096
  AGENT_TASK_NS,
3443
4097
  AGENT_RESULT_KEY,
4098
+ RESULT_SENTINEL,
4099
+ RESERVED_RESULT_KEYS,
3444
4100
  SANDBOXES,
3445
4101
  };
3446
4102
 
@@ -3476,6 +4132,7 @@ export const metadata = {
3476
4132
  { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
3477
4133
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
3478
4134
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
4135
+ { 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' },
3479
4136
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
3480
4137
  { 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' },
3481
4138
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
@@ -3527,10 +4184,14 @@ export const commands = {
3527
4184
  capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
3528
4185
  sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
3529
4186
  image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
4187
+ env: { type: 'string', multiple: true, description: 'hire/work: static env var for the harness as NAME=VALUE (repeatable); persisted on hire, work extends/overrides. E.g. permission toggles.' },
3530
4188
  '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)' },
4189
+ 'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
4190
+ 'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers and job workspaces in ms (default 300000)' },
3533
4191
  'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
4192
+ 'clone-timeout': { type: 'string', description: 'work: max time in ms for cloning a task repository on the host (default 120000)' },
4193
+ 'keep-runs': { type: 'boolean', description: 'work: keep per-job workspaces under <state>/agent-runs instead of deleting them after each job (debug)' },
4194
+ stream: { type: 'boolean', description: 'work: tee each agent job\'s live stdout/stderr to this console, prefixed with the job type + key (spy/debug)' },
3534
4195
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3535
4196
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3536
4197
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
@@ -3676,8 +4337,8 @@ function printUsage() {
3676
4337
  console.log(' c8ctl nano set <bin|model-dir> <path>');
3677
4338
  console.log(' c8ctl nano config');
3678
4339
  console.log(' c8ctl nano update [--check]');
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>]');
4340
+ 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]');
4341
+ 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]');
3681
4342
  console.log('');
3682
4343
  console.log('Subcommands:');
3683
4344
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -3715,13 +4376,16 @@ function printUsage() {
3715
4376
  console.log(' --capabilities <a,b> hire: comma-separated capability list');
3716
4377
  console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
3717
4378
  console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
4379
+ console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
3718
4380
  console.log(' --list hire: list existing agent profiles instead of creating one');
3719
4381
  console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
3720
4382
  console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
3721
4383
  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)');
4384
+ console.log(' --reap-age <ms> work: age before a finished agent container/workspace is reaped (default 3600000)');
4385
+ console.log(' --reap-interval <ms> work: how often to sweep finished agent containers/workspaces (default 300000)');
3724
4386
  console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
4387
+ console.log(' --clone-timeout <ms> work: max time to clone a task repository on the host (default 120000)');
4388
+ console.log(' --keep-runs work: keep per-job workspaces instead of deleting them (debug)');
3725
4389
  console.log('');
3726
4390
  console.log('Persistent assets:');
3727
4391
  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.10.1",
3
+ "version": "1.12.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.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"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.12.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.12.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.12.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.12.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.12.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.12.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.12.0"
59
59
  }
60
60
  }