@nanobpm/nano-workforce 0.85.1 → 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 +7 -0
- package/README.md +18 -12
- package/app/agentic/README.md +13 -4
- package/app/agentic/channel.test.ts +65 -83
- package/app/agentic/channel.ts +47 -114
- package/main.ts +13 -8
- package/package.json +1 -1
- package/pages/cockpit/mount.js +9 -5
- package/renovate.json +11 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [0.85.1](https://github.com/nanobpm/nano-workforce/compare/v0.85.0...v0.85.1) (2026-08-18)
|
|
2
9
|
|
|
3
10
|
|
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`):
|
|
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*
|
|
284
|
-
off-box
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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).
|
|
304
|
-
>
|
|
305
|
-
>
|
|
306
|
-
>
|
|
307
|
-
>
|
|
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
|
|
package/app/agentic/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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("
|
|
323
|
-
assert(warned, "LOCAL mode on a non-loopback bind must warn that the
|
|
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
|
|
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("
|
|
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
|
-
// ---
|
|
371
|
+
// --- LOCAL mode is trusted-network: the well-known token is honoured from any origin ---
|
|
372
372
|
//
|
|
373
|
-
// The
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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(
|
|
443
|
-
assertEquals(
|
|
444
|
-
|
|
424
|
+
assertEquals(ws.readyState, WebSocket.OPEN);
|
|
425
|
+
assertEquals(channel.hub.connectionCount, 1);
|
|
426
|
+
ws.close();
|
|
445
427
|
});
|
package/app/agentic/channel.ts
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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 (
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
139
|
-
* mode (`secure: false`) this may be empty —
|
|
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.
|
|
143
|
-
*
|
|
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
|
|
146
|
-
* credential. Set `secure: true` (with a real secret) to require an ADR 0028 identity
|
|
147
|
-
*
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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) —
|
|
226
|
-
"
|
|
227
|
-
"set NANO_AGENTIC_SECRET
|
|
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
|
|
233
|
-
"
|
|
234
|
-
"
|
|
235
|
-
"
|
|
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
|
-
//
|
|
51
|
-
// - No secret configured -> LOCAL mode: well-known
|
|
52
|
-
// `nano work` worker appears live with zero configuration
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
|
77
|
-
"
|
|
78
|
-
"
|
|
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.
|
|
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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -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
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* `
|
|
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": "
|
|
17
|
-
"matchPackageNames": ["
|
|
18
|
-
"
|
|
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
|
}
|