c8ctl-plugin-nano 1.63.2 → 1.64.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.
@@ -19,6 +19,8 @@
19
19
  // append an AgentInstance must NEVER crash the harness or change `job.complete`
20
20
  // behaviour — the AgentInstance lifecycle is orthogonal to job completion.
21
21
 
22
+ import { hostname } from 'node:os';
23
+
22
24
  import { sessionAcp as defaultSessionAcp } from './agentic.mjs';
23
25
 
24
26
  // The two AgentInstance surfaces we call on the host SDK client. A client missing
@@ -296,6 +298,49 @@ export function deriveAgentDefinition({ profile, envelope } = {}) {
296
298
  return { model, provider, systemPrompt };
297
299
  }
298
300
 
301
+ // #243: marker discriminating the provenance blob inside a CONFIGURATION turn's
302
+ // `content[]` from an ordinary message/tool OBJECT block. Versioned so a consumer can
303
+ // evolve the shape without ambiguity.
304
+ export const PROVENANCE_KIND = 'nanobpm.provenance/v1';
305
+
306
+ /**
307
+ * #243: build the parity-safe provenance content block for the opening CONFIGURATION
308
+ * turn.
309
+ *
310
+ * Camunda's AgentInstance schema pins the CONFIGURATION `definition` to
311
+ * model/provider/systemPrompt (no metadata/attributes field), and nanobpmn promises
312
+ * parity — so producer-invented top-level fields are off-limits. BUT a history item's
313
+ * `content[]` is a discriminated union that already includes an `OBJECT` variant
314
+ * (`{ contentType: 'OBJECT', object: <arbitrary JSON> }`) — the SAME shape the producer
315
+ * already emits for a structured tool result (`contentForResult`). Ride it to attribute
316
+ * a run to the agent harness (`agentName`), the nano runtime (`runtimeVersion` — the
317
+ * plugin `package.json` version, which is one and the same as the nano supervisor
318
+ * version), and, best-effort, the underlying agent CLI (`agentCliVersion`), WITHOUT
319
+ * diverging from the Camunda API.
320
+ *
321
+ * Returns `null` when no substantive identity field is present, so a bare `host`/`pid`
322
+ * (diagnostics only) never emits a noisy content block.
323
+ *
324
+ * @param {object} [p]
325
+ * @param {object} [p.profile] Worker profile (`name` → `agentName`).
326
+ * @param {string} [p.runtimeVersion] Nano plugin/supervisor version (`pluginVersion`).
327
+ * @param {string} [p.agentCliVersion] Best-effort underlying-CLI version (may be blank).
328
+ * @param {string} [p.host] Host name (diagnostic).
329
+ * @param {number} [p.pid] Worker pid (diagnostic).
330
+ * @returns {{ contentType: 'OBJECT', object: object } | null}
331
+ */
332
+ export function buildProvenanceContent({ profile, runtimeVersion, agentCliVersion, host, pid } = {}) {
333
+ const object = { kind: PROVENANCE_KIND };
334
+ if (isNonBlank(profile?.name)) object.agentName = String(profile.name);
335
+ if (isNonBlank(runtimeVersion)) object.runtimeVersion = String(runtimeVersion);
336
+ if (isNonBlank(agentCliVersion)) object.agentCliVersion = String(agentCliVersion);
337
+ if (isNonBlank(host)) object.host = String(host);
338
+ if (Number.isInteger(pid) && pid > 0) object.pid = pid;
339
+ // Only agent/runtime identity justifies a block; host/pid alone are diagnostics.
340
+ if (!(object.agentName || object.runtimeVersion || object.agentCliVersion)) return null;
341
+ return { contentType: 'OBJECT', object };
342
+ }
343
+
299
344
  // Map the ACP classifier's message role to the AgentHistory role enum. ACP has no
300
345
  // distinct REASONING role, so a `reasoning` chunk folds into ASSISTANT.
