c8ctl-plugin-nano 1.59.0 → 1.60.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.
@@ -34,9 +34,10 @@ const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArra
34
34
  * element carries `zeebe:agentDefinition agentType="external"`?
35
35
  *
36
36
  * The engine surfaces exactly this eligibility as the pair of fields it stamps on
37
- * an external agent job's activation: an opaque per-activation `jobLease` token
38
- * (distinct from the job's `deadline`; nanobpmn #1106) plus the `elementInstanceKey`
39
- * the AgentInstance correlates on. The engine-native `aiAgentTask`/`aiAgentSubProcess`
37
+ * an external agent job's activation: an opaque per-activation lease token
38
+ * `leaseToken` on the activated job (Camunda v10 ActivatedJobResult; distinct from
39
+ * the job's `deadline`, nanobpmn #1106) plus the `elementInstanceKey` the
40
+ * AgentInstance correlates on. The engine-native `aiAgentTask`/`aiAgentSubProcess`
40
41
  * variants auto-mint their AgentInstance and never create an activatable job, so any
41
42
  * job a worker actually activates that carries a lease token IS an external agent
42
43
  * job. Absence of either field means "not an external agent job" → the producer
@@ -44,7 +45,7 @@ const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArra
44
45
  */
45
46
  export function isExternalAgentJob(job) {
46
47
  if (!isPlainObject(job)) return false;
47
- return isNonBlank(job.jobLease) && isNonBlank(job.elementInstanceKey);
48
+ return isNonBlank(job.leaseToken) && isNonBlank(job.elementInstanceKey);
48
49
  }
49
50
 
50
51
  /** Best-effort provider inference from a model identifier (openai/anthropic/…). */
@@ -145,7 +146,7 @@ function contentForResult(result) {
145
146
  *
146
147
  * @param {object} opts
147
148
  * @param {object} opts.camunda Host SDK client (createAgentInstance/updateAgentInstance).
148
- * @param {object} opts.job The activated job (jobKey/jobLease/elementInstanceKey/elementId).
149
+ * @param {object} opts.job The activated job (jobKey/leaseToken/elementInstanceKey/elementId).
149
150
  * @param {object} [opts.profile] The worker profile (model/provider seed the definition).
150
151
  * @param {object} [opts.envelope] The normalized task envelope (task.prompt → systemPrompt).
151
152
  * @param {object} [opts.logger] Output-mode-aware logger (warn/info/debug).
@@ -166,7 +167,12 @@ export function createAgentInstanceProducer(opts = {}) {
166
167
  const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
167
168
 
168
169
  const jobKey = job?.jobKey != null ? String(job.jobKey) : '';
169
- const jobLease = job?.jobLease != null ? String(job.jobLease) : '';
170
+ // The activation lease is carried on the job as `leaseToken` (Camunda v10
171
+ // ActivatedJobResult). It is submitted BACK to createAgentInstance/updateAgentInstance
172
+ // as the request-body field `jobLease` (agent-instances.yaml) — the same opaque token,
173
+ // named differently on the two sides of the contract. One internal name here; the
174
+ // translation to `jobLease` happens only at each SDK call below.
175
+ const leaseToken = job?.leaseToken != null ? String(job.leaseToken) : '';
170
176
  const elementInstanceKey = job?.elementInstanceKey != null ? String(job.elementInstanceKey) : '';
171
177
  const elementId = job?.elementId != null ? String(job.elementId) : null;
172
178
 
@@ -212,7 +218,7 @@ export function createAgentInstanceProducer(opts = {}) {
212
218
  agentInstanceKey,
213
219
  elementInstanceKey,
214
220
  jobKey,
215
- jobLease,
221
+ jobLease: leaseToken,
216
222
  history: [turn],
217
223
  };
218
224
  if (status) req.status = status;
@@ -321,7 +327,7 @@ export function createAgentInstanceProducer(opts = {}) {
321
327
  const res = await camunda[SDK_CREATE]({
322
328
  elementInstanceKey,
323
329
  jobKey,
324
- jobLease,
330
+ jobLease: leaseToken,
325
331
  history: [configTurn],
326
332
  });
327
333
  agentInstanceKey =
@@ -425,7 +431,7 @@ export function createAgentInstanceProducer(opts = {}) {
425
431
  agentInstanceKey,
426
432
  elementInstanceKey,
427
433
  jobKey,
428
- jobLease,
434
+ jobLease: leaseToken,
429
435
  status: 'COMPLETED',
430
436
  });
431
437
  });
package/c8ctl-plugin.js CHANGED
@@ -6373,16 +6373,25 @@ function normalizeProjectApps(projects) {
6373
6373
  }
6374
6374
 
6375
6375
  /**
6376
- * Probe whether an embedded app's `/agentic` endpoint answers a WebSocket
6377
- * upgrade. Connects to `ws://<host>:<port>/agentic?token=…` (host defaults to
6376
+ * Probe whether a `/agentic` endpoint answers a WebSocket upgrade. Connects to
6377
+ * `ws(s)://<host>:<port><pathPrefix>/agentic?token=…` (host defaults to
6378
6378
  * `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
6379
6379
  * resolves `true` only if the socket opens within `timeoutMs`; a refused
6380
- * connection, the console proxy's deliberate `501`, a `404`, or a timeout all
6381
- * resolve `false`. Self-cleaning — the probe socket is closed as soon as the
6382
- * outcome is known. Never throws.
6380
+ * connection, a `404`/`501`, or a timeout all resolve `false`. Self-cleaning —
6381
+ * the probe socket is closed as soon as the outcome is known. Never throws.
6383
6382
  *
6384
- * @param {number} port the app's direct agentic port (`appUi.port`)
6385
- * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
6383
+ * `pathPrefix` targets either the app's own port (empty prefix → `/agentic`) or
6384
+ * the engine's console **app-view WebSocket tunnel** (engine #1054), where the
6385
+ * prefix is `/console/app-view/<project>` so the channel rides the single engine
6386
+ * port instead of the app's direct port (see {@link discoverAgenticHubs}).
6387
+ *
6388
+ * `secure` selects the WS scheme: `false` → `ws://` (plain), `true` → `wss://`.
6389
+ * The tunnel leg rides the engine's own port, so an `https://` engine base
6390
+ * requires a `wss://` upgrade — probing it with plain `ws://` would spuriously
6391
+ * fail the tunnel and force the direct-port fallback (see {@link discoverAgenticHubs}).
6392
+ *
6393
+ * @param {number|string} port the port the WS connects to (app port, or engine port for the tunnel)
6394
+ * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
6386
6395
  * @returns {Promise<boolean>}
6387
6396
  */
6388
6397
  function probeAgenticChannel(port, {
@@ -6390,9 +6399,12 @@ function probeAgenticChannel(port, {
6390
6399
  token = LOCAL_AGENTIC_TOKEN,
6391
6400
  WebSocketImpl = globalThis.WebSocket,
6392
6401
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
6402
+ pathPrefix = '',
6403
+ secure = false,
6393
6404
  } = {}) {
6394
6405
  if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
6395
- const url = `ws://${wsHostPart(host)}:${port}/agentic?token=${encodeURIComponent(token)}`;
6406
+ const wsScheme = secure ? 'wss' : 'ws';
6407
+ const url = `${wsScheme}://${wsHostPart(host)}:${port}${pathPrefix}/agentic?token=${encodeURIComponent(token)}`;
6396
6408
  return new Promise((resolve) => {
6397
6409
  let done = false;
6398
6410
  let ws;
@@ -6499,8 +6511,8 @@ async function resolveProbeCandidates(host, { lookupImpl = dnsLookup } = {}) {
6499
6511
  * slow-to-open one can't stall the whole probe: a fast later candidate still
6500
6512
  * wins. Each per-host probe is bounded by `timeoutMs`. A single candidate skips
6501
6513
  * the racing machinery entirely (unchanged legacy path).
6502
- * @param {number} port
6503
- * @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number }} [opts]
6514
+ * @param {number|string} port
6515
+ * @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
6504
6516
  * @returns {Promise<string|null>} the winning host, or null
6505
6517
  */
6506
6518
  async function raceProbeCandidates(port, {
@@ -6509,12 +6521,14 @@ async function raceProbeCandidates(port, {
6509
6521
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
6510
6522
  wsProbe = probeAgenticChannel,
6511
6523
  staggerMs = 250,
6524
+ pathPrefix = '',
6525
+ secure = false,
6512
6526
  } = {}) {
6513
6527
  const list = Array.isArray(hosts) ? hosts.filter(Boolean) : [];
6514
6528
  if (list.length === 0) return null;
6515
6529
  if (list.length === 1) {
6516
6530
  try {
6517
- return (await wsProbe(port, { host: list[0], token, timeoutMs })) ? list[0] : null;
6531
+ return (await wsProbe(port, { host: list[0], token, timeoutMs, pathPrefix, secure })) ? list[0] : null;
6518
6532
  } catch {
6519
6533
  return null;
6520
6534
  }
@@ -6531,7 +6545,7 @@ async function raceProbeCandidates(port, {
6531
6545
  };
6532
6546
  const start = (host) => {
6533
6547
  Promise.resolve()
6534
- .then(() => wsProbe(port, { host, token, timeoutMs }))
6548
+ .then(() => wsProbe(port, { host, token, timeoutMs, pathPrefix, secure }))
6535
6549
  .catch(() => false)
6536
6550
  .then((ok) => {
6537
6551
  if (ok) done(host);
@@ -6547,23 +6561,32 @@ async function raceProbeCandidates(port, {
6547
6561
 
6548
6562
  /**
6549
6563
  * Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
6550
- * URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
6551
- * advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
6552
- * engine's own host** to confirm the channel is actually served there (bypassing
6553
- * the WS-incapable console proxy). Works cross-machine on a trusted LAN: a
6554
- * loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
6555
- * probes that same host the port is taken from the projects API but the host is
6556
- * always the engine's, so a rogue projects API can never steer a probe at the
6557
- * worker's own loopback (#76). Gives the projects fetch and each WS probe
6558
- * INDEPENDENT deadlines (#133) so a slow fetch can't starve the probe, prefers a
6559
- * routable address over a link-local `fe80::` one (Happy-Eyeballs), and is
6560
- * fail-open: any error not a nano engine (Camunda), network failure, malformed
6561
- * body, or a timeout degrades to `[]` so the worker's real job is never
6562
- * blocked.
6564
+ * URL (#75, #96, #97). Reads `GET <engine>/console/api/projects`, keeps the apps
6565
+ * that advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
6566
+ * engine's own host** to confirm the channel is actually served there. Works
6567
+ * cross-machine on a trusted LAN: a loopback engine probes `127.0.0.1`, a remote
6568
+ * engine (e.g. `merlin.local`) probes that same host — the port is taken from the
6569
+ * projects API but the host is always the engine's, so a rogue projects API can
6570
+ * never steer a probe at the worker's own loopback (#76).
6571
+ *
6572
+ * **Single-port hardening (#97, engine #1054):** each app is probed **tunnel-first**
6573
+ * `ws://<engineHost>:<enginePort>/console/app-view/<project>/agentic`, the console
6574
+ * app-view WebSocket tunnel on the engine's *own* port (the same host:port the
6575
+ * worker already reached for the projects read). A surviving tunnel hub is marked
6576
+ * `via:'tunnel'` and needs only the engine port to be reachable — the app's direct
6577
+ * port need not be LAN-open. If the tunnel probe fails (an engine that predates
6578
+ * #1054 refuses the `/agentic` WS upgrade with `501`), it falls back to probing the
6579
+ * app's **direct** port and marks the hub `via:'direct'` (unchanged #96 behaviour).
6580
+ *
6581
+ * Gives the projects fetch and each WS probe INDEPENDENT deadlines (#133) so a slow
6582
+ * fetch can't starve the probe, prefers a routable address over a link-local `fe80::`
6583
+ * one (Happy-Eyeballs), and is fail-open: any error — not a nano engine (Camunda),
6584
+ * network failure, malformed body, or a timeout — degrades to `[]` so the worker's
6585
+ * real job is never blocked.
6563
6586
  *
6564
6587
  * @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
6565
6588
  * @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, lookupImpl?: Function, timeoutMs?: number, fetchTimeoutMs?: number, probeTimeoutMs?: number }} [opts]
6566
- * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
6589
+ * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string, via: 'tunnel'|'direct', enginePort?: string, scheme?: string }>>}
6567
6590
  */
6568
6591
  async function discoverAgenticHubs(engineBaseUrl, {
6569
6592
  token = LOCAL_AGENTIC_TOKEN,
@@ -6586,12 +6609,15 @@ async function discoverAgenticHubs(engineBaseUrl, {
6586
6609
  // loopback services — which was the actual #76 concern (a rogue projects API
6587
6610
  // making the worker probe its own localhost). So the port comes from the
6588
6611
  // engine's projects API, but the HOST is always the engine's, never guessed.
6589
- let host;
6612
+ let engineUrl;
6590
6613
  try {
6591
- host = new URL(base).hostname;
6614
+ engineUrl = new URL(base);
6592
6615
  } catch {
6593
6616
  return [];
6594
6617
  }
6618
+ const host = engineUrl.hostname;
6619
+ const engineScheme = engineUrl.protocol; // 'http:' | 'https:'
6620
+ const enginePort = engineUrl.port || (engineScheme === 'https:' ? '443' : '80');
6595
6621
  const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
6596
6622
  // (C) Decoupled budgets (#133): the projects fetch and each WS probe get their
6597
6623
  // OWN independent deadline. Previously they shared one 2s budget, so a fetch
@@ -6623,13 +6649,32 @@ async function discoverAgenticHubs(engineBaseUrl, {
6623
6649
  const candidates = await resolveProbeCandidates(probeHost, { lookupImpl });
6624
6650
  const settled = await Promise.all(apps.map(async (app) => {
6625
6651
  try {
6626
- const winner = await raceProbeCandidates(app.port, {
6652
+ // Tunnel-first (#97): confirm the channel over the console app-view WS
6653
+ // tunnel on the ENGINE port — the same host:port the projects read just
6654
+ // used — so the app's direct port need not be reachable. A pre-#1054
6655
+ // engine 501s the upgrade and the probe fails; we then fall back to the
6656
+ // app's direct port (unchanged #96 path).
6657
+ const tunnelHost = await raceProbeCandidates(enginePort, {
6658
+ hosts: candidates,
6659
+ token,
6660
+ timeoutMs: probeTimeoutMs,
6661
+ wsProbe,
6662
+ pathPrefix: `/console/app-view/${encodeURIComponent(app.project)}`,
6663
+ // The tunnel rides the engine's own port, so match its TLS: an
6664
+ // `https://` engine base upgrades over `wss://`, not `ws://` (else the
6665
+ // tunnel probe spuriously fails and we drop to the direct port).
6666
+ secure: engineScheme === 'https:',
6667
+ });
6668
+ if (tunnelHost) {
6669
+ return { ...app, host: tunnelHost, via: 'tunnel', enginePort, scheme: engineScheme };
6670
+ }
6671
+ const directHost = await raceProbeCandidates(app.port, {
6627
6672
  hosts: candidates,
6628
6673
  token,
6629
6674
  timeoutMs: probeTimeoutMs,
6630
6675
  wsProbe,
6631
6676
  });
6632
- return winner ? { ...app, host: winner } : null;
6677
+ return directHost ? { ...app, host: directHost, via: 'direct' } : null;
6633
6678
  } catch {
6634
6679
  return null;
6635
6680
  }
@@ -6646,8 +6691,11 @@ async function discoverAgenticHubs(engineBaseUrl, {
6646
6691
  * half-configured. No discovery attempted.
6647
6692
  * - `{ status: 'connect', config }` — a target to connect to. Either the
6648
6693
  * explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
6649
- * single discovered app's `ws://<engineHost>:<port>/agentic` (loopback for a
6650
- * local engine, the engine's LAN host for a remote one).
6694
+ * single discovered app: the console app-view WS tunnel on the engine port
6695
+ * (`<engineHost>:<enginePort>/console/app-view/<project>/agentic`, `via:'tunnel'`,
6696
+ * #97) when available, else the direct app port
6697
+ * (`ws://<engineHost>:<appPort>/agentic`, `via:'direct'`, #96) — loopback for a
6698
+ * local engine, the engine's LAN host for a remote one.
6651
6699
  * - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
6652
6700
  * channel. Hard stop for the worker: it must not silently pick one.
6653
6701
  * - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
@@ -6681,10 +6729,18 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
6681
6729
  } catch { /* keep the loopback default */ }
6682
6730
 
6683
6731
  if (hubs.length === 1) {
6684
- const { project, port, host } = hubs[0];
6732
+ const { project, port, host, via, enginePort, scheme } = hubs[0];
6733
+ // A tunnel hub rides the console app-view WS bridge on the engine's own port
6734
+ // (#97): `<scheme>//<engineHost>:<enginePort>/console/app-view/<project>`, to
6735
+ // which `buildAgenticUrl` appends `/agentic`. A direct hub keeps the #96
6736
+ // `http://<host>:<appPort>` form. `discovered.port` stays the app's advertised
6737
+ // port either way (the hub identity); the local `via` records the route taken.
6738
+ const url = via === 'tunnel'
6739
+ ? `${scheme}//${wsHostPart(host)}:${enginePort}/console/app-view/${encodeURIComponent(project)}`
6740
+ : `http://${wsHostPart(host)}:${port}`;
6685
6741
  const config = {
6686
6742
  ...base,
6687
- url: `http://${wsHostPart(host)}:${port}`,
6743
+ url,
6688
6744
  discovered: { project, port, host },
6689
6745
  };
6690
6746
  // Cache the known-good hub so a later blip self-heals from cache (#133-C).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.59.0",
3
+ "version": "1.60.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",
@@ -73,12 +73,12 @@
73
73
  },
74
74
  "optionalDependencies": {
75
75
  "node-pty": "^1.0.0",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.59.0",
77
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.0",
81
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.0",
82
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.0"
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.60.0",
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.60.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.60.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.60.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.60.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.60.0",
82
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.60.0"
83
83
  }
84
84
  }
@@ -141,17 +141,22 @@ function mapJob(raw) {
141
141
  const pik = raw.processInstanceKey;
142
142
  if (pik !== undefined && pik !== null) job.processInstanceKey = String(pik);
143
143
  // Engine-native AgentInstance attribution (issue #194): the element instance the
144
- // AgentInstance correlates on, the element id, and the opaque per-activation
145
- // `jobLease` token that lease-gates a `createAgentInstance` for an `external`
146
- // agent job (nanobpmn #1099/#1106). These ride opaquely to the runner exactly as
147
- // the SDK activation surfaces them; absent for ordinary (non-agent) jobs, in which
148
- // case the durable-transcript producer stays inert.
144
+ // AgentInstance correlates on, the element id, and the opaque per-activation lease
145
+ // token that lease-gates a `createAgentInstance` for an `external` agent job
146
+ // (nanobpmn #1099/#1106). The Camunda v10 REST contract carries the lease on the
147
+ // ACTIVATED JOB as `leaseToken` (jobs.yaml ActivatedJobResult) NOT `jobLease`,
148
+ // which is the field name only on the createAgentInstance/updateAgentInstance REQUEST
149
+ // body (agent-instances.yaml). The harness carries ONE internal name — `leaseToken` —
150
+ // all the way through, and translates to `jobLease` only at that SDK body (see
151
+ // agent-instance.mjs). These ride opaquely to the runner exactly as the SDK activation
152
+ // surfaces them; absent for ordinary (non-agent) jobs, in which case the
153
+ // durable-transcript producer stays inert.
149
154
  const eik = raw.elementInstanceKey;
150
155
  if (eik !== undefined && eik !== null) job.elementInstanceKey = String(eik);
151
156
  const eid = raw.elementId;
152
157
  if (eid !== undefined && eid !== null) job.elementId = String(eid);
153
- const lease = raw.jobLease;
154
- if (lease !== undefined && lease !== null) job.jobLease = String(lease);
158
+ const lease = raw.leaseToken;
159
+ if (lease !== undefined && lease !== null) job.leaseToken = String(lease);
155
160
  return job;
156
161
  }
157
162
 
@@ -254,6 +259,12 @@ export function createRawEngineClient(opts = {}) {
254
259
  maxJobsToActivate: req.maxJobsToActivate,
255
260
  timeout: req.lockMs,
256
261
  requestTimeout: req.requestTimeoutMs,
262
+ // Request a per-activation lease (Camunda v10 `withLease`): the engine then
263
+ // stamps each activated job with a distinct opaque `leaseToken`
264
+ // (ActivatedJobResult.leaseToken) which lease-gates the external agent job's
265
+ // `createAgentInstance` (nanobpmn #1099/#1106). Without it the token is absent
266
+ // and the durable AgentInstance producer stays inert.
267
+ withLease: true,
257
268
  };
258
269
  // Give the abort budget slack over the server long-poll so we don't cancel
259
270
  // a still-valid long-poll a hair before the server would answer it.