c8ctl-plugin-nano 1.10.0 → 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.
- package/README.md +76 -13
- package/c8ctl-plugin.js +486 -28
- 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
|
-
**
|
|
249
|
-
|
|
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
|
-
>
|
|
258
|
-
> Vercel/Sandcastle provider 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
|
|
@@ -298,7 +362,7 @@ and the history cap.
|
|
|
298
362
|
> ⚠️ With `--in-memory`, restart recovers nothing, and Raft/replicated logs are
|
|
299
363
|
> not persisted. Use it for stress/throughput testing, not durability testing.
|
|
300
364
|
|
|
301
|
-
## Console profile (`--console`
|
|
365
|
+
## Console profile (`--console`)
|
|
302
366
|
|
|
303
367
|
The server ships a browser console. Pick how much of it is exposed at runtime:
|
|
304
368
|
|
|
@@ -308,10 +372,9 @@ c8ctl nano start --console observe # observability views only; authoring refu
|
|
|
308
372
|
c8ctl nano start --console off # headless: no console router at all
|
|
309
373
|
```
|
|
310
374
|
|
|
311
|
-
- Values: `studio` (default), `observe`, `off`.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
every node.
|
|
375
|
+
- Values: `studio` (default), `observe`, `off`. An inherited `NANOBPMN_CONSOLE`
|
|
376
|
+
env var is honored when the flag is not passed. The plugin passes the choice
|
|
377
|
+
through as `NANOBPMN_CONSOLE` on every node.
|
|
315
378
|
|
|
316
379
|
## Configuration (`set` / `config`)
|
|
317
380
|
|
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';
|
|
@@ -398,7 +400,7 @@ function parseRequest(args, flags) {
|
|
|
398
400
|
capture: Boolean(flags?.capture),
|
|
399
401
|
inMemory: Boolean(flags?.['in-memory'] || flags?.['no-journal']),
|
|
400
402
|
historyMax: intFlag('history-max'),
|
|
401
|
-
console: flags?.console
|
|
403
|
+
console: flags?.console,
|
|
402
404
|
workspace: Boolean(flags?.workspace),
|
|
403
405
|
check: Boolean(flags?.check),
|
|
404
406
|
binary: flags?.binary,
|
|
@@ -552,7 +554,7 @@ const CONSOLE_PROFILES = ['off', 'observe', 'studio'];
|
|
|
552
554
|
|
|
553
555
|
/**
|
|
554
556
|
* Resolves the runtime console profile to pass through as NANOBPMN_CONSOLE.
|
|
555
|
-
* Precedence: --console
|
|
557
|
+
* Precedence: --console flag > inherited NANOBPMN_CONSOLE env >
|
|
556
558
|
* 'studio' (the full IDE, our default). Unknown values are rejected so a typo
|
|
557
559
|
* fails fast here rather than silently degrading the console in the server.
|
|
558
560
|
*/
|
|
@@ -708,7 +710,7 @@ async function startCluster(req) {
|
|
|
708
710
|
// outside the per-node data dir so "nano clean" never wipes it.
|
|
709
711
|
NANOBPMN_WORKSPACE_DIR: workspaceDir,
|
|
710
712
|
// Runtime console profile (off | observe | studio). Default studio (full
|
|
711
|
-
// IDE); pass-through so --console
|
|
713
|
+
// IDE); pass-through so --console or an inherited NANOBPMN_CONSOLE
|
|
712
714
|
// picks the observability-only or headless surface. See nano-bpm ADR 0035 §C.
|
|
713
715
|
NANOBPMN_CONSOLE: consoleProfile,
|
|
714
716
|
};
|
|
@@ -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
|
-
|
|
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
|
|
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, ...
|
|
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.
|
|
1971
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
|
@@ -3513,8 +3966,7 @@ export const commands = {
|
|
|
3513
3966
|
'in-memory': { type: 'boolean', description: 'start: run with NO on-disk journal/read-model (in-memory engine; state lost on restart). Alias: --no-journal' },
|
|
3514
3967
|
'no-journal': { type: 'boolean', description: 'start: alias for --in-memory' },
|
|
3515
3968
|
'history-max': { type: 'string', description: 'start: cap retained terminal instances in the read model (NANOBPMN_HISTORY_MAX_INSTANCES; 0/unset = unbounded)' },
|
|
3516
|
-
console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio)
|
|
3517
|
-
profile: { type: 'string', description: 'start: alias for --console (off|observe|studio; default studio)' },
|
|
3969
|
+
console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio)' },
|
|
3518
3970
|
follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
|
|
3519
3971
|
purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
|
|
3520
3972
|
force: { type: 'boolean', description: 'start: stop any existing cluster first' },
|
|
@@ -3528,10 +3980,13 @@ export const commands = {
|
|
|
3528
3980
|
capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
|
|
3529
3981
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
3530
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.' },
|
|
3531
3984
|
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
3532
|
-
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container is reaped (default 3600000)' },
|
|
3533
|
-
'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)' },
|
|
3534
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)' },
|
|
3535
3990
|
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
3536
3991
|
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
3537
3992
|
'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
|
|
@@ -3677,8 +4132,8 @@ function printUsage() {
|
|
|
3677
4132
|
console.log(' c8ctl nano set <bin|model-dir> <path>');
|
|
3678
4133
|
console.log(' c8ctl nano config');
|
|
3679
4134
|
console.log(' c8ctl nano update [--check]');
|
|
3680
|
-
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--list]');
|
|
3681
|
-
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]');
|
|
3682
4137
|
console.log('');
|
|
3683
4138
|
console.log('Subcommands:');
|
|
3684
4139
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
@@ -3704,7 +4159,7 @@ function printUsage() {
|
|
|
3704
4159
|
console.log(' --capture start: enable trace capture (recorded-input replay) on every node');
|
|
3705
4160
|
console.log(' --in-memory start: run with NO on-disk journal/read-model (alias --no-journal; state lost on restart)');
|
|
3706
4161
|
console.log(' --history-max <n> start: cap retained terminal instances in the read model (0/unset = unbounded)');
|
|
3707
|
-
console.log(' --console <profile> start: runtime console profile off|observe|studio (
|
|
4162
|
+
console.log(' --console <profile> start: runtime console profile off|observe|studio (default studio)');
|
|
3708
4163
|
console.log(' --binary <path> Path to the nanobpmn server binary (overrides "set bin")');
|
|
3709
4164
|
console.log(' --purge stop: also delete per-node engine data');
|
|
3710
4165
|
console.log(' --force start: stop any existing cluster first');
|
|
@@ -3716,13 +4171,16 @@ function printUsage() {
|
|
|
3716
4171
|
console.log(' --capabilities <a,b> hire: comma-separated capability list');
|
|
3717
4172
|
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
3718
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');
|
|
3719
4175
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
3720
4176
|
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
3721
4177
|
console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
|
|
3722
4178
|
console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
|
|
3723
|
-
console.log(' --reap-age <ms> work: age before a finished agent container is reaped (default 3600000)');
|
|
3724
|
-
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)');
|
|
3725
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)');
|
|
3726
4184
|
console.log('');
|
|
3727
4185
|
console.log('Persistent assets:');
|
|
3728
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.
|
|
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.
|
|
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.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
|
}
|