c8ctl-plugin-nano 1.34.0 → 1.35.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 CHANGED
@@ -289,6 +289,35 @@ c8ctl nano work reviewer
289
289
  # agentic channel (local): announcing presence as ‹worker› on ws://localhost:8080/agentic
290
290
  ```
291
291
 
292
+ **Zero-config hub auto-discovery.** You usually don't even set `NANO_AGENTIC_URL`.
293
+ When nwf runs **embedded**, the engine (`:8080`) serves the console but the
294
+ `/agentic` channel is served by the **embedded app on its own loopback port**
295
+ (e.g. `:3000`); the engine's console proxy deliberately refuses WebSocket
296
+ upgrades (nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the
297
+ engine URL. With **no** agentic target configured, `work` therefore
298
+ **auto-discovers** it: it reads `GET <engine>/console/api/projects` and, for each
299
+ running app that advertises an agentic UI port (`appUi.enabled === true` and
300
+ `appUi.port`), probes that app's direct `ws://127.0.0.1:<port>/agentic`. Discovery
301
+ is **loopback-only** and **time-bounded** (≤2s) and never meaningfully delays job
302
+ polling.
303
+
304
+ - **Exactly one app →** the worker connects directly to
305
+ `ws://127.0.0.1:<appUi.port>/agentic` (bypassing the WS-incapable console proxy)
306
+ and appears live with **zero configuration**.
307
+ - **Two or more apps →** the worker **does not guess**: it prints an `ambiguous`
308
+ error naming each discovered `project → :port` and **stops**. Pin the one you
309
+ want and re-run: `export NANO_AGENTIC_URL=http://127.0.0.1:<port>` (or persist
310
+ `agenticUrl`).
311
+ - **Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) →** the
312
+ worker prints a one-line advisory naming `NANO_AGENTIC_URL` and **continues
313
+ doing real work** with the channel simply absent — discovery never fails the
314
+ worker's actual job.
315
+
316
+ Setting `NANO_AGENTIC_URL` (or persisted `agenticUrl`) **skips discovery** and is
317
+ used **verbatim** — so an explicit target always wins, and it's also how you
318
+ disambiguate when several apps are running. `NANO_AGENTIC=off` disables the
319
+ channel entirely and attempts **no** discovery.
320
+
292
321
  **Secure mode (opt-in).** For a shared/remote deployment, enrol the worker with an
293
322
  ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
294
323
  pattern the blackboard uses). Setting either switches the worker into SECURE mode,
