@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.
@@ -72,6 +72,14 @@ jobs:
72
72
  - name: Check contract registry (no synonyms / undeclared keys)
73
73
  run: npm run check:contracts
74
74
 
75
+ # MCP tool-schema gate (epic #605, S0): every projected (non-`x-mcp`) request-body operation
76
+ # must carry a self-contained, `$ref`-free inline `body` schema — the runtime projector copies
77
+ # it VERBATIM into the tool `inputSchema`, so a leaked `$ref` is unresolvable in a standard MCP
78
+ # client (nano-ide#502). The inline bodies are DERIVED from `components.schemas` by
79
+ # scripts/inline-mcp-bodies.ts; this fails if a source component changed without regenerating.
80
+ - name: Check MCP tool-body schemas (inline, $ref-free)
81
+ run: npm run check:mcp-bodies
82
+
75
83
  # Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
76
84
  # TypeScript types on the fly (Node >= 22.6) — no build step.
77
85
  - name: Test (Node)
@@ -77,6 +77,13 @@ jobs:
77
77
  - name: Check navigation index freshness
78
78
  run: npm run sync:nav:check
79
79
 
80
+ # Projected MCP tool bodies (openapi.yaml) are a checked-in derived artifact too (epic #605 S0):
81
+ # each is DERIVED from `components.schemas` by scripts/inline-mcp-bodies.ts. Two branches can
82
+ # each edit a source component and its inline body in isolation yet leave the merged tree stale;
83
+ # re-assert freshness on the merged tree so a $ref cannot silently re-leak into the tool surface.
84
+ - name: Check MCP tool-body schemas freshness
85
+ run: npm run check:mcp-bodies
86
+
80
87
  # Backstop: catch ANY other committed generated file that the merged sources render stale, even
81
88
  # one without its own `--check` script above. A clean tree is the whole-repo invariant.
82
89
  - name: No stale committed artifacts on the merged tree
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.159.0](https://github.com/nanobpm/nano-workforce/compare/v0.158.1...v0.159.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **openapi:** self-contained, $ref-free projected MCP tool schemas (S0) ([#614](https://github.com/nanobpm/nano-workforce/issues/614)) ([2018020](https://github.com/nanobpm/nano-workforce/commit/2018020a290c2f416e703e3584b27f92ccf27753)), closes [#605](https://github.com/nanobpm/nano-workforce/issues/605) [nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) [503/#504](https://github.com/503/nano-workforce/issues/504) [#606](https://github.com/nanobpm/nano-workforce/issues/606)
6
+
7
+ ## [0.158.1](https://github.com/nanobpm/nano-workforce/compare/v0.158.0...v0.158.1) (2026-08-29)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **cockpit:** derive relay URL from import.meta.url, not location.host ([#600](https://github.com/nanobpm/nano-workforce/issues/600)) ([#612](https://github.com/nanobpm/nano-workforce/issues/612)) ([69923fa](https://github.com/nanobpm/nano-workforce/commit/69923fad688aaa849302be8a1906dd7c6f938c49))
12
+
1
13
  ## [0.158.0](https://github.com/nanobpm/nano-workforce/compare/v0.157.0...v0.158.0) (2026-08-29)
2
14
 
3
15
  ### Features
@@ -164,23 +164,47 @@ test("relay output is written to the drilled worker's terminal", async () => {
164
164
  assert.deepEqual(r.terminalWrites, ["boot\n"]);
165
165
  });
166
166
 
167
- test("a live drill shows a 'waiting for output' note until the first frame, then clears it", async () => {
167
+ test("a live drill reads 'connecting', promotes to 'waiting' on the subscribe ack, then clears on the first frame", async () => {
168
168
  const r = rig();
169
169
  const cockpit = bootSupplyCockpit(r.env);
170
170
  await cockpit.refresh();
171
171
  const note = () => r.host.byClass("cockpit-terminal-note")[0];
172
172
 
173
173
  cockpit.drill("wk-a");
174
+ // Before the socket opens/subscribes the note honestly reads "connecting" — not a false "connected".
175
+ assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "note armed as connecting before the socket opens");
176
+ assert.match(note()?.textContent ?? "", /connecting/i);
177
+
174
178
  r.sockets[0]?.fireOpen();
175
- assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note armed on a connected-but-quiet stream");
179
+ // The hub ACKs the subscribe → the note promotes to "waiting for live output" on a connected-but-quiet stream.
180
+ r.sockets[0]?.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "wk-a", gap: false, nextOffset: 0 } });
181
+ assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note promoted to waiting once the subscribe is acked");
176
182
  assert.match(note()?.textContent ?? "", /waiting for live output/i);
177
183
 
178
- r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
184
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
179
185
  assert.equal(note()?.getAttribute("data-terminal-note"), "none", "note cleared the instant the first frame is written");
180
186
  assert.equal(note()?.textContent, "");
181
187
  });
