@nanobpm/nano-workforce 0.68.0 → 0.69.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.69.0](https://github.com/nanobpm/nano-workforce/compare/v0.68.0...v0.69.0) (2026-08-15)
2
+
3
+
4
+ ### Features
5
+
6
+ * **agentic:** enable nwf for a remote fleet — loopback-guard the LOCAL token + document network posture ([#224](https://github.com/nanobpm/nano-workforce/issues/224)) ([#228](https://github.com/nanobpm/nano-workforce/issues/228)) ([73f6170](https://github.com/nanobpm/nano-workforce/commit/73f6170ff4c0df0ad6c4eb2a2e6e9b3df5d1e343)), closes [nano-ide#235](https://github.com/nano-ide/issues/235) [nano-ide#235](https://github.com/nano-ide/issues/235)
7
+
1
8
  # [0.68.0](https://github.com/nanobpm/nano-workforce/compare/v0.67.0...v0.68.0) (2026-08-14)
2
9
 
3
10
 
package/README.md CHANGED
@@ -263,6 +263,70 @@ active epic already targets the same custom base. See
263
263
  | `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
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
+ | `NANO_PR_PUBLIC_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. Falls back to `NANO_PR_BASE_URL`, then `http://localhost:3000`. 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` |
268
+
269
+ ### Fleet networking (remote workers)
270
+
271
+ `nano-workforce` can drive a **distributed worker fleet** — `senior:*` agents running on other LAN
272
+ machines. Two app surfaces must be reachable from those off-box workers:
273
+
274
+ - The **capability hooks** — `/app/api/hooks/abandon` and `/app/api/hooks/blackboard`. Every
275
+ side-effecting agent is handed an unguessable per-run capability URL in its prompt and `curl`s it
276
+ before each irreversible action (an unknown token is a `404`). A remote worker can only reach these
277
+ if (a) the app's HTTP server is bound so off-box hosts can connect, and (b) the base URL baked into
278
+ that prompt resolves from the worker's host — hence **`NANO_PR_PUBLIC_BASE_URL` must be the app's
279
+ LAN address, not `localhost`**.
280
+ - The **agentic visibility channel** — `/agentic` (WebSocket). In on-by-default **LOCAL mode** it is
281
+ gated only by a *well-known, non-secret* localhost token, so it is enforced **loopback-only**: an
282
+ off-box peer is refused — as is a **reverse-proxied** peer (a connection carrying an
283
+ `X-Forwarded-For`/`Forwarded`/`X-Real-IP` header is refused even over loopback, so forwarding
284
+ `/agentic` through the console proxy cannot smuggle the well-known token off-box). To give remote
285
+ workers visibility, run the channel in **secure mode** by setting `NANO_AGENTIC_SECRET`. (Fleet
286
+ coordination itself does not depend on this channel — it is visibility only.)
287
+
288
+ #### Bind the HTTP server
289
+
290
+ The capability hooks are only reachable off-box if the app's HTTP server binds to a routable
291
+ interface. The declarative, per-app control is an **app-manifest** setting (loopback by default,
292
+ opt-in to all interfaces):
293
+
294
+ ```jsonc
295
+ // nano.app.json
296
+ { "network": { "bind": "all" } } // 0.0.0.0 / :: — expose to the LAN for a remote fleet
297
+ ```
298
+
299
+ > **Status:** this manifest key is delivered by the Urban runtime in
300
+ > [`nanobpm/nano-ide#235`](https://github.com/nanobpm/nano-ide/issues/235) (add the field to the app
301
+ > schema + plumb the bind host to the node adapter). Until that lands, the runtime binds to all
302
+ > interfaces by default (Node's `listen(port)` default), so a fleet already reaches the hooks — but
303
+ > once the parallel *loopback-by-default* change ships, set `"network": { "bind": "all" }` here to
304
+ > keep nwf reachable. This repo is ready for that flip; nwf already enforces the LOCAL-token
305
+ > loopback-only guard so binding wide never exposes the well-known agentic token off-box.
306
+
307
+ #### Choose a path: direct LAN bind vs console proxy
308
+
309
+ nwf composes with two deployment topologies — pick one and point the fleet at it:
310
+
311
+ | Path | When | Fleet uses |
312
+ |---|---|---|
313
+ | **Direct LAN bind** | nwf run standalone for a fleet | Bind the server wide (above) and set `NANO_PR_PUBLIC_BASE_URL=http://<app-lan-host>:3000`. Workers `curl` the hooks directly on the app's LAN address. |
314
+ | **Console reverse-proxy** | nwf embedded behind the nano console at `/console/app-view/Workforce` | Leave the app bound to loopback and set `NANO_PR_PUBLIC_BASE_URL` to the console's public origin + the app-view prefix, so `/app/api/hooks/*` resolves through the proxy. Workers reach the hooks via the console. |
315
+
316
+ Either way the capability URL in each agent's prompt (`${NANO_PR_PUBLIC_BASE_URL}/app/api/hooks/…`)
317
+ must resolve from the worker's host. **Verify** from a fleet host before relying on it:
318
+
319
+ ```sh
320
+ # From a remote LAN worker, against the base URL seeded into agent prompts.
321
+ # A live run returns { "prKey": "...", "status": "...", "abandoned": false }; -f exits non-zero on 404.
322
+ curl -fsS "${NANO_PR_PUBLIC_BASE_URL}/app/api/hooks/abandon?token=<per-run-token>"
323
+ ```
324
+
325
+ If the `curl` cannot reach the host, off-box agents will (correctly, per the abort contract) treat the
326
+ run as abandoned and stop — the exact failure behind
327
+ [`jwulf/c8ctl-plugin-nano#76`](https://github.com/jwulf/c8ctl-plugin-nano/issues/76). Fix it by
328
+ binding wide + setting a routable `NANO_PR_PUBLIC_BASE_URL`, or by fronting nwf with the console proxy.
329
+
266
330
 
267
331
  ### Purge
268
332
 
@@ -5,11 +5,19 @@
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";
8
9
  import { createLogger } from "@nanobpm/urban/runtime";
9
10
  import { WebSocket } from "ws";
10
11
  import { assert, assertEquals } from "#test-assert";
11
12
  import { noopLog } from "../../test/log.ts";
12
- import { type AgenticChannelHandle, LOCAL_AGENTIC_TOKEN, mountAgenticChannel } from "./channel.ts";
13
+ import {
14
+ type AgenticChannelHandle,
15
+ isForwardedConnection,
16
+ isLoopbackRemote,
17
+ LOCAL_AGENTIC_TOKEN,
18
+ loopbackOnly,
19
+ mountAgenticChannel,
20
+ } from "./channel.ts";
13
21
  import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
14
22
 
15
23
  const SECRET = "test-agentic-secret";
@@ -359,3 +367,79 @@ test("LOCAL mode does NOT warn when the server is bound to loopback", async (t)
359
367
  const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
360
368
  assert(!warned, "a loopback-bound LOCAL channel is the expected safe case and must not warn");
361
369
  });
370
+
371
+ // --- Loopback-only enforcement of the LOCAL well-known token (issue #224 / nano-ide#235) ---
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);
393
+
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);
436
+
437
+ const proxied = guarded({
438
+ token: LOCAL_AGENTIC_TOKEN,
439
+ remote: "127.0.0.1",
440
+ headers: { "x-forwarded-for": "203.0.113.7" },
441
+ });
442
+ assertEquals((proxied as { ok: boolean; code?: number }).ok, false);
443
+ assertEquals((proxied as { code?: number }).code, AUTH_UNAUTHORIZED);
444
+ assertEquals(baseCalls, 0);
445
+ });
@@ -17,6 +17,8 @@ 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,
20
22
  sharedSecretAuthenticator,
