c8ctl-plugin-nano 1.10.1 → 1.11.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 +72 -8
  2. package/c8ctl-plugin.js +481 -22
  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,8 @@ 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.)
240
283
 
241
284
  **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
242
285
  (and the repo/PR credential when `task.allowPr` is set — defaulting to
@@ -245,8 +288,29 @@ the container's stdin exactly as on the host.
245
288
  container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
246
289
  A missing required secret fails the job with a clear provisioning message.
247
290
 
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
291
+ **Harness env (non-secret).** A harness often needs static startup configuration
292
+ e.g. a permission toggle to start a coding CLI with its tools enabled. Persist
293
+ these on the profile at hire time and/or add them at work time (repeatable
294
+ `--env NAME=VALUE`); work-time values extend/override the profile's:
295
+
296
+ ```bash
297
+ c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1
298
+ c8ctl nano work coder --env EXTRA_FLAG=on # extends/overrides the profile env
299
+ ```
300
+
301
+ Interactive `hire` (no `--env`) prompts for these one `NAME=VALUE` at a time
302
+ (blank to finish), so values may safely contain `=` or `,`.
303
+
304
+ They apply on both the host and container paths. Per-job `setup.env` from the
305
+ envelope layers on top (job-specific tuning wins), and the reserved `AGENT_*`
306
+ variables and resolved secrets always win over user-supplied env so they can't be
307
+ shadowed. For **secret** values use `secretRefs`, not `--env`.
308
+
309
+ **Disk hygiene.** Host job **workspaces** and container sandboxes both get
310
+ automatic cleanup so leaked artifacts can't fill the disk. Workspaces under
311
+ `<state>/agent-runs` are removed after each job and swept at startup + on
312
+ `--reap-interval` (leftovers older than `--reap-age`, in-flight dirs skipped).
313
+ For container sandboxes a **label-scoped** reaper runs at worker startup
250
314
  and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
251
315
  removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
252
316
  default `3600000` = 1h) while **skipping any run still in flight** — it never
@@ -254,9 +318,9 @@ touches containers it didn't create and never `system prune`s. A **disk-budget
254
318
  admission shed** fails (retryable) new jobs when the engine data root has less
255
319
  than `--min-free-mb` MB free (default `1024`).
256
320
 
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)
321
+ > Container-side git provisioning (strong isolation) and the
322
+ > Vercel/Sandcastle provider are **later increments** — the envelope names above
323
+ > are frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
260
324
  > can be built against this contract.
261
325
 
262
326
  ## Cleaning up disk
package/c8ctl-plugin.js CHANGED
@@ -41,9 +41,11 @@ 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';
48
+ import { homedir, platform as osPlatform, devNull } from 'node:os';
47
49
  import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
48
50
  import { createRequire } from 'node:module';
49
51
  import { fileURLToPath } from 'node:url';
@@ -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
  }
