@nanobpm/nano-workforce 0.150.2 → 0.150.3

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,9 @@
1
+ ## [0.150.3](https://github.com/nanobpm/nano-workforce/compare/v0.150.2...v0.150.3) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **compileDeliveryGraph:** key reviewUrl to the request origin ([#577](https://github.com/nanobpm/nano-workforce/issues/577)) ([#581](https://github.com/nanobpm/nano-workforce/issues/581)) ([8c04c95](https://github.com/nanobpm/nano-workforce/commit/8c04c95b9d3617b3d71d28e54a7f24cae54c6e2c))
6
+
1
7
  ## [0.150.2](https://github.com/nanobpm/nano-workforce/compare/v0.150.1...v0.150.2) (2026-08-28)
2
8
 
3
9
  ### Bug Fixes
@@ -25,6 +25,7 @@ import {
25
25
  stageProposal,
26
26
  sweepExpiredProposals,
27
27
  } from "./deliveryGraphProposals.ts";
28
+ import { publicBaseUrl } from "./blackboard.ts";
28
29
 
29
30
  const APP_ROOT = resolve(import.meta.dirname, "..");
30
31
 
@@ -87,6 +88,12 @@ test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a
87
88
  assert(!/\/actions\//.test(url), "reviewUrl points at a page, never an API action");
88
89
  });
89
90
 
