c8ctl-plugin-nano 1.33.1 → 1.34.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 (3) hide show
  1. package/README.md +25 -10
  2. package/c8ctl-plugin.js +87 -16
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -274,24 +274,39 @@ its agents' terminals to an operator's cockpit. This rides the app's agentic
274
274
  channel (ADR 0056), served **same-port** on the app's own HTTP base URL at path
275
275
  **`/agentic`** — not a sidecar, so there's no extra port to open.
276
276
 
277
- **Connecting.** Enrolment is opt-in: a worker only connects when it has both an
278
- ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
279
- pattern the blackboard uses). Point it at the app and hand it the two secrets via
280
- env (or persisted config); the channel URL defaults to the configured nano URL:
277
+ **Connecting on by default (local-first).** Nano is designed for local use, so
278
+ visibility is **on by default**. Run a worker against a local app and it appears
279
+ live with **zero configuration** it joins the channel with a well-known
280
+ localhost token in **LOCAL mode** (no credential). The worker presents this
281
+ token to whatever `NANO_AGENTIC_URL` you point it at; the same-machine
282
+ restriction is enforced by the **hub**, which only honours the well-known LOCAL
283
+ token for local/loopback connections:
281
284
 
282
285
  ```bash
283
- # Enrol this worker on the app's same-port /agentic channel
286
+ # LOCAL mode (default): appears live with no secrets
284
287
  export NANO_AGENTIC_URL=http://localhost:8080 # app base URL; channel is served at /agentic
288
+ c8ctl nano work reviewer
289
+ # agentic channel (local): announcing presence as ‹worker› on ws://localhost:8080/agentic
290
+ ```
291
+
292
+ **Secure mode (opt-in).** For a shared/remote deployment, enrol the worker with an
293
+ ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
294
+ pattern the blackboard uses). Setting either switches the worker into SECURE mode,
295
+ which requires **both** (fail closed if only one is set):
296
+
297
+ ```bash
298
+ # SECURE mode: real enrolment
299
+ export NANO_AGENTIC_URL=http://localhost:8080
285
300
  export NANO_AGENTIC_TOKEN=<identity-token> # ADR 0028 identity
286
301
  export NANO_AGENTIC_CREDENTIAL=<capability-cred> # capability credential
287
302
  c8ctl nano work reviewer
288
- # agentic channel: announcing presence as ‹worker› on ws://localhost:8080/agentic
303
+ # agentic channel (secure): announcing presence as ‹worker› on ws://localhost:8080/agentic
289
304
  ```
290
305
 
291
- Without both secrets the worker runs **exactly as before, off the channel** no
292
- visibility, no relay, nothing else changes. A valid identity + capability
293
- connects; an invalid identity is rejected (unauthorized) and a missing capability
294
- is rejected (forbidden).
306
+ To opt out entirely, set `NANO_AGENTIC=off` (or persisted `agentic: false`)the
307
+ worker then runs with **no visibility, no relay, nothing else changed**. In secure
308
+ mode a valid identity + capability connects; an invalid identity is rejected
309
+ (unauthorized) and a missing capability is rejected (forbidden).
295
310
 
296
311
  **How presence appears.** On connect the worker **announces** its identity, its
297
312
  `host`, and the set of `jobKeys` it is currently running, then **heartbeats** to
package/c8ctl-plugin.js CHANGED
@@ -135,6 +135,16 @@ const SUPERVISOR_STATE_FILE = 'supervisor.json';
135
135
  const PROCESSOS_DEFAULT_PORT = 8090;
136
136
  const DEFAULT_NANO_URL = 'http://localhost:8080';
137
137
 