182
188
 
183
- test("switching to a new stream re-arms the 'waiting' note (the prior stream's cleared note does not persist)", async () => {
189
+ test("a drill whose socket never opens surfaces 'connecting', never a false 'connected'/'waiting' (#600)", async () => {
190
+ const r = rig();
191
+ const cockpit = bootSupplyCockpit(r.env);
192
+ await cockpit.refresh();
193
+ const note = () => r.host.byClass("cockpit-terminal-note")[0];
194
+
195
+ // A dead relay target (the #600 symptom: reconnect-looping, never subscribed) — the socket is created
196
+ // but never fires open, so the subscribe is never acked. The note must NOT claim the stream connected.
197
+ cockpit.drill("wk-a");
198
+ assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "a socket that never opens reads as connecting, not connected");
199
+ assert.match(note()?.textContent ?? "", /connecting/i);
200
+ assert.doesNotMatch(
201
+ note()?.textContent ?? "",
202
+ /connected|waiting for live output/i,
203
+ "a dead socket must not masquerade as a connected-but-quiet stream (#600)",
204
+ );
205
+ });
206
+
207
+ test("switching to a new stream re-arms the note (the prior stream's cleared note does not persist)", async () => {
184
208
  const r = rig();
185
209
  const cockpit = bootSupplyCockpit(r.env);
186
210
  await cockpit.refresh();
@@ -192,7 +216,7 @@ test("switching to a new stream re-arms the 'waiting' note (the prior stream's c
192
216
  assert.equal(note()?.getAttribute("data-terminal-note"), "none");
193
217
 
194
218
  cockpit.drill("wk-b");
195
- assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "the new drill re-arms the waiting note");
219
+ assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "the new drill re-arms the note (connecting), not stuck on the prior stream's cleared none");
196
220
  });
197
221
 
