@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
|
+
});
|
package/app/resolveApiBase.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
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.
|
|
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",
|