@nanobpm/nano-workforce 0.85.0 → 0.85.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.85.2](https://github.com/nanobpm/nano-workforce/compare/v0.85.1...v0.85.2) (2026-08-18)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **agentic:** open LOCAL channel on trusted LAN, drop unverified capability credential ([#278](https://github.com/nanobpm/nano-workforce/issues/278)) ([dc1e37a](https://github.com/nanobpm/nano-workforce/commit/dc1e37a8771beeb18e7e66b36bcfaffa498ed509))
7
+
8
+ ## [0.85.1](https://github.com/nanobpm/nano-workforce/compare/v0.85.0...v0.85.1) (2026-08-18)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **deps:** bump @nanobpm/urban to ^0.55.0 ([#276](https://github.com/nanobpm/nano-workforce/issues/276)) ([08fe794](https://github.com/nanobpm/nano-workforce/commit/08fe794280c32e5a63cc4fd5a166dd4e35c1b8e8)), closes [276/#277](https://github.com/nanobpm/nano-workforce/issues/277)
14
+
1
15
  # [0.85.0](https://github.com/nanobpm/nano-workforce/compare/v0.84.0...v0.85.0) (2026-08-17)
2
16
 
3
17
 
package/README.md CHANGED
@@ -264,7 +264,7 @@ active epic already targets the same custom base. See
264
264
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | `PT20M` | ISO-8601 duration the loop waits for a fresh review before escalating a stalled review (timer arm of the `wait-review` gateway) |
265
265
  | `NANO_PR_REVIEW_NUDGE_MINUTES` | `5` | cooldown between the poller's automatic reviewer re-request nudges for one waiting PR (clamped 1–1440) |
266
266
  | `NANO_WORKFORCE_BASE_URL` | `http://localhost:3000` | externally-reachable base URL for the capability hooks (`/app/api/hooks/*`). Must resolve from **wherever the agent runs** — set it to the app's LAN address (or console-proxy URL) for a remote fleet. See [Fleet networking](#fleet-networking-remote-workers) |
267
- | `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): requires an ADR 0028 identity token + capability credential from every peer. Required to attach agentic visibility from **off-box** workers; unset = on-by-default **LOCAL mode** (well-known token, loopback peers only). Also accepts `NANO_PR_WEBHOOK_SECRET` |
267
+ | `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): every peer must present the **same** `NANO_AGENTIC_SECRET` value (set the identical env var on the server and every worker box Tab A → Slot A). Unset = on-by-default **LOCAL mode** — the well-known token is honoured from **any origin** (open on the trusted LAN, matching the engine's posture); exposure is governed by the server bind address, not a shared secret. Also accepts `NANO_PR_WEBHOOK_SECRET` |
268
268
 
269
269
  ### Fleet networking (remote workers)
270
270
 
@@ -280,12 +280,18 @@ machines. Two app surfaces must be reachable from those off-box workers:
280
280
  agent's prompt, so changing it later does **not** heal already-running instances — re-seed them to
281
281
  pick up the new base.
282
282
  - The **agentic visibility channel** — `/agentic` (WebSocket). In on-by-default **LOCAL mode** it is
283
- gated only by a *well-known, non-secret* localhost token, so it is enforced **loopback-only**: an
284
- off-box peer is refused as is a **reverse-proxied** peer (a connection carrying an
285
- `X-Forwarded-For`/`Forwarded`/`X-Real-IP` header is refused even over loopback, so forwarding
286
- `/agentic` through the console proxy cannot smuggle the well-known token off-box). To give remote
287
- workers visibility, run the channel in **secure mode** by setting `NANO_AGENTIC_SECRET`. (Fleet
288
- coordination itself does not depend on this channel it is visibility only.)
283
+ gated only by a *well-known, non-secret* token that the hub honours from **any origin** — so an
284
+ off-box or reverse-proxied peer appears live with no shared secret, matching the open trusted-LAN
285
+ posture of the engine itself. Exposure therefore depends on network reachability — the app's
286
+ **bind address** (below) *and* any reverse proxy or port forwarding in front of it not on the
287
+ channel: bound to loopback it stays on-box *unless a same-host proxy forwards `/agentic`*, bound
288
+ wide it is reachable across the LAN. The startup WARN only reflects what the server can verify about
289
+ its own bind (it fires when it binds wide in LOCAL mode); it cannot see proxy-forwarded exposure. To
290
+ *authenticate* the channel
291
+ instead of leaving it open, run it in **secure mode** by setting `NANO_AGENTIC_SECRET` — the
292
+ **same** env var name and value on the server and on every worker box (the worker presents it as
293
+ its identity token, the hub verifies it against its own). (Fleet coordination itself does not
294
+ depend on this channel — it is visibility only.)
289
295
 
290
296
  #### Bind the HTTP server
291
297
 
@@ -300,11 +306,11 @@ opt-in to all interfaces):
300
306
 
301
307
  > **Status:** this manifest key is delivered by the Urban runtime in