198
222
  test("the terminal survives a list refresh — it is not re-mounted and keeps streaming", async () => {
@@ -314,15 +314,15 @@ class SupplyCockpit implements SupplyCockpitHandle {
314
314
  if (mode !== "replay") this.#structuredRegion?.replaceChildren();
315
315
  }
316
316
 
317
- /** Show (or clear) the terminal status note — the "connected, waiting for output" affordance. */
318
- #setNote(text: string | undefined): void {
317
+ /** Show (or clear) the terminal status note — the "connecting" / "waiting for output" affordance. */
318
+ #setNote(text: string | undefined, state: "connecting" | "waiting" = "waiting"): void {
319
319
  if (text === undefined) {
320
320
  this.#terminalNote.textContent = "";
321
321
  this.#terminalNote.setAttribute("data-terminal-note", "none");
322
322
  return;
323
323
  }
324
324
  this.#terminalNote.textContent = text;
325
- this.#terminalNote.setAttribute("data-terminal-note", "waiting");
325
+ this.#terminalNote.setAttribute("data-terminal-note", state);
326
326
  }
327
327
 
328
328
  async refresh(): Promise<void> {
@@ -528,7 +528,16 @@ class SupplyCockpit implements SupplyCockpitHandle {
528
528
  let session: TerminalSession | undefined;
529
529
  const client = new RelayChannelClient({
530
530
  connect: this.#env.connectRelay,
531
- onRelay: (message) => session?.handle(message),
531
+ onRelay: (message) => {
532
+ // Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
533
+ // then it honestly reads "connecting", so a socket that never opens/subscribes stops
534
+ // masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
535
+ // resubscribe ack does not re-arm the note after real output has already flowed.
536
+ if (!cleared && "op" in message && message.op === "subscribed") {
537
+ this.#setNote("Waiting for live output…", "waiting");
538
+ }
539
+ session?.handle(message);
540
+ },
532
541
  // Re-attach on EVERY (re)connect → resume-from-offset: the terminal survives a cockpit
533
542
  // reconnect without losing or double-writing output.
534
543
  onOpen: () => session?.attach(),
@@ -544,9 +553,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
544
553
  client.open();
545
554
  this.#drill = { stream, client };
546
555
  this.#setMode("live", stream);
547
- // Arm the "waiting for output" note (after #setMode, which clears it): shown until the first
548
- // frame is written, so a connected-but-quiet stream reads as "waiting", not "broken".
549
- this.#setNote("Connected waiting for live output…");
556
+ // Arm the note as "connecting" (after #setMode, which clears it) BEFORE the socket opens. It only
557
+ // becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
558
+ // first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
559
+ this.#setNote("Connecting…", "connecting");
550
560
  } catch (err) {
551
561
  // Building the new terminal failed AFTER the prior drill + terminal were already torn down
552
562
  // above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale
@@ -124,6 +124,24 @@ cockpit *is* the approval (ADR 0005 Decision 7), so an agent cannot dispatch a d
124
124
  graph through MCP. Agents author graphs through the pure `compileDeliveryGraph` /
125
125
  `previewDeliveryGraph` doors, which stay exposed.
126
126
 
127
+ **Projected tool schemas are self-contained (epic #605, S0).** The projector copies each
128
+ operation's request-body schema *verbatim* into the tool's `inputSchema.properties.body`
129
+ and does **not** resolve `$ref`s, so every projected (non-`x-mcp`) request-body operation
130
+ in `openapi.yaml` presents an inline `type: object` body with no `$ref`; the two graph doors
131
+ additionally carry a worked `example` — an agent discovers the body shape (and calls the tool
132
+ with a real object, not a
133
+ stringified one) from the surface alone. The two graph doors split by convention:
134
+ `compileDeliveryGraph` takes the **structured `DeliveryGraph` object** (and *stages*);
135
+ `previewDeliveryGraph` takes the **text shape `{ "graphJson": "<serialized DeliveryGraph>" }`**
136
+ (and is *pure*). Every validation failure returns `issues`/`errors` as `[{ path, message }]`.
137
+ The inline bodies are **derived** from `components.schemas` by
138
+ `scripts/inline-mcp-bodies.ts` (single source of truth; run `npm run gen:mcp-bodies` after
139
+ editing a component), and `npm run check:mcp-bodies` + `test/mcp-tool-schemas.test.ts` (which
140
+ runs the real projector) fail CI if a `$ref` ever re-leaks. The upstream projector fix that
141
+ would make this mitigation unnecessary is tracked in
142
+ [nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) (#502 self-contained schemas,
143
+ #503 faithful object-body transport, #504 real-spec conformance guard).
144
+
127
145
  ## 5. Fallback
128
146
 
129
147
  Agents without MCP are unchanged — resolve the instance, then
@@ -131,3 +149,31 @@ Agents without MCP are unchanged — resolve the instance, then
131
149
  [`nano-workforce` skill](../skills/nano-workforce/SKILL.md), which fetches the same
132
150
  live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
133
151
  before.
152
+
153
+ ## 6. Regression harness — pin the MCP surface from nwf's side
154
+
155
+ The MCP projection layer (schema shape, argument encoding, session handshake) is
156
+ covered end-to-end by a reusable e2e harness (epic #605 slice S1, issue #607):
157
+ `e2e/support/mcp-harness.ts`. It boots a hermetic in-process instance and drives
158
+ the **real** `/app/mcp` endpoint over the full Streamable-HTTP client handshake —
159
+ `initialize` → capture `Mcp-Session-Id` → `notifications/initialized` →
160
+ `tools/list` → `tools/call` — asserting the client-visible contract every agent
161
+ depends on:
162
+
163
+ - every projected tool schema is `$ref`-free with an explicit `type` (a leaked
164
+ `$ref` is unresolvable in the MCP context);
165
+ - an object argument arrives **as an object**, never coerced to a string;
166
+ - validation failures answer uniformly with `issues[{path,message}]`;
167
+ - side-effecting calls stage nothing, so the suite is safe to re-run.
168
+
169
+ It runs in CI under `npm run e2e` (hermetic — no socket, no GitHub), so a
170
+ reintroduced `$ref` or a stringified object body fails the build instead of
171
+ reaching an agent.
172
+
173
+ **Extending it (new per-tool case).** Import `bootMcpHarness` from
174
+ `e2e/support/mcp-harness.ts` in your own `e2e/<slice>.e2e.ts` and drive
175
+ `harness.listTools()` / `harness.callTool(name, args)` — the handshake, session
176
+ management and teardown are owned by the harness, so you add a `test(...)`, never
177
+ a second transport. The module header documents the seam and the exported
178
+ assertion helpers (`assertSchemaSelfContained`, `assertObjectBodyAccepted`,
179
+ `assertValidationIssues`) in full; `e2e/mcp-surface.e2e.ts` is the worked example.
@@ -0,0 +1,235 @@
1
+ // MCP surface end-to-end regression net (epic #605 slice S1, issue #607).
2
+ //
3
+ // Drives the app's REAL runtime-served MCP endpoint (`/app/mcp`, ADR 0067) over the full
4
+ // Streamable-HTTP client handshake — `initialize` → `Mcp-Session-Id` → `notifications/initialized`
5
+ // → `tools/list` → `tools/call` — against a hermetic in-process instance, via the reusable
6
+ // `e2e/support/mcp-harness.ts` module. It PINS the client-visible contract the S0 defect broke:
7
+ //
8
+ // • every projected tool schema is `$ref`-free with an explicit `type` (S0 / nano-ide#502);
9
+ // • an object argument arrives AS AN OBJECT, never coerced to a string (S0 / nano-ide#503);
10
+ // • validation failures answer uniformly with `issues[{path,message}]`;
11
+ // • the reads parse; the mutating framework tools are gated; side-effecting calls leave NO live
12
+ // staged proposal behind (safe to run repeatedly).
13
+ //
14
+ // It is the harness siblings extend: S2 (#608), S4 (#610) and S5 (#611) add their own per-tool case
15
+ // by importing `bootMcpHarness` — they do NOT re-implement the handshake. See the module header of
16
+ // `support/mcp-harness.ts` for the extension seam.
17
+ //
18
+ // Run with `npm run e2e`.
19
+ import assert from "node:assert/strict";
20
+ import { after, before, describe, test } from "node:test";
21
+ import {
22
+ assertObjectBodyAccepted,
23
+ assertSchemaSelfContained,
24
+ assertValidationIssues,
25
+ bootMcpHarness,
26
+ type McpHarness,
27
+ type McpTool,
28
+ MINIMAL_VALID_GRAPH,
29
+ schemaHasRef,
30
+ STRINGIFIED_BODY_MESSAGE,
31
+ } from "./support/mcp-harness.ts";
32
+
33
+ // The read tools the surface must expose and answer (issue #607 scope). Each is safe and repeatable.
34
+ const READ_TOOLS = [
35
+ "getVersion",
36
+ "getAgentInstructions",
37
+ "listActivePrs",
38
+ "listStagedProposals",
39
+ "getLineage",
40
+ ] as const;
41
+
42
+ // The object-body doors covered by a `tools/call` (issue #607 scope). `previewDeliveryGraph` is the
43
+ // PURE positive proof (a valid graph, nothing staged). The rest are side-EFFECTING (they stage a
44
+ // proposal or start a process on a VALID body), so the harness drives them with a deliberately
45
+ // INVALID — but object-shaped — body: validation rejects it, which (a) proves the object argument
46
+ // reached the door AS AN OBJECT and (b) persists nothing, keeping the harness repeatable.
47
+ const OBJECT_BODY_DOORS = [
48
+ "compileDeliveryGraph",
49
+ "startConvergenceLoop",
50
+ "startPlanFanout",
51
+ "startEpicSet",
52
+ "startFeature",
53
+ "agentCompleteEscalation",
54
+ "appendBlackboard",
55
+ ] as const;
56
+
57
+ // S0-PENDING self-containment allowlist (issue #606 / upstream nano-ide#502).
58
+ // --------------------------------------------------------------------------
59
+ // S1 (this harness) and S0 (openapi.yaml restructuring) are the two WAVE-0 scaffold slices and land
60
+ // in parallel — so this test must be GREEN on `main` whether or not S0 has merged yet. On the
61
+ // pre-S0 spec these object-body tools still project a leaked `$ref` (`body: { $ref: <component> }`);
62
+ // S0 inlines them. For each, the audit tolerates EITHER the self-contained shape (post-S0) OR
63
+ // EXACTLY the one known pre-S0 `$ref` (this map's value) — and FAILS on any OTHER `$ref` shape (a
64
+ // novel leak, a wrong target). Once S0 lands, delete the graduated entries here so they fall under
65
+ // the hard self-containment assertion like every other tool. A tool NOT in this map is hard-asserted
66
+ // self-contained NOW — so reintroducing a `$ref` into any clean tool (or a sibling's NEW tool) fails
67
+ // the build immediately.
68
+ const KNOWN_PENDING_S0: Readonly<Record<string, string>> = {
69
+ compileDeliveryGraph: "#/components/schemas/DeliveryGraph",
70
+ previewDeliveryGraph: "#/components/schemas/DeliveryGraphPreviewSubmit",
71
+ startConvergenceLoop: "#/components/schemas/ConvergenceStart",
72
+ startPlanFanout: "#/components/schemas/PlanStart",
73
+ startEpicSet: "#/components/schemas/EpicSetStart",
74
+ startFeature: "#/components/schemas/FeatureStart",
75
+ agentCompleteEscalation: "#/components/schemas/AgentCompleteRequest",
76
+ appendBlackboard: "#/components/schemas/BlackboardAppendRequest",
77
+ saveToLibrary: "#/components/schemas/SaveToLibrarySubmit",
78
+ importToLibrary: "#/components/schemas/ImportToLibrarySubmit",
79
+ previewProposalBpmn: "#/components/schemas/DeliveryGraphProposalBpmnRequest",
80
+ enrolAgenticWorker: "#/components/schemas/EnrolRequest",
81
+ revertEscalationCompletion: "#/components/schemas/RevertCompletionRequest",
82
+ };
83
+
84
+ /** Collect every `$ref` string anywhere in a parsed JSON Schema. */
85
+ function collectRefs(schema: unknown, acc: string[] = []): string[] {
86
+ if (Array.isArray(schema)) {
87
+ for (const item of schema) collectRefs(item, acc);
88
+ } else if (schema && typeof schema === "object") {
89
+ for (const [key, value] of Object.entries(schema as Record<string, unknown>)) {
90
+ if (key === "$ref" && typeof value === "string") acc.push(value);
91
+ else collectRefs(value, acc);
92
+ }
93
+ }
94
+ return acc;
95
+ }
96
+
97
+ /** Audit one tool's projected schema against the S0 self-containment contract, tolerating exactly
98
+ * the one documented pre-S0 leak for a {@link KNOWN_PENDING_S0} tool (see that map's comment). */
99
+ function auditToolSchema(tool: McpTool): void {
100
+ const pendingRef = KNOWN_PENDING_S0[tool.name];
101
+ if (pendingRef === undefined) {
102
+ // Not pending an S0 fix → the schema must already be self-contained. This is the live guard that
103
+ // fails the build the moment a `$ref` is (re)introduced into a clean or newly-added tool.
104
+ assertSchemaSelfContained(tool.inputSchema, tool.name);
105
+ return;
106
+ }
107
+ // Pending an S0 fix: accept the post-S0 clean shape, OR the exact known pre-S0 `$ref`.
108
+ if (!schemaHasRef(tool.inputSchema)) {
109
+ assertSchemaSelfContained(tool.inputSchema, tool.name);
110
+ return;
111
+ }
112
+ const refs = collectRefs(tool.inputSchema);
113
+ const unexpected = refs.filter((r) => r !== pendingRef);
114
+ assert.equal(
115
+ unexpected.length,
116
+ 0,
117
+ `tool "${tool.name}": unexpected \`$ref\`(s) ${JSON.stringify(unexpected)} — only the known ` +
118
+ `pre-S0 leak "${pendingRef}" is tolerated (issue #606 / nano-ide#502). Any other \`$ref\` is a defect.`,
119
+ );
120
+ }
121
+
122
+ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S1 / #607)", () => {
123
+ let h: McpHarness;
124
+ let tools: McpTool[];
125
+ let toolNames: Set<string>;
126
+
127
+ before(async () => {
128
+ h = await bootMcpHarness();
129
+ tools = await h.listTools();
130
+ toolNames = new Set(tools.map((t) => t.name));
131
+ });
132
+
133
+ after(async () => {
134
+ await h?.stop();
135
+ });
136
+
137
+ test("initialize handshake succeeds and tools/list projects the covered tools", () => {
138
+ assert.ok(h.sessionId, "the initialize handshake must yield an Mcp-Session-Id");
139
+ assert.ok(tools.length > 0, "tools/list must project at least one tool");
140
+ for (const name of [...READ_TOOLS, ...OBJECT_BODY_DOORS, "previewDeliveryGraph"]) {
141
+ assert.ok(toolNames.has(name), `tools/list must expose "${name}"`);
142
+ }
143
+ // The operator-only doors stay OFF the MCP surface (ADR 0067 §2 — `x-mcp` excluded).
144
+ for (const excluded of ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"]) {
145
+ assert.ok(!toolNames.has(excluded), `"${excluded}" is operator-only and must NOT be projected`);
146
+ }
147
+ });
148
+
149
+ test("every projected tool schema is $ref-free with an explicit type (S0 contract)", () => {
150
+ for (const tool of tools) auditToolSchema(tool);
151
+ });
152
+
153
+ test("read tools answer with parseable responses", async () => {
154
+ for (const name of READ_TOOLS) {
155
+ const res = await h.callTool(name, {});
156
+ assert.ok(!res.isError, `read "${name}" must not error: ${res.text}`);
157
+ assert.ok(res.text.length > 0, `read "${name}" must return content`);
158
+ // Every read but the markdown guide answers JSON; the guide answers a non-empty string.
159
+ if (name !== "getAgentInstructions") {
160
+ assert.notEqual(res.json, undefined, `read "${name}" must return parseable JSON: ${res.text.slice(0, 120)}`);
161
+ }
162
+ }
163
+ });
164
+
165
+ test("previewDeliveryGraph accepts a structured object body and stays pure (nothing staged)", async () => {
166
+ const res = await h.callTool("previewDeliveryGraph", { body: { graphJson: JSON.stringify(MINIMAL_VALID_GRAPH) } });
167
+ assertObjectBodyAccepted(res, "previewDeliveryGraph");
168
+ assert.ok(!res.isError, `previewDeliveryGraph must compile a valid graph: ${res.text}`);
169
+ const json = res.json as { ok?: boolean; staged?: boolean } | undefined;
170
+ assert.equal(json?.ok, true, `previewDeliveryGraph must report ok:true: ${res.text}`);
171
+ assert.equal(json?.staged, false, "previewDeliveryGraph is a PURE preview — it must never stage");
172
+ });
173
+
174
+ test("object-body doors receive the argument as an object, not a string (uniform validation)", async () => {
175
+ // A deliberately-invalid-but-object body per door: validation rejects it (persisting nothing),
176
+ // which proves the object argument reached the door AS AN OBJECT — never the S0 stringified body.
177
+ for (const name of OBJECT_BODY_DOORS) {
178
+ const args = name === "appendBlackboard" ? { token: "harness-invalid", body: {} } : { body: {} };
179
+ const res = await h.callTool(name, args);
180
+ assert.ok(res.isError, `door "${name}" must reject an empty body with a validation error`);
181
+ assertValidationIssues(res, name); // also asserts the object body was NOT stringified
182
+ }
183
+ });
184
+
185
+ test("mutating framework tools are gated without the shared secret (set-variables)", async () => {
186
+ const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
187
+ assert.ok(res.isError, "urban_debug_set_variables must refuse a credential-free mutation");
188
+ assert.match(
189
+ res.text,
190
+ /shared secret|allowMutations/i,
191
+ `the refusal must name the guard: ${res.text}`,
192
+ );
193
+ });
194
+
195
+ // The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing either half of the S0
196
+ // defect makes the harness fail. These pin the detector's teeth independently of whether S0 has
197
+ // landed — so the guard cannot silently rot into a no-op.
198
+ describe("reintroducing the S0 defect fails the build", () => {
199
+ test("a $ref in a tool schema is caught by the self-containment assertion", () => {
200
+ const good = { type: "object", properties: { body: { type: "object", properties: { n: { type: "number" } } } } };
201
+ assert.doesNotThrow(() => assertSchemaSelfContained(good, "synthetic-clean"));
202
+ const withRef = { type: "object", properties: { body: { $ref: "#/components/schemas/DeliveryGraph" } }, required: ["body"] };
203
+ assert.throws(() => assertSchemaSelfContained(withRef, "synthetic-ref"), /\$ref/, "a reintroduced $ref must throw");
204
+ });
205
+
206
+ test("a typeless schema is caught by the self-containment assertion", () => {
207
+ const typeless = { properties: { body: { type: "object" } } };
208
+ assert.throws(() => assertSchemaSelfContained(typeless, "synthetic-typeless"), /type/, "a typeless schema must throw");
209
+ });
210
+
211
+ test("a stringified object body is rejected by the door and caught by assertObjectBodyAccepted", async () => {
212
+ // Simulate the S0 client coercion: send the body as a JSON STRING instead of an object.
213
+ const res = await h.callTool("compileDeliveryGraph", { body: JSON.stringify(MINIMAL_VALID_GRAPH) });
214
+ assert.ok(res.isError, "a stringified object body must be rejected by the door");
215
+ assert.ok(
216
+ res.text.includes(STRINGIFIED_BODY_MESSAGE),
217
+ `the door must report "${STRINGIFIED_BODY_MESSAGE}": ${res.text}`,
218
+ );
219
+ // The harness's guard must recognize that signature as a failure.
220
+ assert.throws(
221
+ () => assertObjectBodyAccepted(res, "compileDeliveryGraph"),
222
+ /stringified/,
223
+ "assertObjectBodyAccepted must flag a stringified-body result",
224
+ );
225
+ });
226
+ });
227
+
228
+ test("side-effecting calls leave no live staged proposals behind (repeatable)", async () => {
229
+ const res = await h.callTool("listStagedProposals", {});
230
+ assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
231
+ const json = res.json as { count?: number; proposals?: unknown[] } | undefined;
232
+ assert.equal(json?.count, 0, `the harness must stage nothing: ${res.text}`);
233
+ assert.deepEqual(json?.proposals, [], "no live staged proposals may remain");
234
+ });
235
+ });