c8ctl-plugin-nano 1.59.1 → 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.
Files changed (2) hide show
  1. package/c8ctl-plugin.js +90 -34
  2. package/package.json +8 -8
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.1",
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.1",
77
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.1",
79
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.1",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.1",
81
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.1",
82
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.1"
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
  }