package/c8ctl-plugin.js CHANGED
@@ -3834,6 +3834,11 @@ function resolveAgenticConfig() {
3834
3834
  ?? (cfg.agentic === false ? 'off' : cfg.agentic);
3835
3835
  if (/^(0|off|false|no)$/i.test(String(offSetting ?? ''))) return null;
3836
3836
 
3837
+ // An explicit agentic target (env NANO_AGENTIC_URL or persisted `agenticUrl`)
3838
+ // is used verbatim and short-circuits hub auto-discovery (#75). When neither is
3839
+ // set the URL below is the ENGINE base (nanoUrl → NANO_BASE_URL → default), off
3840
+ // which `resolveAgenticTarget()` discovers the embedded app's own /agentic port.
3841
+ const explicitUrl = !!(process.env.NANO_AGENTIC_URL || cfg.agenticUrl);
3837
3842
  const url = process.env.NANO_AGENTIC_URL
3838
3843
  || cfg.agenticUrl
3839
3844
  || cfg.nanoUrl
@@ -3853,11 +3858,230 @@ function resolveAgenticConfig() {
3853
3858
  // enrolment — require BOTH halves, fail closed if only one is present.
3854
3859
  if (token || credential) {
3855
3860
  if (!token || !credential) return null;
3856
- return { url, token, credential, bufferCapacity, secure: true };
3861
+ return { url, token, credential, bufferCapacity, secure: true, explicitUrl };
3857
3862
  }
3858
3863
 
3859
3864
  // LOCAL mode (default): well-known localhost token, no capability credential.
3860
- return { url, token: LOCAL_AGENTIC_TOKEN, credential: '', bufferCapacity, secure: false };
3865
+ return { url, token: LOCAL_AGENTIC_TOKEN, credential: '', bufferCapacity, secure: false, explicitUrl };
3866
+ }
3867
+
3868
+ // Total budget for zero-config hub auto-discovery (#75): the projects-API read
3869
+ // and every WS upgrade probe must degrade to a "not discoverable" advisory
3870
+ // within this window so discovery never meaningfully delays job polling.
3871
+ const AGENTIC_DISCOVERY_TIMEOUT_MS = 2_000;
3872
+
3873
+ /**
3874
+ * Is `hostname` a loopback address? Discovery reads the engine's projects API
3875
+ * and then probes `127.0.0.1:<advertised port>`, so it must only run against a
3876
+ * loopback engine — otherwise a remote engine's response would steer a local
3877
+ * port probe, breaking the loopback-only guarantee (#75/#76). Accepts
3878
+ * `localhost`, the IPv4 loopback block `127.0.0.0/8`, and IPv6 `::1` (with or
3879
+ * without URL brackets); case-insensitive and trimmed.
3880
+ *
3881
+ * @param {string} hostname a URL hostname (e.g. `127.0.0.1`, `localhost`, `[::1]`)
3882
+ * @returns {boolean}
3883
+ */
3884
+ function isLoopbackHost(hostname) {
3885
+ const h = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
3886
+ if (!h) return false;
3887
+ if (h === 'localhost' || h === '::1') return true;
3888
+ return /^127(?:\.\d{1,3}){3}$/.test(h);
3889
+ }
3890
+
3891
+ /**
3892
+ * Normalise the engine's `GET /console/api/projects` payload into the running
3893
+ * embedded apps that advertise an agentic UI port. Accepts the shapes the
3894
+ * console may serve — a keyed map (`{ "Nano_Workforce": { appUi } }`), a bare
3895
+ * array (`[{ name, appUi }]`), or a wrapped array (`{ projects: [...] }`) — and
3896
+ * keeps only apps with `appUi.enabled === true` and a positive integer
3897
+ * `appUi.port`. Anything else (a Camunda engine, an API-only gateway, malformed
3898
+ * JSON) yields `[]`.
3899
+ *
3900
+ * @param {unknown} projects the parsed projects-API body
3901
+ * @returns {Array<{ project: string, port: number, label?: string }>}
3902
+ */
3903
+ function normalizeProjectApps(projects) {
3904
+ if (!projects || typeof projects !== 'object') return [];
3905
+ let entries;
3906
+ if (Array.isArray(projects)) {
3907
+ entries = projects.map((p) => [p?.name ?? p?.project ?? p?.id, p]);
3908
+ } else if (Array.isArray(projects.projects)) {
3909
+ entries = projects.projects.map((p) => [p?.name ?? p?.project ?? p?.id, p]);
3910
+ } else {
3911
+ entries = Object.entries(projects);
3912
+ }
3913
+ const apps = [];
3914
+ for (const [name, proj] of entries) {
3915
+ const ui = proj?.appUi;
3916
+ if (ui && ui.enabled === true && Number.isInteger(ui.port) && ui.port > 0) {
3917
+ apps.push({
3918
+ project: String(name ?? ui.label ?? ui.port),
3919
+ port: ui.port,
3920
+ label: ui.label,
3921
+ });
3922
+ }
3923
+ }
3924
+ return apps;
3925
+ }
3926
+
3927
+ /**
3928
+ * Probe whether an embedded app's own loopback `/agentic` endpoint answers a
3929
+ * WebSocket upgrade. Connects to `ws://127.0.0.1:<port>/agentic?token=…` and
3930
+ * resolves `true` only if the socket opens within `timeoutMs`; a refused
3931
+ * connection, the console proxy's deliberate `501`, a `404`, or a timeout all
3932
+ * resolve `false`. Loopback-only and self-cleaning — the probe socket is closed
3933
+ * as soon as the outcome is known. Never throws.
3934
+ *
3935
+ * @param {number} port the app's direct loopback port (`appUi.port`)
3936
+ * @param {{ token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
3937
+ * @returns {Promise<boolean>}
3938
+ */
3939
+ function probeAgenticChannel(port, {
3940
+ token = LOCAL_AGENTIC_TOKEN,
3941
+ WebSocketImpl = globalThis.WebSocket,
3942
+ timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
3943
+ } = {}) {
3944
+ if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
3945
+ const url = `ws://127.0.0.1:${port}/agentic?token=${encodeURIComponent(token)}`;
3946
+ return new Promise((resolve) => {
3947
+ let done = false;
3948
+ let ws;
3949
+ const finish = (ok) => {
3950
+ if (done) return;
3951
+ done = true;
3952
+ clearTimeout(timer);
3953
+ try { ws?.close(); } catch { /* best-effort cleanup */ }
3954
+ resolve(ok);
3955
+ };
3956
+ const timer = setTimeout(() => finish(false), timeoutMs);
3957
+ try {
3958
+ ws = new WebSocketImpl(url);
3959
+ ws.onopen = () => finish(true);
3960
+ ws.onerror = () => finish(false);
3961
+ ws.onclose = () => finish(false);
3962
+ } catch {
3963
+ finish(false);
3964
+ }
3965
+ });
3966
+ }
3967
+
3968
+ /**
3969
+ * Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
3970
+ * URL (#75). Reads `GET <engine>/console/api/projects`, keeps the apps that
3971
+ * advertise an agentic UI port, and WS-probes each app's direct loopback
3972
+ * `/agentic` to confirm the channel is actually served there (bypassing the
3973
+ * WS-incapable console proxy). Loopback-only (the engine host itself must be
3974
+ * loopback, since the response steers a local port probe), enforces a single
3975
+ * shared time budget across the fetch + probes, and is fail-open: any error —
3976
+ * not a nano engine (Camunda), a non-loopback engine, network failure,
3977
+ * malformed body, or an overall timeout — degrades to `[]` so the worker's real
3978
+ * job is never blocked.
3979
+ *
3980
+ * @param {string} engineBaseUrl the engine base URL (e.g. `http://localhost:8080`)
3981
+ * @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
3982
+ * @returns {Promise<Array<{ project: string, port: number, label?: string }>>}
3983
+ */
3984
+ async function discoverAgenticHubs(engineBaseUrl, {
3985
+ token = LOCAL_AGENTIC_TOKEN,
3986
+ fetchImpl = globalThis.fetch,
3987
+ wsProbe = probeAgenticChannel,
3988
+ timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
3989
+ } = {}) {
3990
+ if (typeof fetchImpl !== 'function' || typeof engineBaseUrl !== 'string' || !engineBaseUrl.trim()) {
3991
+ return [];
3992
+ }
3993
+ const base = engineBaseUrl.replace(/\/+$/, '');
3994
+ // Loopback-only: discovery probes 127.0.0.1:<port> using a port advertised by
3995
+ // the engine's projects API, so a non-loopback (remote) engine could steer a
3996
+ // local port probe. Refuse discovery unless the engine host is loopback (#76).
3997
+ let host;
3998
+ try {
3999
+ host = new URL(base).hostname;
4000
+ } catch {
4001
+ return [];
4002
+ }
4003
+ if (!isLoopbackHost(host)) return [];
4004
+ // Single discovery budget: the projects fetch and the WS probes share ONE
4005
+ // deadline, so total discovery can't approach 2× timeoutMs (the fetch could
4006
+ // consume ~timeoutMs and then each probe was previously given a fresh full
4007
+ // budget). Probes get only the time left after the fetch (#76).
4008
+ const deadline = Date.now() + timeoutMs;
4009
+ let projects;
4010
+ const controller = new AbortController();
4011
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4012
+ try {
4013
+ const res = await fetchImpl(`${base}/console/api/projects`, { signal: controller.signal });
4014
+ if (!res || !res.ok) return [];
4015
+ projects = await res.json();
4016
+ } catch {
4017
+ return [];
4018
+ } finally {
4019
+ clearTimeout(timer);
4020
+ }
4021
+ const apps = normalizeProjectApps(projects);
4022
+ if (apps.length === 0) return [];
4023
+ const remainingMs = deadline - Date.now();
4024
+ if (remainingMs <= 0) return [];
4025
+ // Probe candidate ports concurrently within the remaining shared budget.
4026
+ const settled = await Promise.all(apps.map(async (app) => {
4027
+ try {
4028
+ return (await wsProbe(app.port, { token, timeoutMs: remainingMs })) ? app : null;
4029
+ } catch {
4030
+ return null;
4031
+ }
4032
+ }));
4033
+ return settled.filter(Boolean);
4034
+ }
4035
+
4036
+ /**
4037
+ * Resolve the final agentic-channel target for a worker, running zero-config hub
4038
+ * auto-discovery when no explicit target is configured (#75). Layered on top of
4039
+ * {@link resolveAgenticConfig}:
4040
+ *
4041
+ * - `{ status: 'off' }` — visibility disabled (off-switch) or SECURE mode
4042
+ * half-configured. No discovery attempted.
4043
+ * - `{ status: 'connect', config }` — a target to connect to. Either the
4044
+ * explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
4045
+ * single discovered app's direct `ws://127.0.0.1:<port>/agentic` loopback.
4046
+ * - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
4047
+ * channel. Hard stop for the worker: it must not silently pick one.
4048
+ * - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
4049
+ * no projects API / not a nano engine, or discovery error/timeout). The
4050
+ * worker continues doing real work with no channel.
4051
+ *
4052
+ * @param {{ fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4053
+ * @returns {Promise<{ status: string, config?: object, message?: string, candidates?: Array }>}
4054
+ */
4055
+ async function resolveAgenticTarget(opts = {}) {
4056
+ const base = resolveAgenticConfig();
4057
+ if (!base) return { status: 'off' };
4058
+ // Explicit target wins verbatim and skips discovery entirely.
4059
+ if (base.explicitUrl) return { status: 'connect', config: base };
4060
+
4061
+ const hubs = await discoverAgenticHubs(base.url, { token: base.token, ...opts });
4062
+
4063
+ if (hubs.length === 1) {
4064
+ const { project, port } = hubs[0];
4065
+ return {
4066
+ status: 'connect',
4067
+ config: { ...base, url: `http://127.0.0.1:${port}`, discovered: { project, port } },
4068
+ };
4069
+ }
4070
+ if (hubs.length > 1) {
4071
+ const list = hubs.map((h) => `${h.project} → :${h.port}`).join(', ');
4072
+ return {
4073
+ status: 'ambiguous',
4074
+ candidates: hubs,
4075
+ message: `multiple embedded apps expose an agentic channel (${list}); refusing to guess. `
4076
+ + 'Disambiguate by setting NANO_AGENTIC_URL=http://127.0.0.1:<port> (or persisted agenticUrl) to the one you want.',
4077
+ };
4078
+ }
4079
+ return {
4080
+ status: 'advisory',
4081
+ message: `agentic visibility was not discoverable at ${base.url} — the embedded app port could `
4082
+ + 'not be found (not a nano engine, or its console projects API is absent). Set '
4083
+ + 'NANO_AGENTIC_URL=http://127.0.0.1:<appUi.port> to enable the visibility channel. Continuing without it.',
4084
+ };
3861
4085
  }
3862
4086
 
3863
4087
  /**
@@ -4184,7 +4408,26 @@ async function workAgent(req, flags) {
4184
4408
  // worker joins with the well-known localhost token and no credential; SECURE
4185
4409
  // mode (NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL) sends a real ADR 0028
4186
4410
  // identity + capability; NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4187
- const agenticCfg = resolveAgenticConfig();
4411
+ const agenticTarget = await resolveAgenticTarget({ logger });
4412
+ let agenticCfg = null;
4413
+ switch (agenticTarget.status) {
4414
+ case 'connect':
4415
+ agenticCfg = agenticTarget.config;
4416
+ break;
4417
+ case 'ambiguous':
4418
+ // The operator ran with visibility on-by-default but the hub is
4419
+ // unresolved — a misconfiguration to fix, not to guess through. Hard stop.
4420
+ logger.error(` agentic visibility is ambiguous: ${agenticTarget.message}`);
4421
+ process.exit(1);
4422
+ break;
4423
+ case 'advisory':
4424
+ logger.info(` agentic channel: ${agenticTarget.message}`);
4425
+ break;
4426
+ case 'off':
4427
+ default:
4428
+ logger.info(' agentic channel: disabled — either the off-switch is set (NANO_AGENTIC=off or persisted agentic:false), or SECURE mode is half-configured (set BOTH NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL). Clear the off-switch to use default LOCAL visibility.');
4429
+ break;
4430
+ }
4188
4431
  if (agenticCfg) {
4189
4432
  try {
4190
4433
  workChannel = await createWorkChannel({
@@ -4204,6 +4447,9 @@ async function workAgent(req, flags) {
4204
4447
  });
4205
4448
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
4206
4449
  const mode = agenticCfg.secure ? 'secure' : 'local';
4450
+ if (agenticCfg.discovered) {
4451
+ logger.info(` agentic channel: auto-discovered ${agenticCfg.discovered.project} on the embedded app port :${agenticCfg.discovered.port} (bypassing the WS-incapable console proxy).`);
4452
+ }
4207
4453
  logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
4208
4454
  } catch (err) {
4209
4455
  // Never let a channel failure stop the worker from doing its actual job.
@@ -4227,8 +4473,6 @@ async function workAgent(req, flags) {
4227
4473
  logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
4228
4474
  }
4229
4475
  }
4230
- } else {
4231
- logger.info(' agentic channel: disabled — either the off-switch is set (NANO_AGENTIC=off or persisted agentic:false), or SECURE mode is half-configured (set BOTH NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL). Clear the off-switch to use default LOCAL visibility.');
4232
4476
  }
4233
4477
 
4234
4478
  // C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
@@ -7604,6 +7848,7 @@ export { resolveBinary, findBinary, launcherEnvMarkers };
7604
7848
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
7605
7849
  export { buildNpmInvocation };
7606
7850
  export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
7851
+ export { resolveAgenticTarget, discoverAgenticHubs, probeAgenticChannel, normalizeProjectApps, isLoopbackHost };
7607
7852
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
7608
7853
  export {
7609
7854
  webConsoleUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.34.0",
3
+ "version": "1.35.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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.34.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.34.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.34.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.34.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.34.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.34.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.34.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.0"
67
67
  }
68
68
  }