138
+ // The well-known identity token used for LOCAL agentic visibility (security opt-in). Nano is
139
+ // local-first: on the operator's own machine a `nano work` worker joins the visibility channel with
140
+ // zero configuration, so it presents this constant, well-known localhost token — NOT a secret. The
141
+ // worker does not enforce any loopback restriction itself (it presents this token to whatever
142
+ // NANO_AGENTIC_URL is configured); same-machine gating is enforced by the hub, which only honours
143
+ // this well-known token for local/loopback connections. Kept in lock-step with the hub constant in
144
+ // nanobpm/nano-workforce (`app/agentic/channel.ts` LOCAL_AGENTIC_TOKEN). In secure mode (a real
145
+ // NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL) this is never used.
146
+ const LOCAL_AGENTIC_TOKEN = 'nano-local';
147
+
138
148
  // Passive update notifier (npm-style): refresh the latest published version
139
149
  // from the registry in a detached background process at most once per day, and
140
150
  // surface a one-line "update available" notice at most once per day. Never
@@ -161,6 +171,17 @@ function getLogger() {
161
171
  warn: console.warn,
162
172
  error: console.error,
163
173
  debug: () => {},
174
+ // Primary command output, written to stdout as-is (mirrors the c8ctl host
175
+ // logger's `output()`). Used for preformatted, non-structured content such
176
+ // as the supervisor status table, whose newlines must survive verbatim.
177
+ // Uses `process.stdout.write` (not `console.log`) so the content is emitted
178
+ // literally — `console.log` applies `util.format` (mangling stray `%`
179
+ // sequences) and would append its own newline; here we add exactly one
180
+ // trailing newline when the text lacks one.
181
+ output: (msg) => {
182
+ const s = typeof msg === 'string' ? msg : String(msg);
183
+ process.stdout.write(s.endsWith('\n') ? s : s + '\n');
184
+ },
164
185
  };
165
186
  }
166
187
 
