@nanobpm/nano-workforce 0.150.1 → 0.150.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,9 @@
1
+ ## [0.150.2](https://github.com/nanobpm/nano-workforce/compare/v0.150.1...v0.150.2) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * honour X-Forwarded-Prefix in resolveApiBase so the operator guide baseUrl resolves behind the app-view proxy ([#580](https://github.com/nanobpm/nano-workforce/issues/580)) ([21fdeca](https://github.com/nanobpm/nano-workforce/commit/21fdecac2d7c383cca066fa47950d9b6d28f41fc)), closes [#578](https://github.com/nanobpm/nano-workforce/issues/578)
6
+
1
7
  ## [0.150.1](https://github.com/nanobpm/nano-workforce/compare/v0.150.0...v0.150.1) (2026-08-28)
2
8
 
3
9
  ### Bug Fixes
@@ -41,3 +41,59 @@ test("tolerates a leading slash on the mount suffix", () => {
41
41
  test("strips multiple trailing slashes after the mount suffix", () => {
42
42
  assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
43
  });
44
+
45
+ test("prepends a validated x-forwarded-prefix to the reconstructed base", () => {
46
+ const r = req(
47
+ { host: "nano.ngrok-free.dev", "x-forwarded-prefix": "/console/app-view/Workforce" },
48
+ "/app/api/agent",
49
+ );
50
+ assertEquals(resolveApiBase(r, "agent"), "http://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
51
+ });
52
+
53
+ test("normalises a trailing slash on x-forwarded-prefix", () => {
54
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/agent");
55
+ assertEquals(resolveApiBase(r, "agent"), "http://h/console/app-view/Workforce/app/api");
56
+ });
57
+
58
+ test("ignores a x-forwarded-prefix carrying a scheme", () => {
59
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.test" }, "/app/api/agent");
60
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
61
+ });
62
+
63
+ test("ignores a x-forwarded-prefix carrying an authority", () => {
64
+ const r = req({ host: "h", "x-forwarded-prefix": "//evil.test" }, "/app/api/agent");
65
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
66
+ });
67
+
68
+ test("ignores a x-forwarded-prefix with .. traversal", () => {
69
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/../.." }, "/app/api/agent");
70
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
71
+ });
72
+
73
+ test("ignores a x-forwarded-prefix with percent-encoded .. traversal", () => {
74
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/%2e%2e/%2e%2e" }, "/app/api/agent");
75
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
76
+ });
77
+
78
+ test("ignores a x-forwarded-prefix with a percent-encoded authority", () => {
79
+ const r = req({ host: "h", "x-forwarded-prefix": "/%2F%2Fevil.test" }, "/app/api/agent");
80
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
81
+ });
82
+
83
+ test("ignores a relative (non-absolute) x-forwarded-prefix", () => {
84
+ const r = req({ host: "h", "x-forwarded-prefix": "console/app-view/Workforce" }, "/app/api/agent");
85
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
86
+ });
87
+
88
+ test("prefix composes with x-forwarded-proto and x-forwarded-host", () => {
89
+ const r = req(
90
+ {
91
+ host: "internal",
92
+ "x-forwarded-host": "nano.ngrok-free.dev",
93
+ "x-forwarded-proto": "https",
94
+ "x-forwarded-prefix": "/console/app-view/Workforce",
95
+ },
96
+ "/app/api/agent/skill",
97
+ );
98
+ assertEquals(resolveApiBase(r, "agent/skill"), "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
99
+ });
@@ -4,8 +4,9 @@
4
4
  // to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over
5
5
  // duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork.
6
6
  //
7
- // Honour reverse-proxy forwarding headers; fall back to a localhost default when the Host header is
8
- // absent (e.g. a raw unit-test request).
7
+ // Honour reverse-proxy forwarding headers proto, host, and the external path prefix
8
+ // (X-Forwarded-Prefix, e.g. the console app-view proxy's "/console/app-view/{project}") — and fall
9
+ // back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
9
10
 
10
11
  /**
11
12
  * Recover the control-API base from a request, stripping the operation's own mount suffix.
@@ -20,9 +21,29 @@ export function resolveApiBase(req: { path: string; headers: Headers }, mountSuf
20
21
  // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
21
22
  const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
22
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
+ : "";
23
44
  // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
24
45
  const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
25
46
  const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
26
47
  const basePath = req.path.replace(stripRe, "") || "/app/api";
27
- return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
48
+ return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`;
28
49
  }
@@ -86,6 +86,30 @@ test("examples are keyed to the request's control-API base and leave no placehol
86
86
  assert(!md.includes("__ENGINE__"), "no unsubstituted __ENGINE__ placeholder");
87
87
  });
88
88
 
89
+ test("x-forwarded-prefix is prepended to the baseUrl and rendered examples", async () => {
90
+ const proxied = input(
91
+ {
92
+ host: "internal",
93
+ "x-forwarded-host": "nano.ngrok-free.dev",
94
+ "x-forwarded-proto": "https",
95
+ "x-forwarded-prefix": "/console/app-view/Workforce",
96
+ },
97
+ );
98
+ const body = (await handler(proxied, app)) as any;
99
+ assertEquals(body.body.baseUrl, "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
100
+ const md = body.body.instructions as string;
101
+ assert(
102
+ md.includes("https://nano.ngrok-free.dev/console/app-view/Workforce/app/api/version"),
103
+ "prefixed base URL substituted into examples",
104
+ );
105
+ });
106
+
107
+ test("a hostile x-forwarded-prefix is ignored rather than reflected into the baseUrl", async () => {
108
+ const hostile = input({ host: "wf.example.com", "x-forwarded-prefix": "https://evil.test" });
109
+ const body = (await handler(hostile, app)) as any;
110
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "hostile prefix falls back to today's behaviour");
111
+ });
112
+
89
113
  test("x-forwarded-proto is restricted to http/https", async () => {
90
114
  const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
91
115
  const body = (await handler(spoofed, app)) as any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.150.1",
3
+ "version": "0.150.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",