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.
- package/README.md +80 -8
- package/c8ctl-plugin.js +687 -23
- 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
|
-
**
|
|
249
|
-
|
|
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
|
-
>
|
|
258
|
-
> Vercel/Sandcastle provider 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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
1971
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.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
|
}
|