@@ -1657,7 +1722,7 @@ function normalizeTaskEnvelope(customHeaders, variables) {
1657
1722
  const repo = raw.repository;
1658
1723
  if (isPlainObject(repo) && str(repo.url)) {
1659
1724
  env.repository = {
1660
- provider: str(repo.provider) || 'github',
1725
+ provider: (str(repo.provider) || 'github').toLowerCase(),
1661
1726
  url: str(repo.url),
1662
1727
  ref: str(repo.ref),
1663
1728
  depth: coerceInt(repo.depth, undefined),
@@ -1800,13 +1865,270 @@ function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } =
1800
1865
  return { reaped };
1801
1866
  }
1802
1867
 
1868
+ // ---- Git provisioning (issue #8, increment 2a — host harness) --------------
1869
+ // A repository-bearing task is provisioned on the HOST: clone into a throwaway
1870
+ // run dir, check out / create the working branch, run the harness with the
1871
+ // workspace as CWD, then push the branch + reconcile the agent-opened PR.
1872
+ // Container-side provisioning (strong isolation) is a later increment.
1873
+
1874
+ function agentRunsRoot() {
1875
+ return join(getStateHome(), 'agent-runs');
1876
+ }
1877
+
1878
+ // Redact a token that may have been embedded in a URL or surfaced in git output,
1879
+ // plus any https userinfo (x-access-token:secret@host), before it hits a log or
1880
+ // the result envelope.
1881
+ function redactToken(text, token) {
1882
+ let s = String(text ?? '');
1883
+ if (token) s = s.split(token).join('***');
1884
+ return s.replace(/(https?:\/\/)[^@/\s]+@/gi, '$1');
1885
+ }
1886
+
1887
+ function runGit(args, { cwd, env, timeoutMs = 120_000 } = {}) {
1888
+ try {
1889
+ const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: timeoutMs });
1890
+ return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr: r.stderr || '', signal: r.signal || null };
1891
+ } catch (err) {
1892
+ return { status: null, stdout: '', stderr: err.message, signal: null };
1893
+ }
1894
+ }
1895
+
1896
+ // Write a GIT_ASKPASS helper that echoes $GIT_TOKEN, so the token reaches git
1897
+ // via the child's ENV — never on argv or in the remote URL. Uses a Node helper
1898
+ // (askpass.js reads GIT_TOKEN and writes it verbatim), launched by a per-OS
1899
+ // shim: git can't exec a POSIX `.sh` on Windows, and a raw `.cmd` would let
1900
+ // cmd.exe re-parse token metacharacters (&, |, ^). The shim keeps the token in
1901
+ // env only and never expands it in a shell.
1902
+ function writeAskpass(dir, token) {
1903
+ if (!token) return null;
1904
+ const js = join(dir, 'askpass.js');
1905
+ writeFileSync(js, 'process.stdout.write(process.env.GIT_TOKEN || "");\n', { mode: 0o600 });
1906
+ // Launch via this process's own Node (process.execPath) rather than bare
1907
+ // `node`, which may not be on PATH when Node was invoked by absolute path.
1908
+ const node = process.execPath;
1909
+ if (process.platform === 'win32') {
1910
+ const p = join(dir, 'askpass.cmd');
1911
+ writeFileSync(p, `@"${node}" "%~dp0askpass.js"\r\n`, { mode: 0o700 });
1912
+ return p;
1913
+ }
1914
+ const p = join(dir, 'askpass.sh');
1915
+ writeFileSync(p, `#!/bin/sh\nexec "${node}" "$(dirname "$0")/askpass.js"\n`, { mode: 0o700 });
1916
+ try { chmodSync(p, 0o700); } catch { /* best effort */ }
1917
+ return p;
1918
+ }
1919
+
1920
+ // For https URLs, embed a username (no secret) so git asks GIT_ASKPASS for the
1921
+ // password. Non-https URLs and author-supplied credentials are left untouched.
1922
+ function authUrl(url, provider, hasToken) {
1923
+ if (!hasToken) return url;
1924
+ try {
1925
+ const u = new URL(url);
1926
+ if (u.protocol !== 'https:') return url;
1927
+ if (u.username || u.password) return url; // author already embedded creds
1928
+ u.username = provider === 'github' ? 'x-access-token' : 'git';
1929
+ return u.toString();
1930
+ } catch {
1931
+ return url;
1932
+ }
1933
+ }
1934
+
1935
+ class ProvisionError extends Error {}
1936
+
1937
+ // Never let git invoke the host's configured credential helper for our clone/
1938
+ // push. Reset the helper list ("") so no helper runs — even when we DO have a
1939
+ // token, because helpers like `store`/keychain would persist the job's repo
1940
+ // token to disk. GIT_ASKPASS supplies the secret directly, so no helper is
1941
+ // needed. Combined with GIT_TERMINAL_PROMPT=0 this keeps tokens ephemeral and
1942
+ // an absent-token clone genuinely anonymous.
1943
+ function credArgs() {
1944
+ return ['-c', 'credential.helper='];
1945
+ }
1946
+
1947
+ // Clone repo into <runDir>/workspace and check out / create the working branch.
1948
+ // Returns { workspaceDir, gitEnv, startSha, workingBranch, remote }. Throws a
1949
+ // ProvisionError (token-redacted) on any git failure so the caller can shed.
1950
+ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
1951
+ const repo = envelope.repository;
1952
+ if (!repo || !repo.url) throw new ProvisionError('repository.url is required to provision a workspace');
1953
+ const workspaceDir = join(runDir, 'workspace');
1954
+ const askpass = writeAskpass(runDir, token);
1955
+ const gitEnv = {
1956
+ ...process.env,
1957
+ GIT_TERMINAL_PROMPT: '0',
1958
+ GIT_CONFIG_NOSYSTEM: '1',
1959
+ };
1960
+ // Drop any inherited askpass helpers so a no-token ("anonymous") clone can't
1961
+ // authenticate with host-provided credentials. We re-set GIT_ASKPASS below
1962
+ // only when we minted our own token-backed helper.
1963
+ delete gitEnv.GIT_ASKPASS;
1964
+ delete gitEnv.SSH_ASKPASS;
1965
+ if (askpass) {
1966
+ gitEnv.GIT_ASKPASS = askpass;
1967
+ gitEnv.GIT_TOKEN = token;
1968
+ } else {
1969
+ // No token ⇒ honor the documented "anonymous" guarantee strictly: neutralize
1970
+ // the user's global git config too, so knobs like http.<url>.extraHeader
1971
+ // (added by `gh auth setup-git`) or url.<...>.insteadOf can't silently inject
1972
+ // operator credentials. Only done on the anonymous path — token-backed jobs
1973
+ // keep global config (e.g. http.proxy for reaching the remote).
1974
+ gitEnv.GIT_CONFIG_GLOBAL = devNull;
1975
+ }
1976
+
1977
+ const target = repo.ref || envelope.branch?.base || '';
1978
+ // `git clone --branch` accepts a branch or tag name but NOT a raw commit SHA.
1979
+ // For a SHA we clone the default branch, then fetch + check it out below.
1980
+ const isSha = !!target && /^[0-9a-f]{7,40}$/i.test(target);
1981
+ const cloneArgs = [...credArgs(), 'clone', '--no-tags'];
1982
+ if (repo.depth && repo.depth > 0) cloneArgs.push('--depth', String(repo.depth));
1983
+ if (repo.submodules) cloneArgs.push('--recurse-submodules');
1984
+ if (target && !isSha) cloneArgs.push('--branch', target);
1985
+ const remote = authUrl(repo.url, repo.provider || 'github', !!token);
1986
+ cloneArgs.push(remote, workspaceDir);
1987
+
1988
+ const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs });
1989
+ if (clone.status !== 0) {
1990
+ throw new ProvisionError(`git clone failed: ${redactToken(clone.stderr || clone.stdout, token).trim().slice(0, 500) || `exit ${clone.status}`}`);
1991
+ }
1992
+
1993
+ if (isSha) {
1994
+ // The SHA may not be present under a shallow clone of the default branch —
1995
+ // fetch it explicitly (best effort), then check it out (detached HEAD).
1996
+ const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', target], { cwd: workspaceDir, env: gitEnv, timeoutMs });
1997
+ const co = runGit(['checkout', '--detach', target], { cwd: workspaceDir, env: gitEnv });
1998
+ if (co.status !== 0) {
1999
+ const why = redactToken(co.stderr || fetch.stderr, token).trim().slice(0, 300);
2000
+ throw new ProvisionError(`git checkout ${target} failed: ${why || `exit ${co.status}`}`);
2001
+ }
2002
+ }
2003
+
2004
+ // Give the harness a committer identity in case it commits (many do).
2005
+ runGit(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'nano-agent'], { cwd: workspaceDir, env: gitEnv });
2006
+ runGit(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'nano-agent@users.noreply.github.com'], { cwd: workspaceDir, env: gitEnv });
2007
+
2008
+ // Determine the working branch. With branch.create we make a real branch.
2009
+ // Otherwise we're on whatever the clone checked out: a branch only if
2010
+ // rev-parse resolves a symbolic name — a tag/sha leaves detached HEAD, in
2011
+ // which case there is NO branch to push and workingBranch stays null so
2012
+ // finalizeGit skips the push/PR reconcile instead of pushing a bogus ref.
2013
+ let workingBranch = null;
2014
+ if (envelope.branch?.create) {
2015
+ const cb = runGit(['checkout', '-B', envelope.branch.create], { cwd: workspaceDir, env: gitEnv });
2016
+ if (cb.status !== 0) throw new ProvisionError(`git checkout -B ${envelope.branch.create} failed: ${redactToken(cb.stderr, token).trim().slice(0, 300)}`);
2017
+ workingBranch = envelope.branch.create;
2018
+ } else {
2019
+ const head = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2020
+ const name = (head.stdout || '').trim();
2021
+ workingBranch = (name && name !== 'HEAD') ? name : null; // null ⇒ detached HEAD
2022
+ }
2023
+ const sha = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2024
+ // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
2025
+ // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
2026
+ // commit" (empty startSha) rather than a bogus revision.
2027
+ return { workspaceDir, gitEnv, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
2028
+ }
2029
+
2030
+ // Look up a PR for this branch (2a does NOT open it — the harness does, driven
2031
+ // by the prompt). Uses gh with the resolved token so it works headless. Reports
2032
+ // the PR's ACTUAL author login in `openedBy` (null when unknown/not found) —
2033
+ // gh returns whatever PR is open for the head branch, which may not be ours.
2034
+ function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
2035
+ if (provider && provider !== 'github') return { openedBy: null, found: false, error: `PR reconcile unsupported for provider "${provider}"` };
2036
+ const env = { ...process.env };
2037
+ if (token) {
2038
+ env.GH_TOKEN = token;
2039
+ } else {
2040
+ // No job token ⇒ honor the anonymous guarantee: never let gh fall back to an
2041
+ // operator-provided token in the ambient env. Scrub every gh auth source so
2042
+ // PR reconcile can only use credentials we were explicitly handed.
2043
+ for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
2044
+ }
2045
+ try {
2046
+ const r = spawnSync('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state,isDraft,title,author', '--limit', '1'],
2047
+ { cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
2048
+ if (r.error) return { openedBy: null, found: false, error: `gh not runnable: ${redactToken(r.error.message, token).trim().slice(0, 200)}` };
2049
+ 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}` : ''})` };
2050
+ const arr = JSON.parse((r.stdout || '[]').trim() || '[]');
2051
+ if (!Array.isArray(arr) || arr.length === 0) return { openedBy: null, found: false };
2052
+ const pr = arr[0];
2053
+ return { openedBy: pr.author?.login || null, found: true, number: pr.number, url: pr.url, state: pr.state, isDraft: !!pr.isDraft, title: pr.title };
2054
+ } catch (err) {
2055
+ return { openedBy: null, found: false, error: err.message };
2056
+ }
2057
+ }
2058
+
2059
+ // After the harness runs: enumerate new commits, push the branch (when
2060
+ // branch.push), and reconcile the agent-opened PR (when task.allowPr). A push
2061
+ // failure is reported (pushError) rather than thrown — the process model decides
2062
+ // what to do next, and re-running the agent would be non-idempotent.
2063
+ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope, token }) {
2064
+ const out = { branch: workingBranch, baseSha: startSha || null, headSha: null, commits: [], pushed: false, remote: null, pr: null };
2065
+ const rem = runGit(['remote', 'get-url', 'origin'], { cwd: workspaceDir, env: gitEnv });
2066
+ if (rem.status === 0) out.remote = redactToken(rem.stdout.trim(), token);
2067
+ const headNow = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2068
+ out.headSha = headNow.status === 0 ? ((headNow.stdout || '').trim() || null) : null;
2069
+ if (startSha) {
2070
+ const log = runGit(['rev-list', `${startSha}..HEAD`], { cwd: workspaceDir, env: gitEnv });
2071
+ if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
2072
+ } else if (out.headSha) {
2073
+ // Empty-repo case: provisionRepo found no initial commit (unborn branch), so
2074
+ // there is no base to diff against — every commit now on HEAD is new. Without
2075
+ // this, a harness that makes the repo's first commit would enumerate as "0
2076
+ // commits" and the branch would never be pushed.
2077
+ const log = runGit(['rev-list', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
2078
+ if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
2079
+ }
2080
+
2081
+ if (!workingBranch) {
2082
+ out.detached = true; // clone landed on a tag/sha ⇒ no branch to push
2083
+ } else if (coerceBool(envelope.branch?.push, true) && out.commits.length > 0) {
2084
+ const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', workingBranch], { cwd: workspaceDir, env: gitEnv });
2085
+ if (push.status === 0) out.pushed = true;
2086
+ else out.pushError = redactToken(push.stderr || push.stdout, token).trim().slice(0, 300) || `push exit ${push.status}`;
2087
+ }
2088
+
2089
+ if (workingBranch && envelope.task?.allowPr) {
2090
+ out.pr = reconcileAgentPr({ workspaceDir, token, branch: workingBranch, provider: envelope.repository?.provider || 'github' });
2091
+ }
2092
+ return out;
2093
+ }
2094
+
2095
+ // Reap leftover job workspaces under the runs root. Age-gated, skips in-flight
2096
+ // run dirs, best-effort, bounded to our own directory (never touches anything we
2097
+ // did not create).
2098
+ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
2099
+ let reaped = 0;
2100
+ const root = agentRunsRoot();
2101
+ try {
2102
+ if (!existsSync(root)) return { reaped };
2103
+ const now = Date.now();
2104
+ for (const name of readdirSync(root)) {
2105
+ // Only reap the `run-*` workspaces this worker creates (see the
2106
+ // `mkdtempSync(join(agentRunsRoot(), 'run-'))` in workAgent). Never touch
2107
+ // unrelated files/dirs an operator may have placed under agent-runs.
2108
+ if (!name.startsWith('run-')) continue;
2109
+ const p = join(root, name);
2110
+ if (liveRunDirs.has(p)) continue;
2111
+ try {
2112
+ const st = lstatSync(p);
2113
+ if (!st.isDirectory()) continue; // lstat: a symlink is not a dir ⇒ skipped, never followed
2114
+ if (maxAgeMs > 0 && now - st.mtimeMs < maxAgeMs) continue;
2115
+ rmSync(p, { recursive: true, force: true });
2116
+ reaped++;
2117
+ } catch { /* skip */ }
2118
+ }
2119
+ } catch (err) {
2120
+ return { reaped, error: err.message };
2121
+ }
2122
+ return { reaped };
2123
+ }
2124
+
1803
2125
  // ---- One-shot capture (shared by host + container executors) ---------------
1804
2126
  const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1805
2127
 
1806
2128
  // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
1807
2129
  // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
1808
2130
  // uniform result. Used by both the host and container executors.
1809
- function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
2131
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout }) {
1810
2132
  return new Promise((resolve) => {
1811
2133
  let child;
1812
2134
  const stdoutChunks = [];
@@ -1826,7 +2148,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
1826
2148
  };
1827
2149
 
1828
2150
  try {
1829
- child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
2151
+ child = spawn(command, args, { shell, detached, cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
1830
2152
  } catch (err) {
1831
2153
  finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
1832
2154
  return;
@@ -1911,9 +2233,14 @@ function baseAgentEnv(profile, job) {
1911
2233
  * Both paths resolve to the same result contract.
1912
2234
  */
1913
2235
  function runAgentJob(profile, job, opts = {}) {
1914
- const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
2236
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {} } = opts;
1915
2237
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
1916
2238
  const agentEnv = baseAgentEnv(profile, job);
2239
+ // Static, non-secret env for the harness: the worker/profile's env (e.g. a
2240
+ // harness's permission toggles) plus the per-job envelope's setup.env
2241
+ // (job-specific tuning wins over the profile default). Reserved AGENT_* and
2242
+ // resolved secrets are layered on top so user env can never shadow them.
2243
+ const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
1917
2244
 
1918
2245
  if (!CONTAINER_SANDBOXES.has(sandbox)) {
1919
2246
  return spawnCaptureOneShot({
@@ -1921,7 +2248,9 @@ function runAgentJob(profile, job, opts = {}) {
1921
2248
  shell: true,
1922
2249
  // Own process group so the timeout handler can kill the whole tree.
1923
2250
  detached: process.platform !== 'win32',
1924
- env: { ...process.env, ...agentEnv, ...secretEnv },
2251
+ // When a repository was provisioned, run the harness IN the workspace.
2252
+ cwd,
2253
+ env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
1925
2254
  stdinData: payload,
1926
2255
  timeoutMs,
1927
2256
  onTimeout: (child) => killTree(child),
@@ -1934,10 +2263,9 @@ function runAgentJob(profile, job, opts = {}) {
1934
2263
  // `docker inspect`; docker reads the value from our child's environment.
1935
2264
  const envArgs = [];
1936
2265
  for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
2266
+ for (const k of Object.keys(extraEnv)) envArgs.push('-e', k);
1937
2267
  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); }
2268
+ for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
1941
2269
 
1942
2270
  const args = [
1943
2271
  'run', '--rm', '-i',
@@ -1957,7 +2285,7 @@ function runAgentJob(profile, job, opts = {}) {
1957
2285
  command: engine,
1958
2286
  args,
1959
2287
  shell: false,
1960
- env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
2288
+ env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
1961
2289
  stdinData: payload,
1962
2290
  timeoutMs,
1963
2291
  onTimeout: (child) => {
@@ -1967,11 +2295,11 @@ function runAgentJob(profile, job, opts = {}) {
1967
2295
  });
1968
2296
  }
1969
2297
 
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 }) {
2298
+ // Shape the io.nanobpm.agentResult output envelope. When a repository was
2299
+ // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
2300
+ function buildResultEnvelope(result, { sandbox, image, git } = {}) {
1973
2301
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
1974
- return {
2302
+ const env = {
1975
2303
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
1976
2304
  status,
1977
2305
  sandbox,
@@ -1983,6 +2311,18 @@ function buildResultEnvelope(result, { sandbox, image }) {
1983
2311
  signal: result.signal ?? null,
1984
2312
  error: result.error ?? null,
1985
2313
  };
2314
+ if (git) {
2315
+ env.repository = git.remote ?? null;
2316
+ env.branch = git.branch ?? null;
2317
+ env.baseSha = git.baseSha ?? null;
2318
+ env.headSha = git.headSha ?? null;
2319
+ env.commits = git.commits ?? [];
2320
+ env.pushed = !!git.pushed;
2321
+ if (git.pushError) env.pushError = git.pushError;
2322
+ if (git.pr) env.pr = git.pr;
2323
+ if (git.error) env.gitError = git.error;
2324
+ }
2325
+ return env;
1986
2326
  }
1987
2327
 
1988
2328
  /**
@@ -2020,6 +2360,16 @@ async function workAgent(req, flags) {
2020
2360
  process.exit(1);
2021
2361
  }
2022
2362
 
2363
+ // Static env for the harness: the profile's persisted env, extended/overridden
2364
+ // by any work-time `--env NAME=VALUE` (repeatable). These carry harness startup
2365
+ // config such as permission toggles (e.g. a coder CLI started with tools enabled).
2366
+ const { env: workEnv, errors: workEnvErrors } = parseEnvPairs(flags?.env);
2367
+ if (workEnvErrors.length > 0) {
2368
+ logger.error(workEnvErrors.join('; '));
2369
+ process.exit(1);
2370
+ }
2371
+ const profileEnv = { ...profile.env, ...workEnv };
2372
+
2023
2373
  const intFlag = (v, dflt) => {
2024
2374
  const n = Number.parseInt(String(v ?? ''), 10);
2025
2375
  return Number.isFinite(n) && n > 0 ? n : dflt;
@@ -2055,10 +2405,31 @@ async function workAgent(req, flags) {
2055
2405
  ? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
2056
2406
  : 1_073_741_824; // 1 GiB default floor
2057
2407
 
2408
+ // Git provisioning knobs (increment 2a — host harness with a repository).
2409
+ const cloneTimeoutMs = intFlag(flags?.['clone-timeout'], 120_000);
2410
+ const keepRuns = coerceBool(flags?.['keep-runs'], false);
2411
+
2058
2412
  // Tracks run ids currently executing so the reaper never removes a live
2059
2413
  // container out from under an in-flight job.
2060
2414
  const liveRunIds = new Set();
2415
+ // Tracks per-job workspace dirs currently in use so the run-dir reaper never
2416
+ // deletes a workspace out from under an in-flight host job.
2417
+ const liveRunDirs = new Set();
2061
2418
  let reaperTimer = null;
2419
+ let runDirTimer = null;
2420
+
2421
+ // Run-dir hygiene runs regardless of sandbox: any sandbox=none job that carries
2422
+ // a repository clones a throwaway workspace under the runs root, and a crashed
2423
+ // worker can leave one behind. Bounded to our own directory, age-gated.
2424
+ {
2425
+ const initialRuns = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
2426
+ if (initialRuns.reaped > 0) logger.info(`Reaped ${initialRuns.reaped} leftover job workspace(s) at startup.`);
2427
+ runDirTimer = setInterval(() => {
2428
+ const r = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
2429
+ if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished job workspace(s).`);
2430
+ }, reapIntervalMs);
2431
+ if (typeof runDirTimer.unref === 'function') runDirTimer.unref();
2432
+ }
2062
2433
 
2063
2434
  if (isContainer) {
2064
2435
  if (!containerEngineAvailable(sandbox)) {
@@ -2084,6 +2455,8 @@ async function workAgent(req, flags) {
2084
2455
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
2085
2456
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
2086
2457
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
2458
+ const profileEnvKeys = Object.keys(profileEnv);
2459
+ if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
2087
2460
  logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
2088
2461
  logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
2089
2462
  logger.info('Polling for work — press Ctrl-C to stop.');
@@ -2123,7 +2496,44 @@ async function workAgent(req, flags) {
2123
2496
 
2124
2497
  const runId = randomUUID();
2125
2498
  if (isContainer) liveRunIds.add(runId);
2499
+
2500
+ // Host git provisioning (increment 2a): sandbox=none + a repository →
2501
+ // clone into a throwaway workspace, run the harness there, then push +
2502
+ // reconcile the agent PR. Container-side cloning is a later increment.
2503
+ const hasRepo = !isContainer && !!envelope.repository?.url;
2504
+ let runDir = null;
2505
+ let provisioned = null;
2506
+ let cwd;
2507
+ let extraEnv;
2508
+ let repoToken = null;
2509
+ if (hasRepo) {
2510
+ const provider = envelope.repository.provider || 'github';
2511
+ const authRef = envelope.repository.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : null);
2512
+ if (authRef) repoToken = secretResolver.resolve(authRef) || null; // optional: absent → anonymous clone
2513
+ try {
2514
+ mkdirSync(agentRunsRoot(), { recursive: true });
2515
+ runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
2516
+ liveRunDirs.add(runDir);
2517
+ provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
2518
+ cwd = provisioned.workspaceDir;
2519
+ extraEnv = {
2520
+ AGENT_WORKSPACE: provisioned.workspaceDir,
2521
+ AGENT_REPO_URL: provisioned.remote,
2522
+ AGENT_REPO_BRANCH: provisioned.workingBranch || '',
2523
+ AGENT_REPO_REF: provisioned.ref || '',
2524
+ };
2525
+ } catch (err) {
2526
+ if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
2527
+ if (isContainer) liveRunIds.delete(runId);
2528
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2529
+ const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
2530
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
2531
+ return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
2532
+ }
2533
+ }
2534
+
2126
2535
  let result;
2536
+ let gitResult = null;
2127
2537
  try {
2128
2538
  result = await runAgentJob(profile, job, {
2129
2539
  timeoutMs: jobTimeoutMs,
@@ -2133,20 +2543,51 @@ async function workAgent(req, flags) {
2133
2543
  runId,
2134
2544
  secretEnv: resolved,
2135
2545
  passThroughSecretNames: names,
2546
+ cwd,
2547
+ extraEnv,
2548
+ profileEnv,
2136
2549
  });
2550
+
2551
+ // Finalize git only when the harness succeeded — never push a
2552
+ // half-finished workspace.
2553
+ if (provisioned && result.ok) {
2554
+ try {
2555
+ gitResult = finalizeGit({
2556
+ workspaceDir: provisioned.workspaceDir,
2557
+ gitEnv: provisioned.gitEnv,
2558
+ startSha: provisioned.startSha,
2559
+ workingBranch: provisioned.workingBranch,
2560
+ envelope,
2561
+ token: repoToken,
2562
+ });
2563
+ } catch (err) {
2564
+ gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false, error: redactToken(err.message, repoToken) };
2565
+ }
2566
+ } else if (provisioned) {
2567
+ gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false };
2568
+ }
2137
2569
  } finally {
2138
2570
  if (isContainer) liveRunIds.delete(runId);
2571
+ if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
2572
+ if (runDir) liveRunDirs.delete(runDir);
2139
2573
  }
2140
2574
 
2141
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
2575
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult });
2142
2576
  if (result.ok) {
2143
- logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
2577
+ const gitNote = gitResult
2578
+ ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
2579
+ : '';
2580
+ logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
2581
+ if (gitResult?.pushError) logger.warn(`[${jobType}] job ${job.jobKey}: branch push failed — ${gitResult.pushError}`);
2144
2582
  return job.complete({
2145
2583
  [AGENT_RESULT_KEY]: resultEnvelope,
2146
2584
  output: result.stdout,
2147
2585
  exitCode: 0,
2148
2586
  agent: profile.name,
2149
2587
  truncated: Boolean(result.truncated),
2588
+ ...(gitResult
2589
+ ? { branch: gitResult.branch, commits: gitResult.commits, pushed: gitResult.pushed, pullRequest: gitResult.pr || null }
2590
+ : {}),
2150
2591
  });
2151
2592
  }
2152
2593
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
@@ -2171,6 +2612,7 @@ async function workAgent(req, flags) {
2171
2612
  stopping = true;
2172
2613
  logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
2173
2614
  if (reaperTimer) clearInterval(reaperTimer);
2615
+ if (runDirTimer) clearInterval(runDirTimer);
2174
2616
  let stopFailures = 0;
2175
2617
  await Promise.all(
2176
2618
  workers.map(async (w) => {
@@ -3433,10 +3875,20 @@ export {
3433
3875
  hostEnvSecretResolver,
3434
3876
  buildAgentPayload,
3435
3877
  buildResultEnvelope,
3878
+ parseEnvPairs,
3879
+ normalizeEnvMap,
3436
3880
  reapAgentContainers,
3437
3881
  diskBudgetOk,
3438
3882
  containerEngineAvailable,
3439
3883
  runAgentJob,
3884
+ provisionRepo,
3885
+ finalizeGit,
3886
+ reconcileAgentPr,
3887
+ reapAgentRunDirs,
3888
+ authUrl,
3889
+ redactToken,
3890
+ agentRunsRoot,
3891
+ ProvisionError,
3440
3892
  normalizeStoredProfile,
3441
3893
  jobTypeMatrix,
3442
3894
  AGENT_TASK_NS,
@@ -3476,6 +3928,7 @@ export const metadata = {
3476
3928
  { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
3477
3929
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
3478
3930
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
3931
+ { 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
3932
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
3480
3933
  { 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
3934
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
@@ -3527,10 +3980,13 @@ export const commands = {
3527
3980
  capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
3528
3981
  sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
3529
3982
  image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
3983
+ 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
3984
  '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)' },
3985
+ 'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
3986
+ 'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers and job workspaces in ms (default 300000)' },
3533
3987
  'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
3988
+ 'clone-timeout': { type: 'string', description: 'work: max time in ms for cloning a task repository on the host (default 120000)' },
3989
+ 'keep-runs': { type: 'boolean', description: 'work: keep per-job workspaces under <state>/agent-runs instead of deleting them after each job (debug)' },
3534
3990
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3535
3991
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3536
3992
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
@@ -3676,8 +4132,8 @@ function printUsage() {
3676
4132
  console.log(' c8ctl nano set <bin|model-dir> <path>');
3677
4133
  console.log(' c8ctl nano config');
3678
4134
  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>]');
4135
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
4136
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs]');
3681
4137
  console.log('');
3682
4138
  console.log('Subcommands:');
3683
4139
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -3715,13 +4171,16 @@ function printUsage() {
3715
4171
  console.log(' --capabilities <a,b> hire: comma-separated capability list');
3716
4172
  console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
3717
4173
  console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
4174
+ console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
3718
4175
  console.log(' --list hire: list existing agent profiles instead of creating one');
3719
4176
  console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
3720
4177
  console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
3721
4178
  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)');
4179
+ console.log(' --reap-age <ms> work: age before a finished agent container/workspace is reaped (default 3600000)');
4180
+ console.log(' --reap-interval <ms> work: how often to sweep finished agent containers/workspaces (default 300000)');
3724
4181
  console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
4182
+ console.log(' --clone-timeout <ms> work: max time to clone a task repository on the host (default 120000)');
4183
+ console.log(' --keep-runs work: keep per-job workspaces instead of deleting them (debug)');
3725
4184
  console.log('');
3726
4185
  console.log('Persistent assets:');
3727
4186
  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.11.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.11.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.11.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.11.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.11.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.11.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.11.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.11.0"
59
59
  }
60
60
  }