91
+ test("proposalReviewUrl: with no base falls back to publicBaseUrl() — the text-ingress (no request) path (#577)", () => {
92
+ // The staging text-ingress path has no HTTP request to derive an origin from, so the default base
93
+ // must stay the deployment-wide NANO_WORKFORCE_BASE_URL via publicBaseUrl().
94
+ assertEquals(proposalReviewUrl("abc123"), `${publicBaseUrl()}/app/pages/delivery-graphs#proposal-abc123`);
95
+ });
96
+
90
97
  test("buildProposalRow: stamps status staged, boolean→0/1, and TTL from createdAt", () => {
91
98
  const r = row({ sideEffecting: true, createdAt: "2024-01-01T00:00:00.000Z" });
92
99
  assertEquals(r.status, "staged");
@@ -1,9 +1,10 @@
1
1
  // Tests for app/resolveApiBase.ts — the single canonical control-API base reconstruction shared by
2
- // getAgentInstructions and getAgentSkill. Covers proxy-header handling, scheme restriction,
2
+ // getAgentInstructions and getAgentSkill, plus the human-facing resolvePublicOrigin (#577). Covers
3
+ // proxy-header handling, scheme restriction, host sanitisation, x-forwarded-prefix sanitisation,
3
4
  // host-absent fallback, and mount-suffix stripping for both mount depths.
4
5
  import { test } from "node:test";
5
6
  import { assertEquals } from "#test-assert";
6
- import { resolveApiBase } from "./resolveApiBase.ts";
7
+ import { resolveApiBase, resolvePublicOrigin } from "./resolveApiBase.ts";
7
8
 
8
9
  function req(headers: Record<string, string>, path: string) {
9
10
  return { path, headers: new Headers(headers) };
@@ -42,6 +43,7 @@ test("strips multiple trailing slashes after the mount suffix", () => {
42
43
  assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
44
  });
44
45
 
46
+ // ── resolveApiBase: x-forwarded-prefix sanitisation (#580) via the shared sanitiseForwardedPrefix ──
45
47
  test("prepends a validated x-forwarded-prefix to the reconstructed base", () => {
46
48
  const r = req(
47
49
  { host: "nano.ngrok-free.dev", "x-forwarded-prefix": "/console/app-view/Workforce" },
@@ -97,3 +99,79 @@ test("prefix composes with x-forwarded-proto and x-forwarded-host", () => {
97
99
  );
98
100
  assertEquals(resolveApiBase(r, "agent/skill"), "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
99
101
  });
102
+
103
+ // ── resolvePublicOrigin: the human-facing ORIGIN (+ proxy prefix), no /app/api suffix (#577) ──
104
+ // Shares sanitiseForwardedPrefix with resolveApiBase, so the prefix policy (absolute-only, reject
105
+ // scheme/authority/traversal entirely, percent-aware) is identical on both surfaces — no drift.
106
+ test("resolvePublicOrigin: bare origin from the host header", () => {
107
+ assertEquals(resolvePublicOrigin(req({ host: "wf.example.com" }, "/app/api/actions/compile-delivery-graph")), "http://wf.example.com");
108
+ });
109
+
110
+ test("resolvePublicOrigin: honours x-forwarded-proto and x-forwarded-host", () => {
111
+ const r = req({ host: "internal", "x-forwarded-host": "example.test", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
112
+ assertEquals(resolvePublicOrigin(r), "https://example.test");
113
+ });
114
+
115
+ test("resolvePublicOrigin: restricts x-forwarded-proto to http/https", () => {
116
+ const r = req({ host: "wf.example.com", "x-forwarded-proto": "javascript" }, "/app/api/actions/compile-delivery-graph");
117
+ assertEquals(resolvePublicOrigin(r), "http://wf.example.com");
118
+ });
119
+
120
+ test("resolvePublicOrigin: appends the reverse-proxy x-forwarded-prefix", () => {
121
+ const r = req(
122
+ { "x-forwarded-host": "nano.ngrok-free.dev", "x-forwarded-proto": "https", "x-forwarded-prefix": "/console/app-view/Workforce" },
123
+ "/app/api/actions/compile-delivery-graph",
124
+ );
125
+ assertEquals(resolvePublicOrigin(r), "https://nano.ngrok-free.dev/console/app-view/Workforce");
126
+ });
127
+
128
+ test("resolvePublicOrigin: normalises a trailing slash on x-forwarded-prefix", () => {
129
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/actions/compile-delivery-graph");
130
+ assertEquals(resolvePublicOrigin(r), "http://h/console/app-view/Workforce");
131
+ });
132
+
133
+ test("resolvePublicOrigin: treats a slash-only x-forwarded-prefix as empty (no double slash)", () => {
134
+ const r = req({ host: "h", "x-forwarded-prefix": "///" }, "/app/api/actions/compile-delivery-graph");
135
+ assertEquals(resolvePublicOrigin(r), "http://h");
136
+ });
137
+
138
+ test("resolvePublicOrigin: rejects a path-traversal x-forwarded-prefix entirely", () => {
139
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/../../etc" }, "/app/api/actions/compile-delivery-graph");
140
+ assertEquals(resolvePublicOrigin(r), "http://h");
141
+ });
142
+
143
+ test("resolvePublicOrigin: rejects a scheme/authority x-forwarded-prefix", () => {
144
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.example/hijack" }, "/app/api/actions/compile-delivery-graph");
145
+ assertEquals(resolvePublicOrigin(r), "http://h");
146
+ });
147
+
148
+ test("resolvePublicOrigin: rejects a relative (non-absolute) x-forwarded-prefix", () => {
149
+ const r = req({ host: "h", "x-forwarded-prefix": "@evil.example" }, "/app/api/actions/compile-delivery-graph");
150
+ assertEquals(resolvePublicOrigin(r), "http://h");
151
+ });
152
+
153
+ test("resolvePublicOrigin: falls back to a localhost origin when the Host header is absent", () => {
154
+ assertEquals(resolvePublicOrigin(req({}, "/app/api/actions/compile-delivery-graph")), "http://localhost:3000");
155
+ });
156
+
157
+ // ── host sanitisation: the untrusted x-forwarded-host/host authority is reflected into the URL ──
158
+ test("rejects a userinfo-injecting host (falls back to localhost)", () => {
159
+ const r = req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/actions/compile-delivery-graph");
160
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
161
+ assertEquals(resolveApiBase(req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/agent"), "agent"), "http://localhost:3000/app/api");
162
+ });
163
+
164
+ test("rejects a path-injecting host (falls back to localhost)", () => {
165
+ const r = req({ "x-forwarded-host": "real.example/extra-path" }, "/app/api/actions/compile-delivery-graph");
166
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
167
+ });
168
+
169
+ test("accepts a host:port authority", () => {
170
+ const r = req({ "x-forwarded-host": "wf.example.com:8443", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
171
+ assertEquals(resolvePublicOrigin(r), "https://wf.example.com:8443");
172
+ });
173
+
174
+ test("accepts a bracketed IPv6 host authority", () => {
175
+ const r = req({ "x-forwarded-host": "[2001:db8::1]:3000" }, "/app/api/actions/compile-delivery-graph");
176
+ assertEquals(resolvePublicOrigin(r), "http://[2001:db8::1]:3000");
177
+ });
@@ -17,33 +17,72 @@
17
17
  * "/app/api" when the path is nothing but the suffix.
18
18
  */
19
19
  export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string {
20
- const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
21
- // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
22
- const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
23
- const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
24
- // The external path prefix stripped by a reverse proxy (e.g. the console app-view proxy mounts us
25
- // under "/console/app-view/{project}"). X-Forwarded-Prefix is the de-facto standard header for it.
26
- // It is untrusted, proxy-supplied input that ends up in a URL handed to an agent, so validate it as
27
- // strictly as x-forwarded-proto above: accept only an absolute path of URL-safe path characters —
28
- // rejecting anything with a scheme, an authority ("//host"), or a "."/".." traversal segment — then
29
- // drop trailing slashes so it composes cleanly with the base path. Anything else falls back to an
30
- // empty prefix, i.e. today's behaviour.
31
- //
32
- // Percent-encoding can smuggle those forms past a literal check: "%2e%2e" decodes to "..", and
33
- // "%2f%2f" decodes to an authority-introducing "//". So normalise the common encoded spellings of
34
- // "." and "/" (case-insensitively) before rejecting dot-segments and "//"; the still-encoded raw
35
- // value is what we reflect once it validates.
36
- const rawPrefix = (req.headers.get("x-forwarded-prefix") ?? "").split(",")[0].trim();
37
- const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
38
- const prefix =
39
- /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
40
- !decodedPrefix.includes("//") &&
41
- !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
42
- ? rawPrefix.replace(/\/+$/, "")
43
- : "";
20
+ const { proto, host } = requestProtoHost(req);
21
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
44
22
  // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
45
23
  const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
46
24
  const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
47
25
  const basePath = req.path.replace(stripRe, "") || "/app/api";
48
26
  return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`;
49
27
  }
28
+
29
+ /** The public ORIGIN (+ proxy prefix) this request arrived on — the base for a navigational link
30
+ * handed back to the caller (e.g. a cockpit deep-link), WITHOUT the `/app/api` mount suffix that
31
+ * {@link resolveApiBase} keeps. Where `resolveApiBase` reconstructs the control-API base an *agent*
32
+ * calls back on, this reconstructs the human-facing origin: proto + host (same proxy-header trust as
33
+ * `resolveApiBase`) plus any reverse-proxy path prefix advertised via `x-forwarded-prefix`, so a
34
+ * link built as `${resolvePublicOrigin(req)}/app/pages/…` opens on the exact origin the operator is
35
+ * driving this app from (e.g. a tunnel), not a static deployment-wide base. Falls back to a
36
+ * localhost origin when the Host header is absent (a raw unit-test request). */
37
+ export function resolvePublicOrigin(req: { path: string; headers: Headers }): string {
38
+ const { proto, host } = requestProtoHost(req);
39
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
40
+ return host ? `${proto}://${host}${prefix}` : `http://localhost:3000${prefix}`;
41
+ }
42
+
43
+ /** Sanitise the untrusted, proxy/user-controlled `x-forwarded-prefix` into a safe leading-slash,
44
+ * no-trailing-slash path segment (or ""). The ONE canonical prefix sanitiser shared by
45
+ * {@link resolveApiBase} and {@link resolvePublicOrigin} (AGENTS.md "derivation over duplication") —
46
+ * the prefix is the reverse-proxy path the public URL was mounted under (e.g.
47
+ * "/console/app-view/Workforce") and is reflected into a caller-facing URL, so it must not smuggle a
48
+ * scheme, an authority ("//host"), or a "."/".." traversal segment into the URL. Accept only an
49
+ * absolute path of URL-safe path characters, then drop trailing slashes so it composes cleanly with
50
+ * the base path; anything else falls back to an empty prefix. Percent-encoding can smuggle those
51
+ * forms past a literal check ("%2e%2e" decodes to "..", "%2f%2f" to an authority-introducing "//"),
52
+ * so normalise the common encoded spellings of "." and "/" (case-insensitively) before rejecting
53
+ * dot-segments and "//"; the still-encoded raw value is what we reflect once it validates. Because
54
+ * the return is always either "" or a leading-"/" path, it can never alter the `${proto}://${host}`
55
+ * authority. */
56
+ function sanitiseForwardedPrefix(raw: string | null): string {
57
+ const rawPrefix = (raw ?? "").split(",")[0].trim();
58
+ const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
59
+ return /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
60
+ !decodedPrefix.includes("//") &&
61
+ !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
62
+ ? rawPrefix.replace(/\/+$/, "")
63
+ : "";
64
+ }
65
+
66
+ /** The trusted (proto, host) pair for a request — the ONE place proxy-header handling lives so
67
+ * `resolveApiBase` and `resolvePublicOrigin` can't drift (AGENTS.md "derivation over duplication").
68
+ * Only `http`/`https` are trusted from the user-controlled `x-forwarded-proto`; the host prefers
69
+ * `x-forwarded-host` over `host`. `host` is "" when neither header is present or the advertised host
70
+ * is not a valid authority (see {@link sanitiseHost}). */
71
+ function requestProtoHost(req: { headers: Headers }): { proto: string; host: string } {
72
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
73
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
74
+ const rawHost = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
75
+ return { proto, host: sanitiseHost(rawHost) };
76
+ }
77
+
78
+ /** Sanitise the untrusted, proxy/user-controlled host (`x-forwarded-host`/`host`) into a bare
79
+ * authority — a registered name or IPv4 with an optional `:port`, or a bracketed IPv6 literal with
80
+ * an optional `:port` — or "" when it carries anything else. The host is reflected verbatim into the
81
+ * `${proto}://${host}` authority of a caller-facing URL, so a hostile value like
82
+ * `evil.com@real.example` (userinfo injection) or `real.example/extra-path` (path injection) must be
83
+ * rejected outright rather than smuggled through. */
84
+ function sanitiseHost(host: string): string {
85
+ if (!host) return "";
86
+ const valid = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/.test(host);
87
+ return valid ? host : "";
88
+ }
@@ -30,8 +30,9 @@ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Pro
30
30
  }
31
31
  }
32
32
 
33
- async function call(app: AppApi, body: unknown) {
34
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
33
+ async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
34
+ const req = { path: "/app/api/actions/compile-delivery-graph", headers: new Headers(headers) };
35
+ return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
35
36
  }
36
37
 
37
38
  const GOOD = {
@@ -79,6 +80,33 @@ test("compile-delivery-graph: the response exposes NO dispatch handle — no run
79
80
  });
80
81
  });
81
82
 
83
+ // ── #577: reviewUrl is a caller-facing link → keyed to the request origin, not the static base ──
84
+ test("compile-delivery-graph: reviewUrl is on the request's forwarded origin, not NANO_WORKFORCE_BASE_URL", async () => {
85
+ await withApp(async (app) => {
86
+ const res = await call(app, GOOD, { "x-forwarded-proto": "https", "x-forwarded-host": "example.test" });
87
+ assertEquals(res.status, 200);
88
+ assertEquals(
89
+ res.body.reviewUrl,
90
+ `https://example.test/app/pages/delivery-graphs#proposal-${res.body.digest}`,
91
+ );
92
+ });
93
+ });
94
+
95
+ test("compile-delivery-graph: reviewUrl honours the reverse-proxy x-forwarded-prefix", async () => {
96
+ await withApp(async (app) => {
97
+ const res = await call(app, GOOD, {
98
+ "x-forwarded-proto": "https",
99
+ "x-forwarded-host": "nano.ngrok-free.dev",
100
+ "x-forwarded-prefix": "/console/app-view/Workforce",
101
+ });
102
+ assertEquals(res.status, 200);
103
+ assertEquals(
104
+ res.body.reviewUrl,
105
+ `https://nano.ngrok-free.dev/console/app-view/Workforce/app/pages/delivery-graphs#proposal-${res.body.digest}`,
106
+ );
107
+ });
108
+ });
109
+
82
110
  test("compile-delivery-graph: re-compiling the same graph is idempotent — one staged proposal, TTL anchored to the first stage", async () => {
83
111
  await withApp(async (app, data) => {
84
112
  const first = await call(app, GOOD);
@@ -21,12 +21,13 @@ import {
21
21
  stageProposal,
22
22
  } from "../app/deliveryGraphProposals.ts";
23
23
  import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
24
+ import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
24
25
  import { defineOperation } from "../nano-generated/operations.ts";
25
26
 
26
27
  const STAGED_MESSAGE =
27
28
  "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
28
29
 
29
- export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
30
+ export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
30
31
  // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
31
32
  // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
32
33
  // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
@@ -75,7 +76,10 @@ export default defineOperation("compileDeliveryGraph", async ({ body }, app) =>
75
76
  message: STAGED_MESSAGE,
76
77
  digest,
77
78
  preview,
78
- reviewUrl: proposalReviewUrl(digest),
79
+ // Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
80
+ // proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
81
+ // driving this instance can actually open it (#577).
82
+ reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
79
83
  },
80
84
  };
81
85
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.150.2",
3
+ "version": "0.150.3",
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",