@nanobpm/nano-workforce 0.158.0 → 0.159.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.158.0",
3
+ "version": "0.159.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",
@@ -49,6 +49,8 @@
49
49
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
50
50
  "sync:nav": "node --experimental-strip-types scripts/sync-nav.ts",
51
51
  "sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
52
+ "gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
53
+ "check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
52
54
  "dev": "urban dev",
53
55
  "pretest": "urban gen",
54
56
  "test": "node --experimental-strip-types --test",
@@ -73,7 +75,8 @@
73
75
  "conventional-changelog-conventionalcommits": "^8.0.0",
74
76
  "linkedom": "^0.18.13",
75
77
  "semantic-release": "^25.0.0",
76
- "typescript": "^5.6.0"
78
+ "typescript": "^5.6.0",
79
+ "yaml": "^2.9.0"
77
80
  },
78
81
  "overrides": {
79
82
  "@semantic-release/npm": "^13.1.5"
@@ -542,14 +542,14 @@ export function mountCockpit(host, opts = {}) {
542
542
  setNote(undefined);
543
543
  }
544
544
 
545
- function setNote(text) {
545
+ function setNote(text, state = "waiting") {
546
546
  if (text == null) {
547
547
  terminalNote.textContent = "";
548
548
  terminalNote.setAttribute("data-terminal-note", "none");
549
549
  return;
550
550
  }
551
551
  terminalNote.textContent = text;
552
- terminalNote.setAttribute("data-terminal-note", "waiting");
552
+ terminalNote.setAttribute("data-terminal-note", state);
553
553
  }
554
554
 
555
555
  function teardownTerminal() {
@@ -628,7 +628,14 @@ export function mountCockpit(host, opts = {}) {
628
628
  let session;
629
629
  const client = new RelayChannelClient({
630
630
  connect: connectRelay,
631
- onRelay: (message) => session?.handle(message),
631
+ onRelay: (message) => {
632
+ // Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
633
+ // then it honestly reads "connecting", so a socket that never opens/subscribes stops
634
+ // masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
635
+ // resubscribe ack does not re-arm the note after real output has already flowed.
636
+ if (!cleared && message?.op === "subscribed") setNote("Waiting for live output…", "waiting");
637
+ session?.handle(message);
638
+ },
632
639
  onOpen: () => session?.attach(),
633
640
  onError,
634
641
  });
@@ -636,8 +643,10 @@ export function mountCockpit(host, opts = {}) {
636
643
  client.open();
637
644
  drill = { stream, client };
638
645
  setMode("live", stream);
639
- // Arm the "waiting for output" note (after setMode, which clears it) until the first frame.
640
- setNote("Connected waiting for live output");
646
+ // Arm the note as "connecting" (after setMode, which clears it) BEFORE the socket opens. It only
647
+ // becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
648
+ // first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
649
+ setNote("Connecting…", "connecting");
641
650
  } catch (err) {
642
651
  // The new terminal failed to build after the prior one was torn down: reset the region to idle
643
652
  // (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
@@ -811,7 +820,7 @@ export function mountCockpit(host, opts = {}) {
811
820
  }
812
821
 
813
822
  /**
814
- * Derive the channel WebSocket URL from the current origin (path `/agentic`).
823
+ * Derive the channel WebSocket URL module-relatively (path `/agentic`, anchored to import.meta.url).
815
824
  *
816
825
  * The agentic hub authenticates upgrades with an identity token only
817
826
  * (`sharedSecretAuthenticator({ requireCredential: false })`) — no capability credential is required.
@@ -824,10 +833,21 @@ export function mountCockpit(host, opts = {}) {
824
833
  * token (or an explicit `relayUrl`) for secured deployments.
825
834
  */
826
835
  function defaultRelayUrl(token, capability) {
827
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
828
- const base = `${proto}//${location.host}/agentic`;
829
- if (!token) return base;
830
- const query = new URLSearchParams({ token });
831
- if (capability) query.set("capability", capability);
832
- return `${base}?${query}`;
836
+ // Anchor the relay endpoint to THIS MODULE's url (import.meta.url), NOT location.host exactly as
837
+ // the HTTP endpoints (reportUrl/transcriptsUrl) are (#279/#467). mount.js is ALWAYS served at
838
+ // `<appMount>/cockpit/mount.js`, so `../agentic` resolves to `<appMount>/agentic` on every surface,
839
+ // then swap the scheme http→ws / https→wss:
840
+ // • standalone / local App-View: `ws://<app-origin>/agentic` — byte-identical to the old behaviour.
841
+ // • Studio console App-View: `ws://<console>/console/app-view/<app>/agentic` — the app-view
842
+ // -prefixed path the console proxies, NOT the console origin root.
843
+ // Deriving from `location.host` instead dialed the CONSOLE origin behind the App-View — which has no
844
+ // `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3) — leaving
845
+ // the live terminal permanently dead behind the console (#600). This is the WS hop of the same
846
+ // failure class the HTTP endpoints already fixed by anchoring to import.meta.url.
847
+ const url = new URL("../agentic", import.meta.url);
848
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
849
+ if (!token) return url.href;
850
+ url.searchParams.set("token", token);
851
+ if (capability) url.searchParams.set("capability", capability);
852
+ return url.href;
833
853
  }
@@ -0,0 +1,316 @@
1
+ // Single source of truth for the projected MCP tool INPUT schemas (epic nano-workforce#605, S0).
2
+ //
3
+ // The Urban runtime projects this app's `openapi.yaml` into MCP tools (ADR 0067) — there is
4
+ // intentionally ZERO MCP server code in nwf. The projector (`@nanobpm/urban`
5
+ // `collectOperations` → `toolInputSchema`) copies each operation's request-body schema VERBATIM
6
+ // into the tool's `inputSchema.properties.body`; it does NOT resolve `$ref`s. So a request body
7
+ // authored as `schema: { $ref: "#/components/schemas/DeliveryGraph" }` projects as an opaque
8
+ // `body: { "$ref": … }` a standard MCP client cannot resolve — and, unable to see it is an object,
9
+ // the client stringifies the argument and the door rejects it (`expected object, got string`).
10
+ // The upstream fix (a projector that bundles refs into self-contained schemas) is tracked in
11
+ // nano-ide#501 (P0 #502 self-contained schemas, P1 #503 faithful object-body transport, P2 #504
12
+ // real-spec conformance guard); THIS script is the nwf-side mitigation that keeps the surface
13
+ // callable today: it authors each projected request body as a self-contained, `$ref`-free
14
+ // `type: object` schema INLINE in `openapi.yaml`.
15
+ //
16
+ // Derivation over duplication (AGENTS.md): the `components.schemas` remain the single source of
17
+ // truth. This script DERIVES the inline body by fully dereferencing that component (merging
18
+ // `allOf`, inlining `oneOf` variants, dropping `discriminator` ref-mappings) and splicing the
19
+ // result into the operation's `requestBody` between sentinel markers, in place, without
20
+ // reformatting the rest of the hand-maintained file. Re-run it whenever a source component
21
+ // changes:
22
+ //
23
+ // node --experimental-strip-types scripts/inline-mcp-bodies.ts # write openapi.yaml
24
+ // node --experimental-strip-types scripts/inline-mcp-bodies.ts --check # verify (CI)
25
+ //
26
+ // The convention every projected request-body operation MUST follow (and every later slice that
27
+ // adds one inherits): author the body as a SINGLE top-level `$ref` to a `components.schemas` entry,
28
+ // yielding a `type: object` with inline `properties`, NO `$ref` in the projected schema, and a
29
+ // description that carries the contract; the graph doors (`compileDeliveryGraph`/
30
+ // `previewDeliveryGraph`) additionally carry a worked `example` (enforced by
31
+ // `test/mcp-tool-schemas.test.ts`). `test/mcp-tool-schemas.test.ts` is the runtime drift guard — it
32
+ // runs the REAL urban projector over the spec and fails if any projected tool body reintroduces a
33
+ // `$ref` or loses its explicit type.
34
+ //
35
+ // Drift detection survives inlining: the generated `# BEGIN` sentinel records the source component
36
+ // (`source=#/components/schemas/…`), so on every subsequent run the generator re-derives the inline
37
+ // body from the CURRENT component — even though the operation's `schema:` no longer carries a
38
+ // `$ref` — and `--check` fails if a source component changed but its inline body was not
39
+ // regenerated. (Without the recorded source the check would be a permanent false green: once the
40
+ // `$ref` is inlined there is nothing left to re-derive from.)
41
+ //
42
+ // Mirrors the repo's other derive/verify pairs (layout-bpmn --check, sync-nav --check,
43
+ // check-contracts / reconcile-contracts).
44
+ import { readFileSync, writeFileSync } from "node:fs";
45
+ import process from "node:process";
46
+ // `yaml` (eemeli) is the SAME parser `@nanobpm/urban` uses to read this spec at runtime
47
+ // (openapi/spec.ts), so a round-trip here matches what the projector sees.
48
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
49
+
50
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
51
+ const SPEC_PATH = `${ROOT}openapi.yaml`;
52
+
53
+ const BEGIN_PREFIX = "# BEGIN generated:mcp-body";
54
+ const BEGIN_TAIL = "(scripts/inline-mcp-bodies.ts — do not hand-edit)";
55
+ const END = "# END generated:mcp-body";
56
+
57
+ /** The `# BEGIN` sentinel for a block, embedding the source component so a later run can re-derive. */
58
+ function beginMarker(sourceRef: string): string {
59
+ return `${BEGIN_PREFIX} source=${sourceRef} ${BEGIN_TAIL}`;
60
+ }
61
+
62
+ type Schema = Record<string, unknown>;
63
+
64
+ function isRecord(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === "object" && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ const HTTP_METHODS = ["get", "put", "post", "delete", "patch"] as const;
69
+
70
+ /**
71
+ * Fully dereference a schema against `components.schemas`, producing a `$ref`-free equivalent:
72
+ * - a `$ref` is replaced by the (recursively dereferenced) target component;
73
+ * - an `allOf` is MERGED into one object (union of `properties`/`required`) so a node variant
74
+ * like `DeliveryNodeAgent` (allOf [DeliveryNodeCommon, {inline}]) becomes one flat `type: object`;
75
+ * - `oneOf`/`anyOf` members are dereferenced but kept as a union;
76
+ * - `discriminator` is dropped (its `mapping` values are `#/components/...` pointers that would
77
+ * dangle once inlined, and MCP clients do not need it);
78
+ * - every other keyword is copied through, recursing into `properties`/`items`/arrays.
79
+ * Cycles (none today) are guarded by the visited-name set.
80
+ */
81
+ function deref(node: unknown, comps: Record<string, unknown>, seen: ReadonlySet<string>): unknown {
82
+ if (Array.isArray(node)) return node.map((n) => deref(n, comps, seen));
83
+ if (!isRecord(node)) return node;
84
+
85
+ if (typeof node.$ref === "string") {
86
+ const m = node.$ref.match(/^#\/components\/schemas\/(.+)$/);
87
+ if (!m) throw new Error(`non-local $ref cannot be inlined: ${node.$ref}`);
88
+ const name = m[1];
89
+ if (seen.has(name)) throw new Error(`cyclic $ref: ${name}`);
90
+ const target = comps[name];
91
+ if (!target) throw new Error(`dangling $ref: ${node.$ref}`);
92
+ return deref(target, comps, new Set(seen).add(name));
93
+ }
94
+
95
+ if (Array.isArray(node.allOf)) {
96
+ const props: Record<string, unknown> = {};
97
+ const required: string[] = [];
98
+ const merged: Schema = { type: "object", properties: props, required };
99
+ let additionalProperties: unknown;
100
+ for (const part of node.allOf) {
101
+ const d = deref(part, comps, seen);
102
+ if (!isRecord(d)) continue;
103
+ if (isRecord(d.properties)) Object.assign(props, d.properties);
104
+ if (Array.isArray(d.required)) {
105
+ for (const r of d.required) if (typeof r === "string") required.push(r);
106
+ }
107
+ if ("additionalProperties" in d) additionalProperties = d.additionalProperties;
108
+ if (typeof d.type === "string") merged.type = d.type;
109
+ }
110
+ // Sibling keywords authored alongside `allOf` (e.g. `description`) win over the merged parts.
111
+ for (const [k, v] of Object.entries(node)) {
112
+ if (k === "allOf") continue;
113
+ merged[k] = deref(v, comps, seen);
114
+ }
115
+ if (required.length === 0) delete merged.required;
116
+ else merged.required = [...new Set(required)];
117
+ if (additionalProperties !== undefined && !("additionalProperties" in node)) {
118
+ merged.additionalProperties = additionalProperties;
119
+ }
120
+ return merged;
121
+ }
122
+
123
+ const out: Schema = {};
124
+ for (const [k, v] of Object.entries(node)) {
125
+ if (k === "discriminator") continue;
126
+ out[k] = deref(v, comps, seen);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** Does this (raw, un-dereferenced) schema contain a `$ref` anywhere? */
132
+ function hasRef(node: unknown): boolean {
133
+ if (Array.isArray(node)) return node.some(hasRef);
134
+ if (!isRecord(node)) return false;
135
+ if (typeof node.$ref === "string") return true;
136
+ return Object.values(node).some(hasRef);
137
+ }
138
+
139
+ /** `false` iff the operation is `x-mcp`-excluded (operator-only door). */
140
+ function isProjected(op: Record<string, unknown>): boolean {
141
+ const x = op["x-mcp"];
142
+ if (x === false) return false;
143
+ if (isRecord(x) && x.exclude === true) return false;
144
+ return true;
145
+ }
146
+
147
+ interface Target {
148
+ operationId: string;
149
+ sourceRef: string;
150
+ inline: Schema;
151
+ }
152
+
153
+ /**
154
+ * Recover `operationId -> source component $ref` from the `# BEGIN … source=…` sentinels already in
155
+ * the raw text. This is what lets `--check` keep detecting drift AFTER the `$ref` has been inlined:
156
+ * the operation's parsed `schema:` no longer carries a `$ref`, but the recorded source does, so the
157
+ * generator can re-derive the block from the current component. Scans by operation region (the same
158
+ * indentation-free `operationId:` split `spliceSchema` uses) so a block is attributed to its owner.
159
+ */
160
+ function recordedSources(text: string): Map<string, string> {
161
+ const out = new Map<string, string>();
162
+ const opRe = /^ *operationId: (\S+)/gm;
163
+ const ops: Array<{ id: string; at: number }> = [];
164
+ for (let m = opRe.exec(text); m !== null; m = opRe.exec(text)) {
165
+ ops.push({ id: m[1], at: m.index });
166
+ }
167
+ for (let i = 0; i < ops.length; i++) {
168
+ const end = i + 1 < ops.length ? ops[i + 1].at : text.length;
169
+ const region = text.slice(ops[i].at, end);
170
+ const bm = region.match(/# BEGIN generated:mcp-body source=(\S+)/);
171
+ if (bm) out.set(ops[i].id, bm[1]);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Collect the projected request-body operations to (re)generate. A body is a managed target when it
178
+ * is authored as a single top-level `$ref` (first authoring) OR already carries a generated block
179
+ * whose source component was recorded in its sentinel (subsequent runs). Either way the inline body
180
+ * is DERIVED from the CURRENT `components.schemas`, so a source-component change is always re-derived
181
+ * (and caught by `--check`). A projected body that leaks a NON-top-level `$ref` violates the
182
+ * single-top-level-`$ref` convention and is rejected loudly rather than silently half-inlined.
183
+ */
184
+ function collectTargets(doc: Record<string, unknown>, recorded: Map<string, string>): Target[] {
185
+ const comps: Record<string, unknown> =
186
+ isRecord(doc.components) && isRecord(doc.components.schemas) ? doc.components.schemas : {};
187
+ const paths = isRecord(doc.paths) ? doc.paths : {};
188
+ const targets: Target[] = [];
189
+ for (const item of Object.values(paths)) {
190
+ if (!isRecord(item)) continue;
191
+ for (const method of HTTP_METHODS) {
192
+ const op = item[method];
193
+ if (!isRecord(op) || typeof op.operationId !== "string") continue;
194
+ if (!isProjected(op)) continue;
195
+ const body = isRecord(op.requestBody) ? op.requestBody : undefined;
196
+ const jsonNode =
197
+ body && isRecord(body.content) && isRecord(body.content["application/json"])
198
+ ? body.content["application/json"]
199
+ : undefined;
200
+ const json = isRecord(jsonNode) ? jsonNode.schema : undefined;
201
+ if (!isRecord(json)) continue;
202
+ let sourceRef: string | undefined;
203
+ if (typeof json.$ref === "string") {
204
+ sourceRef = json.$ref;
205
+ } else if (recorded.has(op.operationId)) {
206
+ sourceRef = recorded.get(op.operationId);
207
+ } else {
208
+ if (hasRef(json)) {
209
+ throw new Error(
210
+ `${op.operationId}: projected request body must be authored as a single top-level ` +
211
+ "`$ref` to a components.schemas entry (found a nested `$ref`) — see the MCP schema " +
212
+ "convention in openapi.yaml.",
213
+ );
214
+ }
215
+ continue;
216
+ }
217
+ if (sourceRef === undefined) continue;
218
+ const derefed = deref({ $ref: sourceRef }, comps, new Set());
219
+ const inline: Schema = isRecord(derefed) ? derefed : {};
220
+ if (typeof inline.type !== "string") {
221
+ // A `oneOf`/`anyOf` body root has no single `type`; every projected body IS an object, so
222
+ // pin the explicit `type: object` the MCP contract requires alongside the variant union.
223
+ inline.type = "object";
224
+ }
225
+ targets.push({ operationId: op.operationId, sourceRef, inline });
226
+ }
227
+ }
228
+ targets.sort((a, b) => a.operationId.localeCompare(b.operationId));
229
+ return targets;
230
+ }
231
+
232
+ /** Render an inline schema as a YAML block indented to `pad` spaces, wrapped in the sentinels. */
233
+ function renderBlock(inline: Schema, pad: string, sourceRef: string): string {
234
+ const dumped = stringifyYaml(inline, { indent: 2, lineWidth: 0, singleQuote: true });
235
+ const body = dumped
236
+ .replace(/\n$/, "")
237
+ .split("\n")
238
+ .map((line) => (line.length ? pad + line : line))
239
+ .join("\n");
240
+ return `${pad}${beginMarker(sourceRef)}\n${body}\n${pad}${END}`;
241
+ }
242
+
243
+ /**
244
+ * Replace the `requestBody` → `application/json` → `schema:` value of `operationId` in the raw
245
+ * text with `block`, in place. Returns the new text. Boundaries are found by indentation, the same
246
+ * span technique `scripts/sync-nav.ts` uses, so nothing else in the file is reformatted.
247
+ */
248
+ function spliceSchema(text: string, operationId: string, inline: Schema, sourceRef: string): string {
249
+ const opMarker = `operationId: ${operationId}\n`;
250
+ const opAt = text.indexOf(opMarker);
251
+ if (opAt < 0) throw new Error(`operationId not found in text: ${operationId}`);
252
+ // Bound the search to this operation (up to the next operationId).
253
+ const nextOp = text.indexOf("operationId: ", opAt + opMarker.length);
254
+ const region = nextOp < 0 ? text.slice(opAt) : text.slice(opAt, nextOp);
255
+ const rbRel = region.indexOf("requestBody:");
256
+ if (rbRel < 0) throw new Error(`requestBody not found for ${operationId}`);
257
+ // First `schema:` after requestBody is the request body schema.
258
+ const schemaRel = region.indexOf("schema:", rbRel);
259
+ if (schemaRel < 0) throw new Error(`request schema not found for ${operationId}`);
260
+ const schemaAbs = opAt + schemaRel;
261
+ const lineStart = text.lastIndexOf("\n", schemaAbs) + 1;
262
+ const schemaIndent = schemaAbs - lineStart; // columns before `schema:`
263
+ const pad = " ".repeat(schemaIndent + 2); // schema value is nested one level deeper
264
+ // The schema value spans from the end of the `schema:` line to the first following line whose
265
+ // indent is <= the `schema:` line's indent (a sibling/closing key), skipping blank lines.
266
+ const afterSchemaLine = text.indexOf("\n", schemaAbs) + 1;
267
+ const lines = text.slice(afterSchemaLine).split("\n");
268
+ let consumed = 0;
269
+ for (const line of lines) {
270
+ if (line.trim() === "") {
271
+ consumed += line.length + 1;
272
+ continue;
273
+ }
274
+ const indent = line.length - line.trimStart().length;
275
+ if (indent <= schemaIndent) break;
276
+ consumed += line.length + 1;
277
+ }
278
+ const valueEnd = afterSchemaLine + consumed;
279
+ const block = renderBlock(inline, pad, sourceRef);
280
+ return `${text.slice(0, afterSchemaLine)}${block}\n${text.slice(valueEnd)}`;
281
+ }
282
+
283
+ function generate(text: string): string {
284
+ const parsed = parseYaml(text);
285
+ const doc: Record<string, unknown> = isRecord(parsed) ? parsed : {};
286
+ const targets = collectTargets(doc, recordedSources(text));
287
+ let out = text;
288
+ for (const t of targets) out = spliceSchema(out, t.operationId, t.inline, t.sourceRef);
289
+ return out;
290
+ }
291
+
292
+ function main(): void {
293
+ const check = process.argv.includes("--check");
294
+ const original = readFileSync(SPEC_PATH, "utf8");
295
+ const next = generate(original);
296
+ if (check) {
297
+ if (next !== original) {
298
+ process.stderr.write(
299
+ "openapi.yaml projected MCP request bodies are STALE.\n" +
300
+ "A source component schema changed but its inline `body` was not regenerated.\n" +
301
+ "Run: npm run gen:mcp-bodies (node --experimental-strip-types scripts/inline-mcp-bodies.ts)\n",
302
+ );
303
+ process.exit(1);
304
+ }
305
+ process.stdout.write("openapi.yaml projected MCP request bodies are up to date.\n");
306
+ return;
307
+ }
308
+ if (next !== original) {
309
+ writeFileSync(SPEC_PATH, next);
310
+ process.stdout.write("openapi.yaml projected MCP request bodies regenerated.\n");
311
+ } else {
312
+ process.stdout.write("openapi.yaml projected MCP request bodies already up to date.\n");
313
+ }
314
+ }
315
+
316
+ main();
@@ -94,3 +94,87 @@ test("#279: embed.html forwards BOTH reportUrl and transcriptsUrl from the injec
94
94
  "embed.html must forward reportUrl: cfg.reportUrl",
95
95
  );
96
96
  });
97
+
98
+ // #600: the relay WebSocket default is the WS hop of the SAME #279/#467 failure class. It used to be
99
+ // derived from `location.host` — behind the Studio console that is the console origin (:8080), which
100
+ // has no `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3), so
101
+ // the live terminal was permanently dead behind the console. The fix anchors it to import.meta.url
102
+ // exactly like the HTTP endpoints, `../agentic` off `<appMount>/cockpit/mount.js`, then swaps the
103
+ // scheme http→ws / https→wss. Unlike reportUrl/transcriptsUrl (inlined `.href`) it is BUILT from
104
+ // `new URL("<spec>", import.meta.url)` so the scheme can be swapped, so it is matched separately.
105
+
106
+ // Pull the module-relative relay spec out of `new URL("<spec>", import.meta.url);` in defaultRelayUrl()
107
+ // (the HTTP defaults are `new URL(..., import.meta.url).href`, so `)\s*;` matches only the relay one).
108
+ function relaySpec(): string {
109
+ const m = MOUNT_JS.match(/new URL\(\s*"([^"]*)"\s*,\s*import\.meta\.url\s*\)\s*;/);
110
+ assert(
111
+ m,
112
+ "mount.js defaultRelayUrl() must derive the relay default from new URL(\"<spec>\", import.meta.url), " +
113
+ "anchored to the module's own served location, not location.host (#600)",
114
+ );
115
+ return m![1];
116
+ }
117
+
118
+ // Resolve the relay spec against a REAL served mount url and swap the scheme, mirroring defaultRelayUrl().
119
+ function resolveRelay(mount: string): string {
120
+ const url = new URL(relaySpec(), mount);
121
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
122
+ return url.href;
123
+ }
124
+
125
+ test("#600: defaultRelayUrl derives the relay target from import.meta.url, not location.host", () => {
126
+ assert(
127
+ !/\$\{location\.host\}/.test(MOUNT_JS),
128
+ "the relay default must not be interpolated from location.host: behind the console that is the " +
129
+ "console origin (:8080), which has no /agentic route (404) and refuses WS upgrades (501) (#600)",
130
+ );
131
+ const spec = relaySpec();
132
+ assert(
133
+ !spec.startsWith("/"),
134
+ `relay spec "${spec}" must not be absolute: a leading-slash path resolves against the iframe ORIGIN ` +
135
+ `(console :8080), not the app-view base the console proxies (#279 class)`,
136
+ );
137
+ assert(
138
+ spec.startsWith("../"),
139
+ `relay spec "${spec}" must step up out of /cockpit/ (mount.js is at <appMount>/cockpit/mount.js; the ` +
140
+ `hub is a sibling at <appMount>/agentic) (#467 class)`,
141
+ );
142
+ });
143
+
144
+ test("#600: relay default resolves to ws://<app-origin>/agentic standalone (behaviour unchanged)", () => {
145
+ assertEquals(
146
+ resolveRelay(STANDALONE_MOUNT),
147
+ "ws://127.0.0.1:3000/agentic",
148
+ "standalone the relay default must dial the app origin's /agentic exactly as it does today",
149
+ );
150
+ });
151
+
152
+ test("#600: relay default resolves onto the app-view base inside the Studio console iframe", () => {
153
+ assertEquals(
154
+ resolveRelay(STUDIO_MOUNT),
155
+ "ws://studio-host:8080/console/app-view/Workforce/agentic",
156
+ "behind the console the relay must dial the app-view-prefixed /agentic the console proxies, not the " +
157
+ "console origin root (#279 class) nor the /cockpit/ shell base (#467 class)",
158
+ );
159
+ });
160
+
161
+ test("#600: relay default swaps https→wss on a secure surface", () => {
162
+ assertEquals(
163
+ resolveRelay("https://studio-host:8443/console/app-view/Workforce/cockpit/mount.js"),
164
+ "wss://studio-host:8443/console/app-view/Workforce/agentic",
165
+ "an https surface must yield a wss relay url",
166
+ );
167
+ });
168
+
169
+ test("#600: an explicit relayUrl opt and injected __NANO_APP_VIEW__.relayUrl outrank the derived default", () => {
170
+ // Precedence is pinned in source: opts.relayUrl wins over the module-relative default, and embed.html
171
+ // forwards the console-injected cfg.relayUrl into that opt so it outranks the default behind the console.
172
+ assert(
173
+ /opts\.relayUrl\s*\?\?\s*defaultRelayUrl\(/.test(MOUNT_JS),
174
+ "mount.js must honour an explicit opts.relayUrl over the derived default (opts.relayUrl ?? defaultRelayUrl(...)) (#600)",
175
+ );
176
+ assert(
177
+ /relayUrl:\s*cfg\.relayUrl/.test(EMBED_HTML),
178
+ "embed.html must forward relayUrl: cfg.relayUrl so a console-injected __NANO_APP_VIEW__.relayUrl reaches the relay (#600)",
179
+ );
180
+ });
@@ -0,0 +1,127 @@
1
+ // Drift guard for the projected MCP tool INPUT schemas (epic nano-workforce#605, S0).
2
+ //
3
+ // The Urban runtime projects `openapi.yaml` into MCP tools (ADR 0067) and copies each operation's
4
+ // request-body schema VERBATIM into the tool's `inputSchema.properties.body` — it does NOT resolve
5
+ // `$ref`s. A leaked `$ref` is therefore unresolvable in a standard MCP client, and, unable to see
6
+ // the body is an object, the client stringifies the argument and the door rejects it
7
+ // (`expected object, got string`). This test drives the REAL projector (`collectOperations` from
8
+ // `@nanobpm/urban`, plus the runtime's own `toolInputSchema` shape) over the checked-in spec and
9
+ // fails the build if any projected tool body reintroduces a `$ref`, loses its explicit `type`, or a
10
+ // graph door drops its worked example. It is the runtime counterpart to
11
+ // `scripts/inline-mcp-bodies.ts --check` (which guards the derive step); together they keep the
12
+ // convention every later slice inherits: `type: object`, inline `properties`, no `$ref`, an example.
13
+ //
14
+ // Extension seam: a sibling slice adding a projected request-body operation to `openapi.yaml` needs
15
+ // no change here — this walks EVERY projected operation. A new graph door should be added to
16
+ // GRAPH_DOORS so its example is required too.
17
+ import { test } from "node:test";
18
+ import { assert } from "#test-assert";
19
+ import { readFileSync } from "node:fs";
20
+ import { collectOperations, type OperationInfo, parseSpec } from "@nanobpm/urban/toolkit";
21
+
22
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
23
+ // Parse the spec exactly as the runtime does (`@nanobpm/urban/toolkit` `parseSpec`), so this guard
24
+ // sees precisely the document the MCP projector projects.
25
+ const SPEC = parseSpec(readFileSync(`${ROOT}openapi.yaml`, "utf8"));
26
+
27
+ /** Graph doors that MUST carry at least one worked example (epic #605 acceptance). */
28
+ const GRAPH_DOORS = new Set(["compileDeliveryGraph", "previewDeliveryGraph"]);
29
+
30
+ /** The client-visible tool `inputSchema`, reproduced exactly from the runtime's `toolInputSchema`
31
+ * (mcp.ts) — path/query params plus the request body copied verbatim under `body`. */
32
+ function toolInputSchema(op: OperationInfo): Record<string, unknown> {
33
+ const properties: Record<string, unknown> = {};
34
+ const required: string[] = [];
35
+ for (const p of op.parameters) {
36
+ if (p.in === "path" || p.in === "query") {
37
+ properties[p.name] = p.schema ?? {};
38
+ if (p.required) required.push(p.name);
39
+ }
40
+ }
41
+ if (op.requestBodySchema) {
42
+ properties.body = op.requestBodySchema;
43
+ if (op.requestBodyRequired) required.push("body");
44
+ }
45
+ const schema: Record<string, unknown> = { type: "object", properties };
46
+ if (required.length > 0) schema.required = required;
47
+ return schema;
48
+ }
49
+
50
+ /** Every `$ref` string reachable in a schema, with a JSON-path for the failure message. */
51
+ function findRefs(node: unknown, path: string, out: string[]): void {
52
+ if (Array.isArray(node)) {
53
+ node.forEach((n, i) => findRefs(n, `${path}[${i}]`, out));
54
+ return;
55
+ }
56
+ if (typeof node !== "object" || node === null) return;
57
+ for (const [k, v] of Object.entries(node)) {
58
+ if (k === "$ref" && typeof v === "string") out.push(`${path}.$ref -> ${v}`);
59
+ else findRefs(v, `${path}.${k}`, out);
60
+ }
61
+ }
62
+
63
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
64
+ typeof v === "object" && v !== null && !Array.isArray(v);
65
+
66
+ /** The projected operations whose tool carries a request body (the object-body tools). */
67
+ function projectedBodyOps(): OperationInfo[] {
68
+ return collectOperations(SPEC).filter((op) => !op.mcpExcluded && op.requestBodySchema);
69
+ }
70
+
71
+ test("every projected object-body tool schema is $ref-free (self-contained)", () => {
72
+ const ops = projectedBodyOps();
73
+ assert(ops.length >= 10, `expected the full projected object-body surface, saw ${ops.length}`);
74
+ for (const op of ops) {
75
+ const refs: string[] = [];
76
+ findRefs(toolInputSchema(op).properties, `${op.operationId}.properties`, refs);
77
+ assert(
78
+ refs.length === 0,
79
+ `${op.operationId}: projected tool schema leaks $ref(s) a standard MCP client cannot ` +
80
+ `resolve — inline the component(s) (run npm run gen:mcp-bodies): ${refs.join(", ")}`,
81
+ );
82
+ }
83
+ });
84
+
85
+ test("every projected object-body tool declares an explicit body type: object", () => {
86
+ for (const op of projectedBodyOps()) {
87
+ const body = op.requestBodySchema as Record<string, unknown>;
88
+ // A `oneOf`/`anyOf` body still carries an explicit `type: object` (the generator pins it) so a
89
+ // client knows to pass an object, not a string.
90
+ assert(
91
+ body.type === "object",
92
+ `${op.operationId}: request body must declare an explicit \`type: object\` (saw ` +
93
+ `type=${JSON.stringify(body.type)})`,
94
+ );
95
+ assert(
96
+ isRecord(body.properties) || Array.isArray(body.oneOf) || Array.isArray(body.anyOf),
97
+ `${op.operationId}: request body must expose inline \`properties\` (or a \`oneOf\`/\`anyOf\` ` +
98
+ "of object variants) so the shape is discoverable from the tool surface",
99
+ );
100
+ }
101
+ });
102
+
103
+ test("each graph door carries at least one worked example", () => {
104
+ const byId = new Map(projectedBodyOps().map((op) => [op.operationId, op]));
105
+ for (const door of GRAPH_DOORS) {
106
+ const op = byId.get(door);
107
+ assert(op, `expected graph door ${door} to be a projected object-body tool`);
108
+ const body = op!.requestBodySchema as Record<string, unknown>;
109
+ const hasExample =
110
+ "example" in body ||
111
+ "examples" in body ||
112
+ (isRecord(body.properties) &&
113
+ Object.values(body.properties).some((p) => isRecord(p) && "example" in p));
114
+ assert(hasExample, `${door}: request body must embed a worked \`example\` (§9.5 canonical graph)`);
115
+ }
116
+ });
117
+
118
+ test("operator-only delivery-graph doors stay withheld from the MCP tool surface", () => {
119
+ const excluded = new Set(
120
+ collectOperations(SPEC)
121
+ .filter((op) => op.mcpExcluded)
122
+ .map((op) => op.operationId),
123
+ );
124
+ for (const door of ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"]) {
125
+ assert(excluded.has(door), `${door} must remain x-mcp-excluded (operator-only dispatch gate)`);
126
+ }
127
+ });