c8ctl-plugin-nano 1.36.1 → 1.36.2

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 (3) hide show
  1. package/README.md +18 -13
  2. package/c8ctl-plugin.js +72 -30
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -292,23 +292,28 @@ c8ctl nano work reviewer
292
292
 
293
293
  **Zero-config hub auto-discovery.** You usually don't even set `NANO_AGENTIC_URL`.
294
294
  When nwf runs **embedded**, the engine (`:8080`) serves the console but the
295
- `/agentic` channel is served by the **embedded app on its own loopback port**
296
- (e.g. `:3000`); the engine's console proxy deliberately refuses WebSocket
297
- upgrades (nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the
298
- engine URL. With **no** agentic target configured, `work` therefore
299
- **auto-discovers** it: it reads `GET <engine>/console/api/projects` and, for each
300
- running app that advertises an agentic UI port (`appUi.enabled === true` and
301
- `appUi.port`), probes that app's direct `ws://127.0.0.1:<port>/agentic`. Discovery
302
- is **loopback-only** and **time-bounded** (≤2s) and never meaningfully delays job
303
- polling.
295
+ `/agentic` channel is served by the **embedded app on its own port** (e.g.
296
+ `:3000`); the engine's console proxy deliberately refuses WebSocket upgrades
297
+ (nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the engine URL.
298
+ With **no** agentic target configured, `work` therefore **auto-discovers** it: it
299
+ reads `GET <engine>/console/api/projects` and, for each running app that
300
+ advertises an agentic UI port (`appUi.enabled === true` and `appUi.port`), probes
301
+ that app's direct `ws://<engine-host>:<port>/agentic`. Discovery runs **against
302
+ the engine's own host** a local engine keeps probing `127.0.0.1`, while a
303
+ remote/LAN engine (e.g. `merlin.local:8080`) steers the probe back at *itself*
304
+ (`merlin.local:<port>`), never at the worker's own loopback services. It is
305
+ **time-bounded** (≤2s) and never meaningfully delays job polling. The discovered
306
+ host and port are printed for debugging. (An IPv6 literal engine host is bracketed
307
+ in the URL authority, e.g. `ws://[2001:db8::1]:3000/agentic`.)
304
308
 
305
309
  - **Exactly one app →** the worker connects directly to
306
- `ws://127.0.0.1:<appUi.port>/agentic` (bypassing the WS-incapable console proxy)
307
- and appears live with **zero configuration**.
310
+ `ws://<engine-host>:<appUi.port>/agentic` (bypassing the WS-incapable console
311
+ proxy) and appears live with **zero configuration** — including cross-machine on
312
+ a trusted LAN.
308
313
  - **Two or more apps →** the worker **does not guess**: it prints an `ambiguous`
309
314
  error naming each discovered `project → :port` and **stops**. Pin the one you
310
- want and re-run: `export NANO_AGENTIC_URL=http://127.0.0.1:<port>` (or persist
311
- `agenticUrl`).
315
+ want and re-run: `export NANO_AGENTIC_URL=http://<engine-host>:<port>` (or
316
+ persist `agenticUrl`).
312
317
  - **Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) →** the
313
318
  worker prints a one-line advisory naming `NANO_AGENTIC_URL` and **continues
314
319
  doing real work** with the channel simply absent — discovery never fails the
package/c8ctl-plugin.js CHANGED
@@ -4170,6 +4170,21 @@ function isLoopbackHost(hostname) {
4170
4170
  return /^127(?:\.\d{1,3}){3}$/.test(h);
4171
4171
  }
4172
4172
 
4173
+ /**
4174
+ * Format a hostname for the authority component of a `ws://`/`http://` URL:
4175
+ * a bare IPv6 literal (contains `:`, not already bracketed) is wrapped in `[…]`,
4176
+ * everything else is used verbatim. Idempotent — an already-bracketed host is
4177
+ * left as-is. Guards against building an invalid `ws://::1:3000/…` when a raw or
4178
+ * normalized IPv6 host (e.g. the `::1` constant) has not been bracketed.
4179
+ *
4180
+ * @param {string} host a hostname from `URL.hostname` or a normalized loopback
4181
+ * @returns {string}
4182
+ */
4183
+ function wsHostPart(host) {
4184
+ const h = String(host || '');
4185
+ return h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
4186
+ }
4187
+
4173
4188
  /**
4174
4189
  * Normalise the engine's `GET /console/api/projects` payload into the running
4175
4190
  * embedded apps that advertise an agentic UI port. Accepts the shapes the
@@ -4207,24 +4222,26 @@ function normalizeProjectApps(projects) {
4207
4222
  }
4208
4223
 
4209
4224
  /**
4210
- * Probe whether an embedded app's own loopback `/agentic` endpoint answers a
4211
- * WebSocket upgrade. Connects to `ws://127.0.0.1:<port>/agentic?token=…` and
4225
+ * Probe whether an embedded app's `/agentic` endpoint answers a WebSocket
4226
+ * upgrade. Connects to `ws://<host>:<port>/agentic?token=…` (host defaults to
4227
+ * `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
4212
4228
  * resolves `true` only if the socket opens within `timeoutMs`; a refused
4213
4229
  * connection, the console proxy's deliberate `501`, a `404`, or a timeout all
4214
- * resolve `false`. Loopback-only and self-cleaning — the probe socket is closed
4215
- * as soon as the outcome is known. Never throws.
4230
+ * resolve `false`. Self-cleaning — the probe socket is closed as soon as the
4231
+ * outcome is known. Never throws.
4216
4232
  *
4217
- * @param {number} port the app's direct loopback port (`appUi.port`)
4218
- * @param {{ token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
4233
+ * @param {number} port the app's direct agentic port (`appUi.port`)
4234
+ * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
4219
4235
  * @returns {Promise<boolean>}
4220
4236
  */
4221
4237
  function probeAgenticChannel(port, {
4238
+ host = '127.0.0.1',
4222
4239
  token = LOCAL_AGENTIC_TOKEN,
4223
4240
  WebSocketImpl = globalThis.WebSocket,
4224
4241
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
4225
4242
  } = {}) {
4226
4243
  if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
4227
- const url = `ws://127.0.0.1:${port}/agentic?token=${encodeURIComponent(token)}`;
4244
+ const url = `ws://${wsHostPart(host)}:${port}/agentic?token=${encodeURIComponent(token)}`;
4228
4245
  return new Promise((resolve) => {
4229
4246
  let done = false;
4230
4247
  let ws;
@@ -4249,19 +4266,21 @@ function probeAgenticChannel(port, {
4249
4266
 
4250
4267
  /**
4251
4268
  * Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
4252
- * URL (#75). Reads `GET <engine>/console/api/projects`, keeps the apps that
4253
- * advertise an agentic UI port, and WS-probes each app's direct loopback
4254
- * `/agentic` to confirm the channel is actually served there (bypassing the
4255
- * WS-incapable console proxy). Loopback-only (the engine host itself must be
4256
- * loopback, since the response steers a local port probe), enforces a single
4257
- * shared time budget across the fetch + probes, and is fail-open: any error
4258
- * not a nano engine (Camunda), a non-loopback engine, network failure,
4259
- * malformed body, or an overall timeout degrades to `[]` so the worker's real
4260
- * job is never blocked.
4269
+ * URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
4270
+ * advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
4271
+ * engine's own host** to confirm the channel is actually served there (bypassing
4272
+ * the WS-incapable console proxy). Works cross-machine on a trusted LAN: a
4273
+ * loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
4274
+ * probes that same host the port is taken from the projects API but the host is
4275
+ * always the engine's, so a rogue projects API can never steer a probe at the
4276
+ * worker's own loopback (#76). Enforces a single shared time budget across the
4277
+ * fetch + probes, and is fail-open: any error — not a nano engine (Camunda),
4278
+ * network failure, malformed body, or an overall timeout — degrades to `[]` so
4279
+ * the worker's real job is never blocked.
4261
4280
  *
4262
- * @param {string} engineBaseUrl the engine base URL (e.g. `http://localhost:8080`)
4281
+ * @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
4263
4282
  * @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4264
- * @returns {Promise<Array<{ project: string, port: number, label?: string }>>}
4283
+ * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
4265
4284
  */
4266
4285
  async function discoverAgenticHubs(engineBaseUrl, {
4267
4286
  token = LOCAL_AGENTIC_TOKEN,
@@ -4273,16 +4292,21 @@ async function discoverAgenticHubs(engineBaseUrl, {
4273
4292
  return [];
4274
4293
  }
4275
4294
  const base = engineBaseUrl.replace(/\/+$/, '');
4276
- // Loopback-only: discovery probes 127.0.0.1:<port> using a port advertised by
4277
- // the engine's projects API, so a non-loopback (remote) engine could steer a
4278
- // local port probe. Refuse discovery unless the engine host is loopback (#76).
4295
+ // Discover against the ENGINE's own host the app is embedded in the engine,
4296
+ // so its /agentic port lives on the same host the worker already trusts as its
4297
+ // engine (that's where it pulls jobs from). A loopback engine keeps probing
4298
+ // 127.0.0.1 (unchanged local behaviour); a remote/LAN engine (e.g.
4299
+ // merlin.local) steers the probe back to ITSELF, never at the worker's own
4300
+ // loopback services — which was the actual #76 concern (a rogue projects API
4301
+ // making the worker probe its own localhost). So the port comes from the
4302
+ // engine's projects API, but the HOST is always the engine's, never guessed.
4279
4303
  let host;
4280
4304
  try {
4281
4305
  host = new URL(base).hostname;
4282
4306
  } catch {
4283
4307
  return [];
4284
4308
  }
4285
- if (!isLoopbackHost(host)) return [];
4309
+ const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
4286
4310
  // Single discovery budget: the projects fetch and the WS probes share ONE
4287
4311
  // deadline, so total discovery can't approach 2× timeoutMs (the fetch could
4288
4312
  // consume ~timeoutMs and then each probe was previously given a fresh full
@@ -4304,10 +4328,13 @@ async function discoverAgenticHubs(engineBaseUrl, {
4304
4328
  if (apps.length === 0) return [];
4305
4329
  const remainingMs = deadline - Date.now();
4306
4330
  if (remainingMs <= 0) return [];
4307
- // Probe candidate ports concurrently within the remaining shared budget.
4331
+ // Probe candidate ports concurrently within the remaining shared budget. Each
4332
+ // surviving hub carries the engine host so the caller builds the right URL.
4308
4333
  const settled = await Promise.all(apps.map(async (app) => {
4309
4334
  try {
4310
- return (await wsProbe(app.port, { token, timeoutMs: remainingMs })) ? app : null;
4335
+ return (await wsProbe(app.port, { host: probeHost, token, timeoutMs: remainingMs }))
4336
+ ? { ...app, host: probeHost }
4337
+ : null;
4311
4338
  } catch {
4312
4339
  return null;
4313
4340
  }
@@ -4324,7 +4351,8 @@ async function discoverAgenticHubs(engineBaseUrl, {
4324
4351
  * half-configured. No discovery attempted.
4325
4352
  * - `{ status: 'connect', config }` — a target to connect to. Either the
4326
4353
  * explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
4327
- * single discovered app's direct `ws://127.0.0.1:<port>/agentic` loopback.
4354
+ * single discovered app's `ws://<engineHost>:<port>/agentic` (loopback for a
4355
+ * local engine, the engine's LAN host for a remote one).
4328
4356
  * - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
4329
4357
  * channel. Hard stop for the worker: it must not silently pick one.
4330
4358
  * - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
@@ -4342,11 +4370,24 @@ async function resolveAgenticTarget(opts = {}) {
4342
4370
 
4343
4371
  const hubs = await discoverAgenticHubs(base.url, { token: base.token, ...opts });
4344
4372
 
4373
+ // The host to suggest in operator-facing messages: the engine's own host
4374
+ // (bracketed if an IPv6 literal, so the suggested URL authority is valid), so a
4375
+ // remote-engine advisory names the reachable LAN host rather than 127.0.0.1.
4376
+ let suggestHost = '127.0.0.1';
4377
+ try {
4378
+ const h = new URL(base.url).hostname;
4379
+ suggestHost = wsHostPart(isLoopbackHost(h) ? '127.0.0.1' : h);
4380
+ } catch { /* keep the loopback default */ }
4381
+
4345
4382
  if (hubs.length === 1) {
4346
- const { project, port } = hubs[0];
4383
+ const { project, port, host } = hubs[0];
4347
4384
  return {
4348
4385
  status: 'connect',
4349
- config: { ...base, url: `http://127.0.0.1:${port}`, discovered: { project, port } },
4386
+ config: {
4387
+ ...base,
4388
+ url: `http://${wsHostPart(host)}:${port}`,
4389
+ discovered: { project, port, host },
4390
+ },
4350
4391
  };
4351
4392
  }
4352
4393
  if (hubs.length > 1) {
@@ -4355,14 +4396,14 @@ async function resolveAgenticTarget(opts = {}) {
4355
4396
  status: 'ambiguous',
4356
4397
  candidates: hubs,
4357
4398
  message: `multiple embedded apps expose an agentic channel (${list}); refusing to guess. `
4358
- + 'Disambiguate by setting NANO_AGENTIC_URL=http://127.0.0.1:<port> (or persisted agenticUrl) to the one you want.',
4399
+ + `Disambiguate by setting NANO_AGENTIC_URL=http://${suggestHost}:<port> (or persisted agenticUrl) to the one you want.`,
4359
4400
  };
4360
4401
  }
4361
4402
  return {
4362
4403
  status: 'advisory',
4363
4404
  message: `agentic visibility was not discoverable at ${base.url} — the embedded app port could `
4364
4405
  + 'not be found (not a nano engine, or its console projects API is absent). Set '
4365
- + 'NANO_AGENTIC_URL=http://127.0.0.1:<appUi.port> to enable the visibility channel. Continuing without it.',
4406
+ + `NANO_AGENTIC_URL=http://${suggestHost}:<appUi.port> to enable the visibility channel. Continuing without it.`,
4366
4407
  };
4367
4408
  }
4368
4409
 
@@ -4744,7 +4785,8 @@ async function workAgent(req, flags) {
4744
4785
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
4745
4786
  const mode = agenticCfg.secure ? 'secure' : 'local';
4746
4787
  if (agenticCfg.discovered) {
4747
- logger.info(` agentic channel: auto-discovered ${agenticCfg.discovered.project} on the embedded app port :${agenticCfg.discovered.port} (bypassing the WS-incapable console proxy).`);
4788
+ const d = agenticCfg.discovered;
4789
+ logger.info(` agentic channel: auto-discovered ${d.project} on the app's /agentic port ${wsHostPart(d.host)}:${d.port} (bypassing the WS-incapable console proxy).`);
4748
4790
  }
4749
4791
  logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
4750
4792
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.36.1",
3
+ "version": "1.36.2",
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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.1",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.1",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.1",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.1",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.1",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.1",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.1"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.2",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.2",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.2",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.2",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.2",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.2",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.2"
67
67
  }
68
68
  }