302
308
  > [`nanobpm/nano-ide#235`](https://github.com/nanobpm/nano-ide/issues/235) (add the field to the app
303
- > schema + plumb the bind host to the node adapter). Until that lands, the runtime binds to all
304
- > interfaces by default (Node's `listen(port)` default), so a fleet already reaches the hooks but
305
- > once the parallel *loopback-by-default* change ships, set `"network": { "bind": "all" }` here to
306
- > keep nwf reachable. This repo is ready for that flip; nwf already enforces the LOCAL-token
307
- > loopback-only guard so binding wide never exposes the well-known agentic token off-box.
309
+ > schema + plumb the bind host to the node adapter). This repo already ships
310
+ > `"network": { "bind": "all" }` so a fleet reaches the hooks off-box. The agentic visibility channel
311
+ > is **open on the LAN by default** in LOCAL mode (governed by the bind address, with a startup WARN
312
+ > when bound wide), so no loopback-only guard stands between a wide bind and remote worker visibility —
313
+ > set `NANO_AGENTIC_SECRET` if you want to authenticate that channel instead.
308
314
 
309
315
  #### Choose a path: direct LAN bind vs console proxy
310
316
 
@@ -8,7 +8,7 @@ owns the keystone wiring; siblings H1/H3/H4 extend it **without touching the boo
8
8
  - **`channel.ts`** — `mountAgenticChannel(...)`. Stands up the `@nanobpm/agentic` WebSocket channel
9
9
  + `AgenticHub` on the app's **own** HTTP server (`app.httpServer`, same port as the pages and
10
10
  `/app/api/hooks/*` — no sidecar port), authenticates upgrades on `/agentic` (ADR 0028 identity
11
- token + a required capability credential, mirroring the blackboard hook's `?token=…` pattern), and
11
+ token; the capability credential was removed because it was accept-any friction), and
12
12
  mounts every discovered family. Returns a handle whose `teardown()` reverses everything.
13
13
  - **`registry.ts`** — the `AgenticFamilyRegistry` seam + the `AgenticFamily` / `AgenticContext`
14
14
  contracts. Mounts families on boot, tears them down in **reverse** order on shutdown.
@@ -46,9 +46,18 @@ H0 itself needs no migration.
46
46
 
47
47
  ## Configuration
48
48
 
49
- - `NANO_AGENTIC_SECRET` (falls back to `NANO_PR_WEBHOOK_SECRET`) the shared identity secret peers
50
- present as `?token=…`. When neither is set, the channel is **not mounted** (logged), so the app
51
- never exposes an unauthenticated upgrade.
49
+ The channel has three modes, selected by environment (see `main.ts`):
50
+
51
+ - **LOCAL mode (default)** — when neither `NANO_AGENTIC_SECRET` nor its fallback
52
+ `NANO_PR_WEBHOOK_SECRET` is set, the channel **is** mounted with a well-known token: no *shared
53
+ secret* (and no capability credential) is required — the well-known token is still presented, so
54
+ peers on the trusted LAN can connect off-box. Exposure depends on network
55
+ reachability (the server bind plus any reverse proxy/port forwarding); the startup `WARN` fires
56
+ when the hub sees a non-loopback bind **or** cannot verify the bind at all (`server.address()` is
57
+ `null`), so it won't flag exposure created by a same-host proxy forwarding `/agentic`.
58
+ - **SECURE mode** — set `NANO_AGENTIC_SECRET` (falls back to `NANO_PR_WEBHOOK_SECRET`) to require a
59
+ shared identity secret (the same value on the hub and every peer), presented as `?token=…`.
60
+ - **Disabled** — set `NANO_AGENTIC=off` (or `0`/`false`/`no`) to not mount the channel at all.
52
61
 
53
62
  ## Event-sourced transcripts (#251)
54
63
 
@@ -5,17 +5,13 @@
5
5
  // hub is visible via `inspect()`, families mount/tear-down through the seam, and shutdown is clean.
6
6
  import { type AddressInfo, createServer, type Server } from "node:http";
7
7
  import { test } from "node:test";
8
- import { AUTH_UNAUTHORIZED } from "@nanobpm/agentic/channel";
9
8
  import { createLogger } from "@nanobpm/urban/runtime";
10
9
  import { WebSocket } from "ws";
11
10
  import { assert, assertEquals } from "#test-assert";
12
11
  import { noopLog } from "../../test/log.ts";
13
12
  import {
14
13
  type AgenticChannelHandle,
15
- isForwardedConnection,
16
- isLoopbackRemote,
17
14
  LOCAL_AGENTIC_TOKEN,
18
- loopbackOnly,
19
15
  mountAgenticChannel,
20
16
  } from "./channel.ts";
21
17
  import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
@@ -103,7 +99,7 @@ async function mount(
103
99
  });
104
100
  }
105
101
 