@@ -1244,7 +1265,7 @@ function logsCluster(req) {
1244
1265
  const proc = spawn('tail', tailArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
1245
1266
  proc.on('error', (err) => {
1246
1267
  logger.error(`Failed to read logs: ${err.message}`);
1247
- logger.info(`Log files:\n ${files.join('\n ')}`);
1268
+ logger.output(`Log files:\n ${files.join('\n ')}`);
1248
1269
  });
1249
1270
  }
1250
1271
 
@@ -3789,30 +3810,54 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
3789
3810
  * HTTP base URL at `/agentic`; the identity token + capability credential follow
3790
3811
  * the blackboard's `?token=…` pattern.
3791
3812
  *
3813
+ * Local-first (security opt-in). Nano is designed for local use, so visibility is
3814
+ * ON BY DEFAULT:
3815
+ * - LOCAL mode (default): no credentials configured — the worker connects with
3816
+ * the well-known localhost token ({@link LOCAL_AGENTIC_TOKEN}) and no
3817
+ * capability credential, so it appears live with zero configuration (the hub's
3818
+ * matching LOCAL mode accepts it).
3819
+ * - SECURE mode: set NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL (or the
3820
+ * persisted `agenticToken`/`agenticCredential`) — an ADR 0028 identity token
3821
+ * AND a capability credential are then sent (enrolment). If only one is set the
3822
+ * config is incomplete and we stay off (fail closed), returning `null`.
3823
+ * - OFF: NANO_AGENTIC=off (or 0/false/no), or persisted `agentic:false`.
3824
+ *
3792
3825
  * Env wins over persisted config; the base URL falls back to the configured nano
3793
- * URL (the app's own port). A worker only connects when BOTH an identity token
3794
- * and a capability credential are present (enrolment) — absent either, it runs
3795
- * exactly as before, off the visibility page. Returns `null` when not enrolled.
3826
+ * URL (the app's own port). Returns `null` only when disabled or half-configured.
3796
3827
  *
3797
- * @returns {{ url: string, token: string, credential: string, bufferCapacity: number } | null}
3828
+ * @returns {{ url: string, token: string, credential: string, bufferCapacity: number, secure: boolean } | null}
3798
3829
  */
3799
3830
  function resolveAgenticConfig() {
3800
3831
  const cfg = readConfig();
3832
+ // Explicit off-switch (env wins). Lets an operator fully opt out of visibility.
3833
+ const offSetting = process.env.NANO_AGENTIC
3834
+ ?? (cfg.agentic === false ? 'off' : cfg.agentic);
3835
+ if (/^(0|off|false|no)$/i.test(String(offSetting ?? ''))) return null;
3836
+
3801
3837
  const url = process.env.NANO_AGENTIC_URL
3802
3838
  || cfg.agenticUrl
3803
3839
  || cfg.nanoUrl
3804
3840
  || process.env.NANO_BASE_URL
3805
3841
  || DEFAULT_NANO_URL;
3842
+ if (!url) return null;
3806
3843
  const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
3807
3844
  const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
3808
- if (!url || !token || !credential) return null;
3809
3845
  // Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
3810
3846
  // long expected outage can be given more headroom; resolveBufferCapacity
3811
3847
  // validates it to a positive integer and falls back to the client default.
3812
3848
  const bufferCapacity = resolveBufferCapacity(
3813
3849
  process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
3814
3850
  );
3815
- return { url, token, credential, bufferCapacity };
3851
+
3852
+ // SECURE mode: any explicit credential configured means the operator opted into
3853
+ // enrolment — require BOTH halves, fail closed if only one is present.
3854
+ if (token || credential) {
3855
+ if (!token || !credential) return null;
3856
+ return { url, token, credential, bufferCapacity, secure: true };
3857
+ }
3858
+
3859
+ // LOCAL mode (default): well-known localhost token, no capability credential.
3860
+ return { url, token: LOCAL_AGENTIC_TOKEN, credential: '', bufferCapacity, secure: false };
3816
3861
  }
3817
3862
 
3818
3863
  /**
@@ -4135,8 +4180,10 @@ async function workAgent(req, flags) {
4135
4180
  // accessors on `workChannel` (relay-lane sink + connect/disconnect/reconnect
4136
4181
  // lifecycle events) rather than opening their own connection.
4137
4182
  //
4138
- // Enrolment is opt-in: without an identity token + capability credential the
4139
- // worker runs exactly as before, off the channel (see resolveAgenticConfig).
4183
+ // Local-first (security opt-in): visibility is ON BY DEFAULT. In LOCAL mode the
4184
+ // worker joins with the well-known localhost token and no credential; SECURE
4185
+ // mode (NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL) sends a real ADR 0028
4186
+ // identity + capability; NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4140
4187
  const agenticCfg = resolveAgenticConfig();
4141
4188
  if (agenticCfg) {
4142
4189
  try {
@@ -4156,7 +4203,8 @@ async function workAgent(req, flags) {
4156
4203
  logger,
4157
4204
  });
4158
4205
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
4159
- logger.info(` agentic channel: announcing presence as ${workerName} on ${shown}`);
4206
+ const mode = agenticCfg.secure ? 'secure' : 'local';
4207
+ logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
4160
4208
  } catch (err) {
4161
4209
  // Never let a channel failure stop the worker from doing its actual job.
4162
4210
  workChannel = null;
@@ -4180,7 +4228,7 @@ async function workAgent(req, flags) {
4180
4228
  }
4181
4229
  }
4182
4230
  } else {
4183
- logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
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.');
4184
4232
  }
4185
4233
 
4186
4234
  // C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
@@ -5094,6 +5142,27 @@ function formatSupervisorStatus(status) {
5094
5142
  return lines.join('\n');
5095
5143
  }
5096
5144
 
5145
+ /**
5146
+ * Print a preformatted supervisor status table as primary command output.
5147
+ *
5148
+ * Preformatted, multi-line text MUST go through the logger's `output()`
5149
+ * channel, never `info()`. In `--output json` mode the c8ctl host logger wraps
5150
+ * an `info()` message in a JSON envelope (`{"status":"info","message":"…"}`),
5151
+ * which escapes every newline to a literal `\n` and collapses the aligned table
5152
+ * onto a single line — the exact breakage this guards against. `output()` writes
5153
+ * the content to stdout verbatim in every output mode (like `raw` command
5154
+ * output), so the table renders correctly regardless of mode. Falls back to
5155
+ * `info()` for a logger without `output()`, and to `console.log` if `logger`
5156
+ * is null/undefined or lacks `info()` (defensive; both the c8ctl host logger
5157
+ * and this plugin's fallback logger provide `output()`).
5158
+ */
5159
+ function printSupervisorStatus(logger, status) {
5160
+ const text = formatSupervisorStatus(status);
5161
+ if (logger && typeof logger.output === 'function') logger.output(text);
5162
+ else if (logger && typeof logger.info === 'function') logger.info(text);
5163
+ else console.log(text);
5164
+ }
5165
+
5097
5166
  function readSupervisorState() {
5098
5167
  const file = getSupervisorStateFile();
5099
5168
  if (!existsSync(file)) return null;
@@ -5727,7 +5796,7 @@ async function supervisorStatusCmd() {
5727
5796
  const res = await supervisorRequest({ op: 'status' }, { socketPath: getSupervisorSocketPath(), timeoutMs: 500, responseTimeoutMs: SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS });
5728
5797
  if (res && res.ok) {
5729
5798
  try { writeSupervisorState(stateFromStatus(res, getSupervisorSocketPath())); } catch { /* best effort */ }
5730
- logger.info(formatSupervisorStatus(res));
5799
+ printSupervisorStatus(logger, res);
5731
5800
  return;
5732
5801
  }
5733
5802
  } catch { /* no live daemon on the socket — genuinely down */ }
@@ -5743,13 +5812,13 @@ async function supervisorStatusCmd() {
5743
5812
  }
5744
5813
  try {
5745
5814
  const res = await supervisorRequest({ op: 'status' });
5746
- if (res.ok) { logger.info(formatSupervisorStatus(res)); return; }
5815
+ if (res.ok) { printSupervisorStatus(logger, res); return; }
5747
5816
  } catch { /* fall back to state file below */ }
5748
5817
  // Socket unreachable but pid alive — render from the last persisted state.
5749
- logger.info(formatSupervisorStatus({
5818
+ printSupervisorStatus(logger, {
5750
5819
  daemon: { pid: running.pid, startedAt: running.startedAt, socket: running.socket },
5751
5820
  workers: (running.workers || []).map((w) => summarizeSupervisorWorker(w)),
5752
- }));
5821
+ });
5753
5822
  }
5754
5823
 
5755
5824
  async function supervisorAddCmd(req, flags) {
@@ -5862,7 +5931,7 @@ function supervisorLogsCmd(req) {
5862
5931
  if (follow) logger.warn('`--follow` is not supported without `tail` on this platform; printing the current tail only.');
5863
5932
  try {
5864
5933
  const lines = readFileSync(file, 'utf-8').split('\n');
5865
- logger.info(lines.slice(-200).join('\n'));
5934
+ logger.output(lines.slice(-200).join('\n'));
5866
5935
  } catch (err) { logger.error(`Could not read ${file}: ${err.message}`); }
5867
5936
  });
5868
5937
  }
@@ -7534,6 +7603,7 @@ function parseProcessosRequest(args, flags) {
7534
7603
  export { resolveBinary, findBinary, launcherEnvMarkers };
7535
7604
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
7536
7605
  export { buildNpmInvocation };
7606
+ export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
7537
7607
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
7538
7608
  export {
7539
7609
  webConsoleUrl,
@@ -7624,6 +7694,7 @@ export {
7624
7694
  formatDuration,
7625
7695
  summarizeSupervisorWorker,
7626
7696
  formatSupervisorStatus,
7697
+ printSupervisorStatus,
7627
7698
  supervisorStatusSignature,
7628
7699
  supervisorJobCell,
7629
7700
  supervisorWorkerActivityFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.33.1",
3
+ "version": "1.34.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.33.1",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.1",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.1",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.1",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.1",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.1",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.1"
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"
67
67
  }
68
68
  }