301
346
  function historyRole(acpRole) {
@@ -396,6 +441,15 @@ export function createAgentInstanceProducer(opts = {}) {
396
441
  logger = console,
397
442
  now = () => Date.now(),
398
443
  sessionAcp = defaultSessionAcp,
444
+ // #243: durable-transcript provenance for the opening CONFIGURATION turn. The nano
445
+ // runtime version (== the supervisor version) and the agent harness name/CLI version
446
+ // ride a parity-safe OBJECT content block (see `buildProvenanceContent`). All are
447
+ // best-effort/optional — a blank value simply omits its field, and if none are
448
+ // present no provenance block is emitted. `host`/`pid` default to this process.
449
+ runtimeVersion = '',
450
+ agentCliVersion = '',
451
+ host = hostname(),
452
+ pid = process.pid,
399
453
  createRetryBaseMs = DEFAULT_CREATE_RETRY_BASE_MS,
400
454
  createRetryMaxMs = DEFAULT_CREATE_RETRY_MAX_MS,
401
455
  preMintBufferMax = DEFAULT_PRE_MINT_BUFFER_MAX,
@@ -743,11 +797,17 @@ export function createAgentInstanceProducer(opts = {}) {
743
797
  // Build the opening CONFIGURATION turn from the concrete runtime definition.
744
798
  const buildConfigTurn = () => {
745
799
  const def = deriveAgentDefinition({ profile, envelope });
800
+ // #243: a parity-safe provenance OBJECT (agent name, nano runtime/supervisor
801
+ // version, best-effort agent-CLI version) rides the CONFIGURATION turn's content[]
802
+ // — the same OBJECT content variant the producer already emits for tool results —
803
+ // so no field is invented on the Camunda-pinned CONFIGURATION definition. Omitted
804
+ // entirely when no substantive identity is available.
805
+ const provenance = buildProvenanceContent({ profile, runtimeVersion, agentCliVersion, host, pid });
746
806
  const configTurn = {
747
807
  historyItemId: `configuration:${elementInstanceKey}`,
748
808
  loopIteration: 1,
749
809
  role: 'CONFIGURATION',
750
- content: [],
810
+ content: provenance ? [provenance] : [],
751
811
  producedAt: iso(),
752
812
  model: def.model,
753
813
  provider: def.provider,
package/c8ctl-plugin.js CHANGED
@@ -58,7 +58,7 @@ import { lookup as dnsLookup } from 'node:dns/promises';
58
58
  import * as nodeDns from 'node:dns';
59
59
  import { randomUUID, createHash, randomBytes } from 'node:crypto';
60
60
  import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
61
- import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
61
+ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep, delimiter } from 'node:path';
62
62
  import { createRequire } from 'node:module';
63
63
  import { fileURLToPath } from 'node:url';
64
64
  import { createInterface } from 'node:readline/promises';
@@ -1721,6 +1721,104 @@ function buildAgentCommandLine(command, args) {
1721
1721
  return `${command} ${list.map(shQuote).join(' ')}`;
1722
1722
  }
1723
1723
 
1724
+ // #243: extract a plausible version token from an agent CLI's `--version` output for
1725
+ // the durable transcript's provenance block. Prefers a semver-ish token, else the
1726
+ // first non-empty line; length-capped so a chatty (or adversarial) harness can't bloat
1727
+ // the transcript. Returns null when nothing usable is present.
1728
+ export function extractVersionToken(text) {
1729
+ const s = String(text || '');
1730
+ const semver = s.match(/\bv?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?\b/);
1731
+ if (semver) return semver[0].slice(0, 64);
1732
+ const firstLine = s.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
1733
+ return firstLine ? firstLine.slice(0, 64) : null;
1734
+ }
1735
+
1736
+ // #243: best-effort probe of an agent harness CLI's OWN version (distinct from the nano
1737
+ // plugin/supervisor version), for the durable transcript's provenance block. Runs
1738
+ // `<command> --version` through a shell (PATH resolution, mirroring how the harness is
1739
+ // spawned) with a hard timeout + SIGKILL and stdin closed so an interactive harness
1740
+ // gets EOF and exits rather than hanging. Restricted to a BARE single executable and run
1741
+ // under the caller-supplied `env` (the merged profile env) so it can neither run an
1742
+ // embedded-argument command nor resolve a different PATH binary than the real run
1743
+ // (#257). ENTIRELY best-effort: any failure, timeout, non-zero exit with no version-ish
1744
+ // output, an embedded/compound command, or unrecognizable output yields null (the field
1745
+ // is simply omitted). Meant to be called ONCE per worker process (the command is fixed
1746
+ // per profile), so it never taxes the activation hot path. Disable via
1747
+ // NANO_AGENT_CLI_PROBE=off. The spawner is injected for deterministic testing.
1748
+ function probeAgentCliVersion(command, { timeoutMs = 1500, run = spawnSync, env = undefined } = {}) {
1749
+ if (typeof command !== 'string' || command.trim() === '') return null;
1750
+ if (String(process.env.NANO_AGENT_CLI_PROBE || '').trim().toLowerCase() === 'off') return null;
1751
+ // #257 review: only probe a BARE single executable. `buildAgentCommandLine`
1752
+ // preserves an embedded-argument `command` verbatim when structured args are
1753
+ // empty (e.g. `command: "node agent.js"`), so `${command} --version` would run
1754
+ // that script/compound command — probing the interpreter or a wholly different
1755
+ // program — and persist a false `agentCliVersion`. A token carrying whitespace or
1756
+ // shell metacharacters is not a bare executable: omit the probe rather than
1757
+ // record a wrong (or side-effecting) reading. A plain path token (with / \ : . _ -)
1758
+ // is still allowed so an absolute harness path probes normally.
1759
+ if (!/^[\w./\\:+-]+$/.test(command.trim())) return null;
1760
+ // #257 review: SKIP a RELATIVE path command (a token carrying a path separator
1761
+ // that is not absolute, e.g. `./harness`, `../bin/tool`, `sub/dir/cmd`). Unlike a
1762
+ // bare PATH-resolved name (cwd-independent) or an absolute path (fully determined),
1763
+ // a relative path resolves against the probe's cwd — which is the WORKER's cwd, NOT
1764
+ // the per-job `cwd` (a fresh run dir / cloned repo) the harness is actually launched
1765
+ // from in `runAgentJob`. Probing `./harness` here could read a different file/version
1766
+ // than the command the job runs (or find nothing where the job would), persisting a
1767
+ // false/omitted `agentCliVersion`. Best-effort: omit rather than record a wrong
1768
+ // reading for a command whose resolution is cwd-ambiguous.
1769
+ {
1770
+ const cmd = command.trim();
1771
+ if (/[\\/]/.test(cmd) && !isAbsolute(cmd)) return null;
1772
+ // #257 review: a BARE PATH-resolved name is only cwd-independent if PATH itself
1773
+ // is. If the caller-supplied env's PATH carries a RELATIVE or EMPTY entry (e.g.
1774
+ // `.`, an empty field meaning cwd, or `./node_modules/.bin`), resolution depends
1775
+ // on the working directory — and the probe's cwd (the WORKER's) is NOT the per-job
1776
+ // cwd (the run dir / cloned repo) the harness is launched from — so `--version`
1777
+ // could resolve a different executable/version than the job's. Omit rather than
1778
+ // record a cwd-ambiguous reading. An ABSOLUTE command bypasses PATH entirely, so
1779
+ // it is unaffected; a probe with no explicit `env` inherits `process.env` verbatim
1780
+ // (the caller opted into that resolution) and is left untouched.
1781
+ if (env && !isAbsolute(cmd)) {
1782
+ const pathVar = env.PATH ?? env.Path ?? env.path ?? '';
1783
+ if (String(pathVar).split(delimiter).some((e) => e === '' || !isAbsolute(e))) return null;
1784
+ }
1785
+ }
1786
+ let out;
1787
+ try {
1788
+ out = run(`${command} --version`, {
1789
+ shell: true,
1790
+ timeout: timeoutMs,
1791
+ killSignal: 'SIGKILL',
1792
+ encoding: 'utf8',
1793
+ stdio: ['ignore', 'pipe', 'pipe'],
1794
+ // #257 review: run the probe under the SAME environment the harness will run
1795
+ // under (the profile env merged over the host env), so PATH resolution matches
1796
+ // the real invocation and the probe can't resolve a different binary than the
1797
+ // one the job will spawn. Undefined inherits `process.env` (spawnSync default).
1798
+ ...(env ? { env } : {}),
1799
+ // #257 review: cap the captured output. Without a finite maxBuffer a harness
1800
+ // that streams continuously during the timeout window makes each worker retain
1801
+ // unbounded stdout/stderr (exhausting memory) before the 64-byte transcript cap
1802
+ // is ever applied. On overflow spawnSync sets `out.error` (ENOBUFS), which the
1803
+ // guard below already rejects — so the probe stays best-effort (yields null).
1804
+ maxBuffer: 256 * 1024,
1805
+ windowsHide: true,
1806
+ });
1807
+ } catch {
1808
+ return null;
1809
+ }
1810
+ if (!out) return null;
1811
+ // #257 review: spawnSync does NOT throw for a normal shell failure or timeout —
1812
+ // with `shell: true` it returns a result carrying `error` (spawn failure /
1813
+ // ETIMEDOUT) and/or a non-zero/null exit `status`, alongside diagnostic text on
1814
+ // stderr (e.g. `/bin/sh: <cmd>: not found`) or a partial capture. Feeding that to
1815
+ // extractVersionToken (whose fallback accepts the first non-empty line) would
1816
+ // persist the error banner as a bogus `agentCliVersion`. Trust only a clean exit
1817
+ // (no `error`, status 0); anything else omits the field.
1818
+ if (out.error || out.status !== 0) return null;
1819
+ return extractVersionToken(`${out.stdout || ''}\n${out.stderr || ''}`);
1820
+ }
1821
+
1724
1822
  // A worker job-type token: rank/capability tokens use `:` (rank↔cap) and `+`
1725
1823
  // (combined caps) as delimiters, and code-first `@nanobpm/workflow` job types
1726
1824
  // are `<flowId>:<taskName>` or an explicit override. The first character must be
@@ -8753,6 +8851,17 @@ async function workAgent(req, flags, ctx) {
8753
8851
  const workerPidStart = pidStartToken(process.pid);
8754
8852
  let pluginVersion = null;
8755
8853
  try { pluginVersion = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf-8')).version ?? null; } catch { /* best effort */ }
8854
+ // #243: the agent harness CLI's own version is probed ONCE per worker process
8855
+ // (bounded, best-effort) so the durable transcript's provenance block can attribute
8856
+ // a run to a specific harness build. The probe is DEFERRED until the FIRST
8857
+ // external-agent activation (its only consumer is the AgentInstance producer), so a
8858
+ // worker that only services ordinary jobs never runs the harness `--version`. The
8859
+ // probe env is WORKER-STATIC — `process.env` merged with the profile `env` only,
8860
+ // NOT the per-job `setup.env` — so the once-per-worker cache can't leak one job's
8861
+ // setup-derived reading to later jobs (#257). Never fatal — a null result just omits
8862
+ // the field. `agentCliProbed` latches the once-only semantics.
8863
+ let agentCliVersion = null;
8864
+ let agentCliProbed = false;
8756
8865
  let workerNsDir;
8757
8866
  try {
8758
8867
  ({ nsDir: workerNsDir } = allocateWorkerNamespace({
@@ -8864,6 +8973,66 @@ async function workAgent(req, flags, ctx) {
8864
8973
  // active profile itself — identical to the old no-arg behaviour.
8865
8974
  const camunda = globalThis.c8ctl.createClient(resolveConnectionProfile(ctx));
8866
8975
 
8976
+ // #243/#257: precompute the job-independent gates for the harness `--version`
8977
+ // probe (the probe itself is DEFERRED to the first external-agent activation
8978
+ // below — see maybeProbeAgentCliVersion). The probe only yields a consumable
8979
+ // reading when:
8980
+ // - HOST execution (a container job runs `sh -c` inside the selected image, so a
8981
+ // same-named host binary would report a plausible-but-wrong version);
8982
+ // - the AgentInstance kill-switch is not set (NANO_AGENT_INSTANCE=off disables the
8983
+ // producer — the probe's only consumer — so probing would needlessly run a
8984
+ // possibly non-idempotent harness with no reader);
8985
+ // - the host SDK actually supports AgentInstance (create/update). The producer
8986
+ // degrades to a disabled facade for older clients, so probing under such a client
8987
+ // is pure waste + an unnecessary side effect (Copilot review, #257);
8988
+ // - the ACP classifier is available. `createAgentInstanceProducer` is ALSO inert
8989
+ // (the `usable` gate requires `classifyUpdate`) when the agentic classifier is
8990
+ // missing, even with a create/update-capable SDK — so probing would run the
8991
+ // harness `--version` for a producer that can never mint a transcript to consume
8992
+ // it. Mirror that precondition here so the probe stays side-effect-free whenever
8993
+ // the producer is disabled (Copilot review, #257);
8994
+ // - the profile command alone is the full invocation (no extra args). An
8995
+ // interpreter-style profile (`command:'node', args:['agent.js']`) would probe the
8996
+ // interpreter, not the harness — omit rather than misattribute.
8997
+ // The probe itself (probeAgentCliVersion) additionally refuses an embedded-argument
8998
+ // command and runs under the WORKER-STATIC profile env (see maybeProbeAgentCliVersion
8999
+ // for why per-job setup.env is deliberately excluded), so PATH resolves a stable
9000
+ // representative harness binary.
9001
+ const agentInstanceProbeOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
9002
+ const sdkSupportsAgentInstance =
9003
+ !!camunda &&
9004
+ typeof camunda.createAgentInstance === 'function' &&
9005
+ typeof camunda.updateAgentInstance === 'function';
9006
+ // The producer's `usable` gate also requires the ACP classifier (agent-instance.mjs);
9007
+ // without it the producer is inert regardless of SDK support, so exclude the probe too.
9008
+ const acpClassifierAvailable = typeof agenticSessionAcp?.classifyUpdate === 'function';
9009
+ const agentCliProbeEligible =
9010
+ !isContainer && !agentInstanceProbeOff && sdkSupportsAgentInstance && acpClassifierAvailable && effectiveArgs.length === 0;
9011
+ // #257 review: DEFER the probe until an external-agent job is actually being
9012
+ // serviced — so a worker that only ever receives ordinary service jobs never runs
9013
+ // the harness `--version` at all (no wasted startup delay / side effect). Runs at
9014
+ // most once per worker (cached), so the per-job hot path pays nothing after the
9015
+ // first external activation. The probe env is WORKER-STATIC — `process.env` merged
9016
+ // with the profile `env` only, NOT the per-job `setup.env`: `agentCliProbed` is a
9017
+ // worker-wide latch, so folding a single job's `setup.env` (which can change PATH,
9018
+ // and thus which CLI build resolves) into the cache would leak THAT job's reading
9019
+ // to every later job serviced by the same worker (#257). A worker-static basis makes
9020
+ // the cached `agentCliVersion` a correct-by-construction representative reading for
9021
+ // the profile; a job whose `setup.env` genuinely alters the resolved binary is a
9022
+ // per-job divergence the single cached provenance reading deliberately does not chase.
9023
+ const maybeProbeAgentCliVersion = () => {
9024
+ if (agentCliProbed || !agentCliProbeEligible) return;
9025
+ agentCliProbed = true;
9026
+ try {
9027
+ agentCliVersion = probeAgentCliVersion(profile?.command, {
9028
+ env: {
9029
+ ...process.env,
9030
+ ...normalizeEnvMap(profileEnv),
9031
+ },
9032
+ });
9033
+ } catch { /* best effort */ }
9034
+ };
9035
+
8867
9036
  // Broker REST endpoint for live linked-resource prompts (issue #63) and the
8868
9037
  // C8 REST source for `--auto`'s engine-read enrolment. Derived from the SAME
8869
9038
  // client that activates jobs (its profile REST address) when no explicit
@@ -9366,7 +9535,19 @@ async function workAgent(req, flags, ctx) {
9366
9535
  // job, never on these lines).
9367
9536
  const aiCorr = `job ${job.jobKey} eik ${job.elementInstanceKey ?? '?'} pik ${job.processInstanceKey ?? '?'}`;
9368
9537
  if (!agentInstanceOff && isExternalAgentJob(job)) {
9369
- agentInstanceProducer = createAgentInstanceProducer({ camunda, job, profile, envelope, logger });
9538
+ maybeProbeAgentCliVersion();
9539
+ // #257 review: the synchronous CLI `--version` probe above can block for up
9540
+ // to its timeout on the FIRST external activation. A force-stop/lease-loss
9541
+ // can win that race while spawnSync is blocked, so re-check the setup-abort
9542
+ // gate before minting the durable AgentInstance — otherwise an already-aborted
9543
+ // run would call activate() and mint an instance/transcript that is then
9544
+ // immediately orphaned (the post-activate gates run too late to prevent the
9545
+ // create). Mirrors the pre-probe agent-instance gate above.
9546
+ if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'agent-instance', logger })) {
9547
+ if (isContainer) liveRunIds.delete(runId);
9548
+ return;
9549
+ }
9550
+ agentInstanceProducer = createAgentInstanceProducer({ camunda, job, profile, envelope, logger, runtimeVersion: pluginVersion, agentCliVersion });
9370
9551
  try {
9371
9552
  // `createAgentInstanceProducer` always returns an object — including a
9372
9553
  // DISABLED facade when the host SDK lacks createAgentInstance/
@@ -15464,6 +15645,7 @@ export {
15464
15645
  startAgenticChannelWatchdog,
15465
15646
  };
15466
15647
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
15648
+ export { probeAgentCliVersion };
15467
15649
  export {
15468
15650
  webConsoleUrl,
15469
15651
  consoleLinkLabel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.63.2",
3
+ "version": "1.64.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",
@@ -75,12 +75,12 @@
75
75
  },
76
76
  "optionalDependencies": {
77
77
  "node-pty": "^1.0.0",
78
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.63.2",
79
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.63.2",
80
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.63.2",
81
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.63.2",
82
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.63.2",
83
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.63.2",
84
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.63.2"
78
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.64.0",
79
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.64.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.64.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.64.0",
82
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.64.0",
83
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.64.0",
84
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.64.0"
85
85
  }
86
86
  }