106
- test("a valid identity token + capability credential upgrades on /agentic", async (t) => {
102
+ test("a valid identity token upgrades on /agentic (capability credential no longer required)", async (t) => {
107
103
  const { server, port } = await startHttp();
108
104
  const channel = await mount(port, server);
109
105
  t.after(async () => {
@@ -131,7 +127,7 @@ test("an invalid identity token is rejected (4401)", async (t) => {
131
127
  assertEquals(channel.hub.connectionCount, 0);
132
128
  });
133
129
 
134
- test("a missing capability credential is rejected (4403)", async (t) => {
130
+ test("SECURE mode upgrades with NO capability credential (credential requirement ripped out)", async (t) => {
135
131
  const { server, port } = await startHttp();
136
132
  const channel = await mount(port, server);
137
133
  t.after(async () => {
@@ -139,8 +135,12 @@ test("a missing capability credential is rejected (4403)", async (t) => {
139
135
  await closeServer(server);
140
136
  });
141
137
 
142
- const closedCode = await rejectionCode(port, `?token=${SECRET}`);
143
- assertEquals(closedCode, 4403);
138
+ // The capability credential was accept-any (never verified), so it is no longer required at all:
139
+ // a valid identity token alone upgrades. Only the token gates the channel.
140
+ const ws = await connect(port, `?token=${SECRET}`);
141
+ assertEquals(ws.readyState, WebSocket.OPEN);
142
+ assertEquals(channel.hub.connectionCount, 1);
143
+ ws.close();
144
144
  });
145
145
 
146
146
  test("normal HTTP routes keep working alongside the channel", async (t) => {
@@ -303,7 +303,7 @@ function capturingLog(): { log: ReturnType<typeof noopLog>; records: Array<{ lev
303
303
  return { log, records };
304
304
  }
305
305
 
306
- test("LOCAL mode warns when the server is bound to a non-loopback interface", async (t) => {
306
+ test("LOCAL mode warns the visibility channel is OPEN on the LAN on a non-loopback bind", async (t) => {
307
307
  const server = createServer((_req, res) => res.end());
308
308
  await new Promise<void>((resolve) => server.listen(0, "0.0.0.0", resolve));
309
309
  const { log, records } = capturingLog();
@@ -319,8 +319,8 @@ test("LOCAL mode warns when the server is bound to a non-loopback interface", as
319
319
  await closeServer(server);
320
320
  });
321
321
 
322
- const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
323
- assert(warned, "LOCAL mode on a non-loopback bind must warn that the well-known token is exposed");
322
+ const warned = records.some((r) => r.level === "warn" && r.msg.includes("OPEN on the LAN"));
323
+ assert(warned, "LOCAL mode on a non-loopback bind must warn that the visibility channel is exposed");
324
324
  });
325
325
 
326
326
  test("LOCAL mode warns when the server bind address is unverifiable (not listening)", async (t) => {
@@ -346,7 +346,7 @@ test("LOCAL mode warns when the server bind address is unverifiable (not listeni
346
346
  const warned = records.some(
347
347
  (r) => r.level === "warn" && r.msg.includes("bind address could not be verified"),
348
348
  );
349
- assert(warned, "LOCAL mode on an unbound server must warn that the well-known token is unverifiable");
349
+ assert(warned, "LOCAL mode on an unbound server must warn that exposure is unverifiable");
350
350
  });
351
351
 
352
352
  test("LOCAL mode does NOT warn when the server is bound to loopback", async (t) => {
@@ -364,82 +364,64 @@ test("LOCAL mode does NOT warn when the server is bound to loopback", async (t)
364
364
  await closeServer(server);
365
365
  });
366
366
 
367
- const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
367
+ const warned = records.some((r) => r.level === "warn" && r.msg.includes("OPEN on the LAN"));
368
368
  assert(!warned, "a loopback-bound LOCAL channel is the expected safe case and must not warn");
369
369
  });
370
370
 
371
- // --- Loopback-only enforcement of the LOCAL well-known token (issue #224 / nano-ide#235) ---
371
+ // --- LOCAL mode is trusted-network: the well-known token is honoured from any origin ---
372
372
  //
373
- // The LOCAL token is not a secret, so once the app binds to all interfaces (network.bind: "all") it
374
- // must never be honoured off-box. `isLoopbackRemote` vets the peer's origin; `loopbackOnly` wraps an
375
- // authenticator to refuse a non-loopback peer with 4401 while delegating loopback peers to the base.
376
-
377
- test("isLoopbackRemote accepts same-host peers and rejects everything else", () => {
378
- for (const ok of ["127.0.0.1", "127.0.0.5", "::1", "::ffff:127.0.0.1", "::ffff:127.1.2.3"]) {
379
- assert(isLoopbackRemote(ok), `${ok} should be loopback`);
380
- }
381
- for (const no of [undefined, "", "10.0.0.4", "192.168.1.20", "::ffff:10.0.0.4", "2001:db8::1", "0.0.0.0"]) {
382
- assert(!isLoopbackRemote(no), `${String(no)} should NOT be loopback`);
383
- }
384
- });
385
-
386
- test("loopbackOnly refuses a non-loopback peer with 4401 and never calls the base authenticator", () => {
387
- let baseCalls = 0;
388
- const base = () => {
389
- baseCalls++;
390
- return { ok: true as const, grant: { identity: "peer" } };
391
- };
392
- const guarded = loopbackOnly(base);
373
+ // The loopback-only enforcement (and its isLoopbackRemote/isForwardedConnection/loopbackOnly guard)
374
+ // was removed: LOCAL mode now matches the unauthenticated engine's trusted-LAN posture, so a
375
+ // non-loopback / reverse-proxied peer that would previously have been refused now upgrades. Exposure
376
+ // is governed by the server bind + the startup WARN, and SECURE mode (NANO_AGENTIC_SECRET) remains
377
+ // the opt-in for a shared secret (the same value on the hub and every peer).
393
378
 
394
- const remote = guarded({ token: LOCAL_AGENTIC_TOKEN, remote: "10.0.0.4" });
395
- assert(!("then" in remote), "authenticator result is synchronous here");
396
- assertEquals((remote as { ok: boolean; code?: number }).ok, false);
397
- assertEquals((remote as { code?: number }).code, AUTH_UNAUTHORIZED);
398
- assertEquals(baseCalls, 0);
399
- });
400
-
401
- test("loopbackOnly delegates a loopback peer to the base authenticator", () => {
402
- let baseCalls = 0;
403
- const base = () => {
404
- baseCalls++;
405
- return { ok: true as const, grant: { identity: "peer" } };
406
- };
407
- const guarded = loopbackOnly(base);
408
-
409
- const local = guarded({ token: LOCAL_AGENTIC_TOKEN, remote: "127.0.0.1" });
410
- assertEquals((local as { ok: boolean }).ok, true);
411
- assertEquals(baseCalls, 1);
412
- });
413
-
414
- // A reverse proxy that connects to the app over loopback makes an off-box client appear same-host to
415
- // `req.remote`. `isForwardedConnection` detects the relay from proxy-forwarding headers, so
416
- // `loopbackOnly` fails closed on a proxied peer even when `req.remote` itself is loopback.
417
-
418
- test("isForwardedConnection detects proxy-forwarding headers and ignores absent/empty ones", () => {
419
- assert(isForwardedConnection({ "x-forwarded-for": "10.0.0.4" }), "x-forwarded-for marks a relay");
420
- assert(isForwardedConnection({ forwarded: "for=10.0.0.4" }), "forwarded marks a relay");
421
- assert(isForwardedConnection({ "x-real-ip": "10.0.0.4" }), "x-real-ip marks a relay");
422
-
423
- assert(!isForwardedConnection(undefined), "no headers is a direct connection");
424
- assert(!isForwardedConnection({}), "empty headers is a direct connection");
425
- assert(!isForwardedConnection({ "x-forwarded-for": " " }), "whitespace value is treated as absent");
426
- assert(!isForwardedConnection({ "content-type": "application/json" }), "unrelated headers are ignored");
427
- });
428
-
429
- test("loopbackOnly refuses a reverse-proxied peer (loopback remote + forwarding header) with 4401", () => {
430
- let baseCalls = 0;
431
- const base = () => {
432
- baseCalls++;
433
- return { ok: true as const, grant: { identity: "peer" } };
434
- };
435
- const guarded = loopbackOnly(base);
379
+ test("LOCAL mode upgrades a reverse-proxied / forwarded peer (loopback-only guard removed)", async (t) => {
380
+ const { server, port } = await startHttp();
381
+ const channel = await mountAgenticChannel({
382
+ server,
383
+ secret: "",
384
+ secure: false,
385
+ data: undefined,
386
+ log: noopLog(),
387
+ });
388
+ t.after(async () => {
389
+ await channel.teardown();
390
+ await closeServer(server);
391
+ });
436
392
 
437
- const proxied = guarded({
438
- token: LOCAL_AGENTIC_TOKEN,
439
- remote: "127.0.0.1",
440
- headers: { "x-forwarded-for": "203.0.113.7" },
393
+ // A proxy-forwarding header used to fail LOCAL mode closed (a reverse-proxied peer was refused).
394
+ // With the guard gone the well-known token alone upgrades this forwarded/reverse-proxied
395
+ // connection (still to 127.0.0.1, now carrying an x-forwarded-for header).
396
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${LOCAL_AGENTIC_TOKEN}`, {
397
+ headers: { "x-forwarded-for": "10.0.0.4" },
398
+ });
399
+ await new Promise<void>((resolve, reject) => {
400
+ // Bound the wait so a stalled handshake fails fast instead of hanging CI forever.
401
+ const timer = setTimeout(() => {
402
+ // Terminate the still-pending socket so a stalled handshake can't keep the
403
+ // event loop alive after we reject.
404
+ ws.terminate();
405
+ reject(new Error("upgrade neither opened nor closed in time"));
406
+ }, REJECTION_TIMEOUT_MS);
407
+ timer.unref?.();
408
+ ws.on("open", () => {
409
+ clearTimeout(timer);
410
+ resolve();
411
+ });
412
+ // This test expects OPEN, so a transport error (which may arrive without a paired close)
413
+ // must reject rather than hang the promise forever.
414
+ ws.on("error", (err) => {
415
+ clearTimeout(timer);
416
+ ws.terminate();
417
+ reject(err);
418
+ });
419
+ ws.on("close", (code, reason) => {
420
+ clearTimeout(timer);
421
+ reject(new Error(`closed ${code}: ${reason.toString()}`));
422
+ });
441
423
  });
442
- assertEquals((proxied as { ok: boolean; code?: number }).ok, false);
443
- assertEquals((proxied as { code?: number }).code, AUTH_UNAUTHORIZED);
444
- assertEquals(baseCalls, 0);
424
+ assertEquals(ws.readyState, WebSocket.OPEN);
425
+ assertEquals(channel.hub.connectionCount, 1);
426
+ ws.close();
445
427
  });
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // This is the keystone of the agentic-visibility epic (#142): it stands up the WebSocket channel +
4
4
  // hub on the app's OWN HTTP server (same port as the pages and `/app/api/hooks/*` — no sidecar
5
- // port), authenticates each upgrade (ADR 0028 identity token + a capability credential, mirroring
6
- // the `?token=…` pattern the blackboard hook uses), and mounts every registered family module
5
+ // port), authenticates each upgrade (ADR 0028 identity token; the capability credential was removed
6
+ // as it was accept-any friction), and mounts every registered family module
7
7
  // through the {@link AgenticFamilyRegistry} seam.
8
8
  //
9
9
  // `main.ts` calls {@link mountAgenticChannel} once after `runFromEnv` and calls the returned
@@ -17,8 +17,6 @@ import type { Server } from "node:http";
17
17
  import type { AddressInfo } from "node:net";
18
18
  import {
19
19
  AgenticHub,
20
- AUTH_UNAUTHORIZED,
21
- type Authenticator,
22
20
  sharedSecretAuthenticator,
23
21
  WebSocketChannelTransport,
24
22
  } from "@nanobpm/agentic/channel";
@@ -30,12 +28,15 @@ import { AgenticFamilyRegistry } from "./registry.ts";
30
28
  export const AGENTIC_PATH = "/agentic";
31
29
 
32
30
  /**
33
- * The well-known identity token used in LOCAL mode (security opt-in). Nano is local-first: on a
34
- * developer's own machine the visibility channel is on by default with no credential friction, so
35
- * the hub and the `nano work` worker agree on this constant, well-known localhost token. It is NOT
36
- * a secret it only gates same-machine dev traffic. In secure mode (`secure: true` + a real
37
- * `NANO_AGENTIC_SECRET`) this constant is never used and a real ADR 0028 verifier applies. Keep in
38
- * lock-step with the worker constant in jwulf/c8ctl-plugin-nano (`c8ctl-plugin.js` LOCAL_AGENTIC_TOKEN).
31
+ * The well-known identity token used in LOCAL mode (the default). Nano is trusted-network-first: the
32
+ * visibility channel is on by default with no credential friction, so the hub and the `nano work`
33
+ * worker agree on this constant, well-known token. It is NOT a secret — LOCAL mode is designed for a
34
+ * trusted LAN and the token is honoured from ANY origin (the same trust posture the unauthenticated
35
+ * engine already relies on); a non-loopback bind therefore leaves the visibility channel OPEN on the
36
+ * LAN, which the startup WARN surfaces. Set `NANO_AGENTIC_SECRET` (SECURE mode) to require a shared
37
+ * secret (the same value on the hub and every peer) instead. Keep in lock-step with the worker
38
+ * constant in jwulf/c8ctl-plugin-nano
39
+ * (`c8ctl-plugin.js` LOCAL_AGENTIC_TOKEN).
39
40
  */
40
41
  export const LOCAL_AGENTIC_TOKEN = "nano-local";
41
42
 
@@ -55,96 +56,28 @@ function isLoopbackBind(addr: string | AddressInfo | null): boolean {
55
56
  return host === "::1" || host === "::ffff:127.0.0.1" || host.startsWith("127.");
56
57
  }
57
58
 
58
- /**
59
- * True if a peer's remote address (`req.remote`, i.e. `socket.remoteAddress`) is a same-host /
60
- * loopback peer. This is the per-connection counterpart to {@link isLoopbackBind}: while that vets
61
- * the *server's* bind, this vets the *client's* origin, so LOCAL mode can be honoured off a
62
- * wildcard/all-interfaces bind (`network.bind: "all"`, issue #224) yet still refuse the well-known
63
- * {@link LOCAL_AGENTIC_TOKEN} to anything but a same-machine peer. Loopback is `127.0.0.0/8`, `::1`,
64
- * or the IPv6-mapped IPv4 forms Node reports on a dual-stack listener (`::ffff:127.x`). An
65
- * absent/unparseable remote is NOT provably same-host, so it is treated as non-loopback
66
- * (fail-closed), matching the `isLoopbackBind(null) === false` posture.
67
- */
68
- export function isLoopbackRemote(remote: string | undefined): boolean {
69
- if (!remote) return false;
70
- return (
71
- remote === "::1" ||
72
- remote === "::ffff:127.0.0.1" ||
73
- remote.startsWith("127.") ||
74
- remote.startsWith("::ffff:127.")
75
- );
76
- }
77
-
78
- /**
79
- * Proxy-forwarding request headers. Their presence means the connection was relayed through a
80
- * reverse proxy, so `req.remote` is the *proxy's* address (typically loopback for an embedded/console
81
- * proxy) rather than the true client — a loopback `remote` no longer proves a same-host peer. A
82
- * genuine same-machine loopback peer connects directly and never carries one of these.
83
- */
84
- const FORWARDING_HEADERS: readonly string[] = ["x-forwarded-for", "forwarded", "x-real-ip"];
85
-
86
- /**
87
- * True if the handshake carries a proxy-forwarding header — i.e. the connection reached us through a
88
- * reverse proxy, so `req.remote` is the proxy, not the originating client. Used to fail LOCAL mode
89
- * closed: a relayed connection can present a loopback `remote` (the proxy) while the real client is
90
- * off-box, so the well-known token must never be honoured for it (see {@link loopbackOnly}). Headers
91
- * are lower-cased by the transport ({@link HandshakeRequest.headers}); an empty/whitespace value is
92
- * treated as absent.
93
- */
94
- export function isForwardedConnection(headers: Readonly<Record<string, string>> | undefined): boolean {
95
- if (!headers) return false;
96
- return FORWARDING_HEADERS.some((h) => {
97
- const value = headers[h];
98
- return typeof value === "string" && value.trim() !== "";
99
- });
100
- }
101
-
102
- /**
103
- * Wrap `base` so a peer is admitted ONLY from a direct, same-host loopback connection. LOCAL mode
104
- * gates purely on the well-known {@link LOCAL_AGENTIC_TOKEN}, which is not a secret — so once the app
105
- * is exposed on the LAN (`network.bind: "all"`, issue #224) that token must never be honoured
106
- * off-box. This enforces the invariant per-connection (any other peer is closed `4401`), independent
107
- * of the server's bind, closing the interplay the bind-to-all setting exposes (nano-ide#235).
108
- *
109
- * Two ways a peer can fail to be a same-host loopback client, both refused:
110
- * - a non-loopback `req.remote` (a direct off-box connection); or
111
- * - a proxy-forwarding header ({@link isForwardedConnection}) — the connection was relayed, so a
112
- * loopback `req.remote` is the *proxy*, not the client. Refusing any forwarded connection keeps
113
- * the guard robust even if `/agentic` is inadvertently reverse-proxied over loopback (the
114
- * embedded/console-proxy topology), where the off-box client would otherwise appear same-host.
115
- *
116
- * Note this guards ONLY the agentic visibility channel: the capability HTTP hooks
117
- * (`/app/api/hooks/*`) carry their own unguessable per-request tokens and stay reachable off-box
118
- * (including through the console proxy), which is what a remote fleet needs. To attach agentic
119
- * visibility from off-box — directly or via a proxy — run the channel in SECURE mode instead.
120
- */
121
- export function loopbackOnly(base: Authenticator): Authenticator {
122
- return (req) => {
123
- if (isForwardedConnection(req.headers) || !isLoopbackRemote(req.remote)) {
124
- return {
125
- ok: false,
126
- code: AUTH_UNAUTHORIZED,
127
- reason:
128
- "LOCAL-mode agentic channel is loopback-only and refuses reverse-proxied peers; use secure mode (NANO_AGENTIC_SECRET) for off-box or proxied peers",
129
- };
130
- }
131
- return base(req);
132
- };
133
- }
59
+ // LOCAL mode honours the well-known token from ANY origin: exposure depends on network reachability
60
+ // the server bind (loopback by default) plus any reverse proxy/port forwarding in front of it —
61
+ // matching the trusted-network posture the unauthenticated engine already relies on. The startup
62
+ // WARN below only reflects what the server can verify about its own bind (it fires on a wide bind,
63
+ // or when the bind can't be verified — `server.address()` is `null`); it cannot detect a same-host
64
+ // proxy forwarding /agentic. Set NANO_AGENTIC_SECRET (SECURE mode) to
65
+ // require a shared secret (the same value on the hub and every peer) instead.
134
66
 
135
67
  export interface MountAgenticChannelOptions {
136
68
  /** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
137
69
  readonly server: Server;
138
- /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. In LOCAL
139
- * mode (`secure: false`) this may be empty — the hub substitutes {@link LOCAL_AGENTIC_TOKEN}. */
70
+ /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…` (the same
71
+ * value on the hub and every peer). In LOCAL mode (`secure: false`) this may be empty — {@link
72
+ * mountAgenticChannel} substitutes {@link LOCAL_AGENTIC_TOKEN} via its local `secret` assignment. */
140
73
  readonly secret: string;
141
74
  /**
142
- * Security mode. Nano is local-first, so this defaults to `true` (strict) at the library level to
143
- * keep the fail-closed contract for any caller that doesn't opt in — but `main.ts` passes
75
+ * Security mode. Defaults to `true` (strict) at the library level — fail-closed for any caller
76
+ * that doesn't explicitly opt in — but `main.ts` passes
144
77
  * `secure: false` whenever no `NANO_AGENTIC_SECRET` is configured, mounting an on-by-default LOCAL
145
- * channel: a well-known localhost token ({@link LOCAL_AGENTIC_TOKEN}) and NO required capability
146
- * credential. Set `secure: true` (with a real secret) to require an ADR 0028 identity token AND a
147
- * capability credential on every upgrade.
78
+ * channel: a well-known token ({@link LOCAL_AGENTIC_TOKEN}) honoured from any origin, with NO
79
+ * capability credential. Set `secure: true` (with a real secret) to require an ADR 0028 identity
80
+ * token on every upgrade.
148
81
  */
149
82
  readonly secure?: boolean;
150
83
  /** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
@@ -195,44 +128,44 @@ export async function mountAgenticChannel(
195
128
  }
196
129
 
197
130
  const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
198
- // LOCAL mode gates only on the well-known localhost token, so it must be honoured only for a
199
- // same-machine peer: wrap the authenticator to refuse any non-loopback remote (see loopbackOnly).
200
- // Secure mode presents a real ADR 0028 identity token + capability credential, so it is safe from
201
- // any origin and needs no such guard.
202
- const baseAuthenticator = sharedSecretAuthenticator({ secret, requireCredential: secure });
131
+ // Identity-token-only auth in both modes: SECURE verifies the token against the real
132
+ // NANO_AGENTIC_SECRET; LOCAL accepts the well-known token from any origin. The capability
133
+ // credential is intentionally NOT required nano-workforce never verified it (accept-any), so it
134
+ // was pure configuration friction; a real ADR 0028 capability check can reintroduce it later by
135
+ // passing a verifier.
136
+ const authenticator = sharedSecretAuthenticator({ secret, requireCredential: false });
203
137
  const hub = new AgenticHub({
204
138
  transport,
205
- // Secure mode: a valid identity token PLUS a required capability credential upgrades; either
206
- // missing/invalid is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an
207
- // Authenticator. LOCAL mode: token-only (the well-known localhost token), loopback peers only.
208
- authenticator: secure ? baseAuthenticator : loopbackOnly(baseAuthenticator),
139
+ // A valid identity token upgrades (4401 on mismatch). SECURE mode's token is the real secret;
140
+ // LOCAL mode's is the well-known token, honoured from any origin (trusted-LAN posture).
141
+ authenticator,
209
142
  onError: (err, connectionId) =>
210
143
  log.warn("agentic hub error", { connectionId, err: String(err) }),
211
144
  });
212
145
  // Share the app's port: the transport rode the existing server, so it is already listening.
213
146
  await transport.ready();
214
147
 
215
- // LOCAL mode is now enforced loopback-only per connection (see loopbackOnly), so the well-known
216
- // token can never be honoured off-box even on a wildcard/all-interfaces bind. A non-loopback bind
217
- // is still worth surfacing though: it means off-box agentic peers are REFUSED, so a remote worker
218
- // fleet gets no visibility until the channel runs in secure mode. Warn so the operator makes the
219
- // deliberate choice. A `null` address (server not listening yet) is unverifiable — warn too.
148
+ // LOCAL mode honours the well-known token (not a secret) from any origin. On a loopback bind that
149
+ // only reaches same-host peers; on a non-loopback bind the visibility channel is OPEN on the LAN —
150
+ // anyone who can reach this port can attach and watch worker presence/terminals. That is the
151
+ // intended trusted-network posture, but it must never be silent, so warn. A `null` address (server
152
+ // not listening yet) is unverifiable — warn too, since it may be exposed.
220
153
  if (!secure) {
221
154
  const addr = server.address();
222
155
  if (addr === null) {
223
156
  log.warn(
224
157
  "agentic channel is in LOCAL mode but the server bind address could not be verified " +
225
- "(the server is not listening yet) — the loopback-only enforcement for the well-known " +
226
- "LOCAL_AGENTIC_TOKEN cannot be confirmed. Mount the channel after the server is listening, " +
227
- "set NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
158
+ "(the server is not listening yet) — if it is bound off-box the visibility channel is " +
159
+ "OPEN on the LAN with the well-known token. Mount the channel after the server is " +
160
+ "listening, set NANO_AGENTIC_SECRET to require a secret, or NANO_AGENTIC=off to disable.",
228
161
  { mode: "local", bind: null },
229
162
  );
230
163
  } else if (!isLoopbackBind(addr)) {
231
164
  log.warn(
232
- "agentic channel is in LOCAL mode but the server is not bound to loopback — off-box peers " +
233
- "are refused the channel (the well-known LOCAL_AGENTIC_TOKEN is enforced loopback-only), " +
234
- "so a remote worker fleet cannot attach visibility. Set NANO_AGENTIC_SECRET for secure " +
235
- "mode to serve remote peers, or bind the server to 127.0.0.1.",
165
+ "agentic channel is in LOCAL mode and the server is not bound to loopback — the visibility " +
166
+ "channel is OPEN on the LAN: any peer that can reach this port can attach with the " +
167
+ "well-known token and watch worker presence/terminals. Set NANO_AGENTIC_SECRET to require " +
168
+ "a secret, or NANO_AGENTIC=off to disable.",
236
169
  { mode: "local", bind: typeof addr === "object" ? addr.address : String(addr) },
237
170
  );
238
171
  }
package/main.ts CHANGED
@@ -47,11 +47,13 @@ const app = await runFromEnv({ engine, host, port: PORT, handleSignals: false })
47
47
  // shares the app port (no sidecar). This is the ONLY main.ts wiring for the whole epic — sibling
48
48
  // slices (H1/H3/H4) extend it by dropping a family module under `app/agentic/families/`, never here.
49
49
  //
50
- // Local-first (security opt-in): Nano is designed for local use, so the channel is ON BY DEFAULT.
51
- // - No secret configured -> LOCAL mode: well-known localhost token, no credential required, so a
52
- // `nano work` worker appears live with zero configuration.
53
- // - `NANO_AGENTIC_SECRET` (or `NANO_PR_WEBHOOK_SECRET`) set -> SECURE mode: ADR 0028 identity token
54
- // + capability credential required on every upgrade.
50
+ // Trusted-LAN by default (security opt-in): Nano runs on a trusted network, so the channel is ON BY DEFAULT.
51
+ // - No secret configured -> LOCAL mode: well-known token, no credential required, honoured from
52
+ // any origin, so a `nano work` worker appears live with zero configuration (trusted-LAN posture;
53
+ // a WARN surfaces the exposure on a non-loopback bind).
54
+ // - `NANO_AGENTIC_SECRET` (or `NANO_PR_WEBHOOK_SECRET`) set -> SECURE mode: a shared-secret ADR 0028
55
+ // identity token (the same value on the hub and every peer) required on every upgrade (no
56
+ // capability credential — that was accept-any friction).
55
57
  // - `NANO_AGENTIC=off` (or 0/false/no) -> disabled entirely.
56
58
  // `app.httpServer` is a `node:http` Server once started (undefined on hosts that don't surface one,
57
59
  // e.g. Deno).
@@ -73,9 +75,12 @@ if (httpServer instanceof Server) {
73
75
  });
74
76
  if (!secure) {
75
77
  app.log.info(
76
- "agentic channel mounted in LOCAL mode (on by default, token-only — a well-known localhost " +
77
- "token, no capability credential). Set NANO_AGENTIC_SECRET for secure mode, or " +
78
- "NANO_AGENTIC=off to disable.",
78
+ "agentic channel mounted in LOCAL mode (on by default, token-only — a well-known token, no " +
79
+ "capability credential). Honoured from any origin, so on a non-loopback bind it is reachable " +
80
+ "off-box on the trusted LAN (a WARN surfaces a provably wide bind; it cannot detect exposure " +
81
+ "created by a same-host reverse proxy forwarding /agentic while the app stays loopback-bound). " +
82
+ "Set NANO_AGENTIC_SECRET to require a shared secret (the same value on the hub and every peer), " +
83
+ "or NANO_AGENTIC=off to disable.",
79
84
  );
80
85
  }
81
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.85.0",
3
+ "version": "0.85.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@nanobpm/agentic": "^0.1.0",
56
- "@nanobpm/urban": "^0.54.0"
56
+ "@nanobpm/urban": "^0.55.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@biomejs/biome": "^2.4.11",
@@ -587,11 +587,15 @@ export function mountCockpit(host, opts = {}) {
587
587
  /**
588
588
  * Derive the channel WebSocket URL from the current origin (path `/agentic`).
589
589
  *
590
- * The agentic hub authenticates upgrades with `sharedSecretAuthenticator({ requireCredential: true })`,
591
- * so a bare `ws(s)://host/agentic` is rejected (4401/4403). When a `token` (and optional `capability`)
592
- * are supplied, they are appended as the `?token=…&capability=…` query the hub requires; without them
593
- * the default URL cannot authenticate and drill-in will be refused pass credentials (or an explicit
594
- * `relayUrl`) for secured deployments.
590
+ * The agentic hub authenticates upgrades with an identity token only
591
+ * (`sharedSecretAuthenticator({ requireCredential: false })`) no capability credential is required.
592
+ * In SECURE mode a bare `ws(s)://host/agentic` is rejected (4401) until a valid `token` is supplied;
593
+ * in LOCAL mode the hub still requires a `token` query param, but it is a well-known, non-secret
594
+ * value the hub accepts from any origin. Auth is token-only — a `capability` is legacy and ignored
595
+ * by the current hub (`requireCredential: false`); it is still appended to the query when supplied
596
+ * (`?token=…&capability=…`) for backward compatibility but plays no part in authenticating the
597
+ * upgrade. Without a token neither mode can authenticate and drill-in will be refused — pass a
598
+ * token (or an explicit `relayUrl`) for secured deployments.
595
599
  */
596
600
  function defaultRelayUrl(token, capability) {
597
601
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
package/renovate.json CHANGED
@@ -1,21 +1,19 @@
1
1
  {
2
2
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
3
  "extends": ["config:recommended"],
4
- "customManagers": [
5
- {
6
- "customType": "regex",
7
- "description": "Keep the @nanobpm/urban pin in deno.json's import map in sync with the npm registry (the npm manager only sees package.json, so without this deno.json would drift).",
8
- "fileMatch": ["^deno\\.jsonc?$"],
9
- "matchStrings": [
10
- ],
11
- "datasourceTemplate": "npm"
12
- }
13
- ],
14
4
  "packageRules": [
15
5
  {
16
- "description": "Land the package.json and deno.json bumps for @nanobpm/urban in a single PR so the two pins can never diverge.",
17
- "matchPackageNames": ["@nanobpm/urban"],
18
- "groupName": "@nanobpm/urban"
6
+ "description": "Auto-merge non-major @nanobpm/* updates once CI is green. These are first-party packages we publish from nano-ide; CI (typecheck + test) is the gate. For 0.x pins a caret range locks the minor, so Renovate's range bump is what actually pulls a new minor through; automerging it keeps nwf on the latest urban without manual PRs. platformAutomerge is off because main is unprotected, so Renovate waits for its own green branch status before merging.",
7
+ "matchPackageNames": ["/^@nanobpm//"],
8
+ "matchUpdateTypes": ["minor", "patch"],
9
+ "automerge": true,
10
+ "platformAutomerge": false
11
+ },
12
+ {
13
+ "description": "Major @nanobpm/* updates may be breaking - require a human to review and merge.",
14
+ "matchPackageNames": ["/^@nanobpm//"],
15
+ "matchUpdateTypes": ["major"],
16
+ "automerge": false
19
17
  }
20
18
  ]
21
19
  }