21
23
  WebSocketChannelTransport,
22
24
  } from "@nanobpm/agentic/channel";
@@ -53,6 +55,83 @@ function isLoopbackBind(addr: string | AddressInfo | null): boolean {
53
55
  return host === "::1" || host === "::ffff:127.0.0.1" || host.startsWith("127.");
54
56
  }
55
57
 
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
+ }
134
+
56
135
  export interface MountAgenticChannelOptions {
57
136
  /** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
58
137
  readonly server: Server;
@@ -116,39 +195,44 @@ export async function mountAgenticChannel(
116
195
  }
117
196
 
118
197
  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 });
119
203
  const hub = new AgenticHub({
120
204
  transport,
121
205
  // Secure mode: a valid identity token PLUS a required capability credential upgrades; either
122
206
  // missing/invalid is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an
123
- // Authenticator. LOCAL mode: token-only (the well-known localhost token), no credential required.
124
- authenticator: sharedSecretAuthenticator({ secret, requireCredential: secure }),
207
+ // Authenticator. LOCAL mode: token-only (the well-known localhost token), loopback peers only.
208
+ authenticator: secure ? baseAuthenticator : loopbackOnly(baseAuthenticator),
125
209
  onError: (err, connectionId) =>
126
210
  log.warn("agentic hub error", { connectionId, err: String(err) }),
127
211
  });
128
212
  // Share the app's port: the transport rode the existing server, so it is already listening.
129
213
  await transport.ready();
130
214
 
131
- // LOCAL mode gates only on the well-known localhost token, so it is safe ONLY while the server is
132
- // bound to loopback. The channel rides the app's server and does not own its bind address, so it
133
- // cannot enforce this but if the server is exposed on a wildcard/public interface, the token is
134
- // reachable off-box; warn loudly so an operator either binds to loopback or switches to secure mode.
135
- // A `null` address (server not listening yet) is unverifiable — warn rather than silently skipping
136
- // the exposure check, since the bind could later resolve to a public interface.
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.
137
220
  if (!secure) {
138
221
  const addr = server.address();
139
222
  if (addr === null) {
140
223
  log.warn(
141
224
  "agentic channel is in LOCAL mode but the server bind address could not be verified " +
142
- "(the server is not listening yet) — the well-known LOCAL_AGENTIC_TOKEN cannot be " +
143
- "confirmed loopback-only. Mount the channel after the server is listening, set " +
144
- "NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
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.",
145
228
  { mode: "local", bind: null },
146
229
  );
147
230
  } else if (!isLoopbackBind(addr)) {
148
231
  log.warn(
149
- "agentic channel is in LOCAL mode but the server is not bound to loopback — the well-known " +
150
- "LOCAL_AGENTIC_TOKEN is reachable from other hosts. Set NANO_AGENTIC_SECRET for secure " +
151
- "mode, or bind the server to 127.0.0.1.",
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.",
152
236
  { mode: "local", bind: typeof addr === "object" ? addr.address : String(addr) },
153
237
  );
154
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
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",