@nanobpm/nano-workforce 0.171.9 → 0.171.11
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 +12 -0
- package/app/contracts.ts +2 -2
- package/app/repoEnvelope.ts +14 -9
- package/app/service.test.ts +11 -7
- package/docs/mcp-runbook.md +35 -0
- package/e2e/mcp-surface.e2e.ts +76 -2
- package/e2e/support/mcp-harness.ts +15 -5
- package/openapi.yaml +10 -3
- package/package.json +1 -1
- package/pages/overview.page.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.11](https://github.com/nanobpm/nano-workforce/compare/v0.171.10...v0.171.11) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **mcp:** declare x-nano-secret-env on hookSecret so urban_debug_* mutations authorize remotely ([#700](https://github.com/nanobpm/nano-workforce/issues/700)) ([bd46ac6](https://github.com/nanobpm/nano-workforce/commit/bd46ac6e235fc9a13cab27351108cea9570f387b)), closes [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#698](https://github.com/nanobpm/nano-workforce/issues/698) [pre-#698](https://github.com/nanobpm/pre-/issues/698)
|
|
6
|
+
|
|
7
|
+
## [0.171.10](https://github.com/nanobpm/nano-workforce/compare/v0.171.9...v0.171.10) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **service:** emit world-restore SHA under harness-read `sha` key ([#696](https://github.com/nanobpm/nano-workforce/issues/696)) ([9794994](https://github.com/nanobpm/nano-workforce/commit/97949948913087c84f79bf0fcaa283c85a7cab0e)), closes [#324](https://github.com/nanobpm/nano-workforce/issues/324) [#695](https://github.com/nanobpm/nano-workforce/issues/695)
|
|
12
|
+
|
|
1
13
|
## [0.171.9](https://github.com/nanobpm/nano-workforce/compare/v0.171.8...v0.171.9) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -384,9 +384,9 @@ export const WIRE_CONTRACTS = {
|
|
|
384
384
|
name: "io.nanobpm.agentTask.repository",
|
|
385
385
|
owner: "app/repoEnvelope.ts",
|
|
386
386
|
semantics:
|
|
387
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `
|
|
387
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
388
388
|
shape:
|
|
389
|
-
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string,
|
|
389
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, sha?: string, branch?: { create: string } }',
|
|
390
390
|
},
|
|
391
391
|
"epicSet.submit": {
|
|
392
392
|
category: "wire",
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -36,10 +36,12 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
|
36
36
|
* that base reachable for the diff.
|
|
37
37
|
*
|
|
38
38
|
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
39
|
-
*
|
|
40
|
-
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the
|
|
41
|
-
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a
|
|
42
|
-
* have moved.
|
|
39
|
+
* the last pushed SHA is emitted under the `sha` key so a REPLACEMENT activation (a fresh worktree
|
|
40
|
+
* after a lease loss) reconstructs the working tree to the EXACT pushed SHA — the inversion of the
|
|
41
|
+
* round's outbound `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a
|
|
42
|
+
* branch tip that may have moved. The key is `sha` (not `commitSha`) because that is the field the
|
|
43
|
+
* c8ctl worker harness actually reads to drive the checkout; the earlier `commitSha` key was a silent
|
|
44
|
+
* no-op (issue #695). Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
43
45
|
* head branch normally.
|
|
44
46
|
*
|
|
45
47
|
* Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
@@ -80,13 +82,16 @@ export function repoEnvelopeVars(
|
|
|
80
82
|
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
81
83
|
...(baseRef ? { baseRef } : {}),
|
|
82
84
|
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
83
|
-
// working tree to (inverting the round's push into a fetch+checkout).
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
85
|
+
// working tree to (inverting the round's push into a fetch+checkout). Emitted under the key
|
|
86
|
+
// `sha` — the field the c8ctl worker harness actually reads (`provisionRepo` normalizes
|
|
87
|
+
// `repo.sha` and drives `git fetch origin <sha>` + `git checkout --detach <sha>` off it); a
|
|
88
|
+
// prior `commitSha` key was a silent no-op the harness never read (issue #695). Only emitted
|
|
89
|
+
// when the value is a well-formed 40-hex commit SHA: it is forwarded to the harness as an
|
|
90
|
+
// EXACT checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to
|
|
91
|
+
// an unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
87
92
|
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
88
93
|
// when the PR has no durable push-checkpoint yet.
|
|
89
|
-
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
94
|
+
...(isCommitSha(commitSha) ? { sha: commitSha } : {}),
|
|
90
95
|
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
91
96
|
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
92
97
|
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
package/app/service.test.ts
CHANGED
|
@@ -534,20 +534,24 @@ test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", ()
|
|
|
534
534
|
}
|
|
535
535
|
});
|
|
536
536
|
|
|
537
|
-
test("repoEnvelopeVars emits
|
|
537
|
+
test("repoEnvelopeVars emits the push-checkpoint under the harness-read `sha` key, only for a well-formed 40-hex SHA (world-restore, #324/#695)", () => {
|
|
538
538
|
const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
|
|
539
539
|
const env = (repoEnvelopeVars("owner/repo", "feat/x", "main", sha) as any)["io.nanobpm.agentTask"];
|
|
540
|
-
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
540
|
+
// The emitted key MUST be `sha` — the field the c8ctl harness `provisionRepo` reads to drive
|
|
541
|
+
// `git fetch origin <sha>` + `git checkout --detach <sha>`. A prior `commitSha` key was a silent
|
|
542
|
+
// no-op the harness never read (issue #695), so guard the exact wire name here, not just presence.
|
|
543
|
+
assertEquals(env.repository.sha, sha, "a valid 40-hex SHA is threaded through under the harness-read `sha` key");
|
|
544
|
+
assertEquals("commitSha" in env.repository, false, "the retired `commitSha` key must never be emitted (silent no-op, #695)");
|
|
545
|
+
// A non-SHA ref, an abbreviated SHA, or a whitespace-tainted value is dropped (no `sha` key): it is
|
|
546
|
+
// forwarded to the harness as an EXACT checkout target, so a bad value could reconstruct to a moved
|
|
547
|
+
// branch tip or fail provisioning. Omission degrades to the pre-#324 head-branch-tip clone.
|
|
544
548
|
for (const bad of ["main", "feat/x", "77ee099", `${sha} `, ` ${sha}`, `${sha}\n`, "z".repeat(40), `${sha}0`, ""]) {
|
|
545
549
|
const r = (repoEnvelopeVars("owner/repo", "feat/x", "main", bad) as any)["io.nanobpm.agentTask"].repository;
|
|
546
|
-
assertEquals("
|
|
550
|
+
assertEquals("sha" in r, false, `expected no sha for "${JSON.stringify(bad)}"`);
|
|
547
551
|
}
|
|
548
552
|
// Omitted entirely when there is no checkpoint SHA at all (the common first-activation case).
|
|
549
553
|
const none = (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository;
|
|
550
|
-
assertEquals("
|
|
554
|
+
assertEquals("sha" in none, false);
|
|
551
555
|
});
|
|
552
556
|
|
|
553
557
|
// Pre-PR provisioning (issue #684): the implementation path has no head branch yet, so it passes
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -133,6 +133,41 @@ header on **both reads and mutations** — read endpoints like `GET /app/api/age
|
|
|
133
133
|
`/app/mcp` follows the same `network.bind` manifest setting as the rest of the app's
|
|
134
134
|
HTTP surface.
|
|
135
135
|
|
|
136
|
+
### Framework mutation guard — `urban_debug_*` mutations need `x-hook-secret` too
|
|
137
|
+
|
|
138
|
+
The framework-owned **mutating** `urban_debug_*` tools (`set_variables`, `retry_job`,
|
|
139
|
+
`resolve_incident`, `cancel_instance`) are gated by the Urban runtime's *own* mutation
|
|
140
|
+
guard (`authorizeMutation`), which is separate from nwf's app-operation guard above. It
|
|
141
|
+
authorizes a mutation one of two ways:
|
|
142
|
+
|
|
143
|
+
1. **Loopback bypass** — `URBAN_MCP_ALLOW_MUTATIONS=true` **and** `mcp.allowRemote` off.
|
|
144
|
+
This is the credential-free, local-only path; it is disabled the moment the surface is
|
|
145
|
+
remote-exposed (`allowRemote` on), by design — the guard never drops for a non-loopback
|
|
146
|
+
caller.
|
|
147
|
+
2. **Shared-secret scheme** — an apiKey *header* security scheme that declares an
|
|
148
|
+
`x-nano-secret-env` extension naming the env var holding the secret. nwf declares this
|
|
149
|
+
on **`hookSecret`** (`x-nano-secret-env: NANO_PR_WEBHOOK_SECRET`), so a mutating
|
|
150
|
+
`urban_debug_*` call carrying `x-hook-secret: <NANO_PR_WEBHOOK_SECRET>` is authorized on
|
|
151
|
+
a remote-exposed instance.
|
|
152
|
+
|
|
153
|
+
This is the **same secret and same header** as nwf's app-operation guard (both key on
|
|
154
|
+
`NANO_PR_WEBHOOK_SECRET` via `x-hook-secret`), so reads and mutations — app-operation and
|
|
155
|
+
framework — share one credential. The MCP server entry's `headers` already carries it (§1);
|
|
156
|
+
nothing extra is needed for `urban_debug_*` mutations once the secret is set.
|
|
157
|
+
|
|
158
|
+
**Fail-closed caveat.** When `NANO_PR_WEBHOOK_SECRET` is **unset** there is no credential to
|
|
159
|
+
present, so on a remote-exposed instance (`allowRemote` on) framework mutations remain
|
|
160
|
+
**closed** — this is correct fail-closed behavior, not a regression. Because the scheme is now
|
|
161
|
+
*declared* (it names the env var) but the secret is absent, the runtime surfaces the attempt as a
|
|
162
|
+
`500` *security-misconfigured* ("secret env `NANO_PR_WEBHOOK_SECRET` is not set") rather than a
|
|
163
|
+
plain refusal — either way no mutation occurs. An operator who needs remote mutation repair (e.g.
|
|
164
|
+
patching a wedged instance's variable + retrying a `JOB_NO_RETRIES` job) must set
|
|
165
|
+
`NANO_PR_WEBHOOK_SECRET`; then a `urban_debug_*` mutation carrying `x-hook-secret: <secret>`
|
|
166
|
+
succeeds, while a missing or wrong header `401`s. The loopback bypass
|
|
167
|
+
(`URBAN_MCP_ALLOW_MUTATIONS=true` with `allowRemote` off) remains the credential-free
|
|
168
|
+
local-only alternative. Only `hookSecret` may carry `x-nano-secret-env` — the runtime
|
|
169
|
+
throws on more than one shared-secret scheme.
|
|
170
|
+
|
|
136
171
|
**Operator-only doors stay operator-only.** The staged delivery-graph lifecycle —
|
|
137
172
|
`stageDeliveryGraph`, `dispatchDeliveryGraph`, `dismissProposal` — is `x-mcp`-excluded
|
|
138
173
|
from the projected tool surface (ADR 0067 §2): the human clicking **Dispatch** in the
|
package/e2e/mcp-surface.e2e.ts
CHANGED
|
@@ -184,12 +184,18 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
test("mutating framework tools are gated without the shared secret (set-variables)", async () => {
|
|
187
|
+
// This harness boots WITHOUT `NANO_PR_WEBHOOK_SECRET`. Since #698 declares the `hookSecret`
|
|
188
|
+
// shared-secret scheme (`x-nano-secret-env`), a remote-exposed mutation now fails CLOSED
|
|
189
|
+
// DETERMINISTICALLY as a misconfiguration ("secret env … is not set") — the credential the guard
|
|
190
|
+
// requires cannot exist until the operator sets the env var. Pin exactly that new shape (NOT the
|
|
191
|
+
// pre-#698 no-scheme "shared secret"/"allowMutations" refusal), so the test actually proves #698's
|
|
192
|
+
// `x-nano-secret-env` declaration took effect rather than accepting the old behavior.
|
|
187
193
|
const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
|
|
188
194
|
assert.ok(res.isError, "urban_debug_set_variables must refuse a credential-free mutation");
|
|
189
195
|
assert.match(
|
|
190
196
|
res.text,
|
|
191
|
-
/
|
|
192
|
-
`the refusal must
|
|
197
|
+
/secret env .* is not set|misconfigured/i,
|
|
198
|
+
`the refusal must be the #698 misconfiguration shape: ${res.text}`,
|
|
193
199
|
);
|
|
194
200
|
});
|
|
195
201
|
|
|
@@ -264,3 +270,71 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
|
|
|
264
270
|
assert.deepEqual(json?.proposals, [], "no live staged proposals may remain");
|
|
265
271
|
});
|
|
266
272
|
});
|
|
273
|
+
|
|
274
|
+
// Framework mutation-guard authorization (issue #698).
|
|
275
|
+
// --------------------------------------------------------------------------
|
|
276
|
+
// The framework-owned mutating `urban_debug_*` tools (set_variables/retry_job/resolve_incident/
|
|
277
|
+
// cancel_instance) are gated by @nanobpm/urban's OWN mutation guard (`authorizeMutation`), distinct
|
|
278
|
+
// from nwf's app-operation guard. On a REMOTE-exposed instance (the harness always boots with
|
|
279
|
+
// `URBAN_MCP_ALLOW_REMOTE: "true"`) the loopback bypass is off, so the ONLY door left is the
|
|
280
|
+
// shared-secret apiKey scheme — which the guard recognizes only when an apiKey *header* scheme
|
|
281
|
+
// declares `x-nano-secret-env`. nwf now declares `x-nano-secret-env: NANO_PR_WEBHOOK_SECRET` on the
|
|
282
|
+
// `hookSecret` scheme (this issue), so a mutating call carrying `x-hook-secret: <secret>` is
|
|
283
|
+
// authorized past the guard, while a missing/wrong header stays 401. This pins that contract.
|
|
284
|
+
const HOOK_SECRET = "issue-698-shared-secret";
|
|
285
|
+
|
|
286
|
+
describe("MCP surface e2e — framework mutation guard authorizes with the shared secret (#698)", () => {
|
|
287
|
+
let h: McpHarness;
|
|
288
|
+
|
|
289
|
+
before(async () => {
|
|
290
|
+
// Remote-exposed (harness default) + a shared secret set: the exact condition of #698, where the
|
|
291
|
+
// loopback bypass is off and the shared-secret scheme is the only authorization door.
|
|
292
|
+
h = await bootMcpHarness({ env: { NANO_PR_WEBHOOK_SECRET: HOOK_SECRET } });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
after(async () => {
|
|
296
|
+
await h?.stop();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a mutating urban_debug_* call is refused WITHOUT the shared-secret header", async () => {
|
|
300
|
+
const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
|
|
301
|
+
assert.ok(res.isError, "a credential-free mutation must be refused on a remote-exposed instance");
|
|
302
|
+
assert.match(
|
|
303
|
+
res.text,
|
|
304
|
+
/unauthorized|401/i,
|
|
305
|
+
`a missing shared-secret header must 401: ${res.text}`,
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a mutating urban_debug_* call is refused WITH A WRONG shared-secret header", async () => {
|
|
310
|
+
const res = await h.callTool(
|
|
311
|
+
"urban_debug_set_variables",
|
|
312
|
+
{ processInstanceKey: "1", variables: {} },
|
|
313
|
+
{ "x-hook-secret": "not-the-secret" },
|
|
314
|
+
);
|
|
315
|
+
assert.ok(res.isError, "a wrong-secret mutation must still be refused");
|
|
316
|
+
assert.match(
|
|
317
|
+
res.text,
|
|
318
|
+
/unauthorized|401/i,
|
|
319
|
+
`a wrong shared-secret header must 401: ${res.text}`,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("a mutating urban_debug_* call carrying the correct x-hook-secret is authorized past the guard", async () => {
|
|
324
|
+
const res = await h.callTool(
|
|
325
|
+
"urban_debug_set_variables",
|
|
326
|
+
{ processInstanceKey: "1", variables: {} },
|
|
327
|
+
{ "x-hook-secret": HOOK_SECRET },
|
|
328
|
+
);
|
|
329
|
+
// The correct credential clears the shared-secret guard. The call may still fail downstream (the
|
|
330
|
+
// hermetic engine has no instance `1`), but it must NO LONGER be the shared-secret refusal NOR a
|
|
331
|
+
// generic authorization failure (401/unauthorized) — excluding those is the falsifiable proof the
|
|
332
|
+
// `x-nano-secret-env` declaration made the scheme authorizable (rather than the credential silently
|
|
333
|
+
// still being rejected).
|
|
334
|
+
assert.doesNotMatch(
|
|
335
|
+
res.text,
|
|
336
|
+
/shared secret|allowMutations|NO_SHARED_SECRET|unauthorized|401/i,
|
|
337
|
+
`the authorized call must clear the shared-secret guard, got: ${res.text}`,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -125,8 +125,9 @@ export interface McpHarness {
|
|
|
125
125
|
readonly sessionId: string;
|
|
126
126
|
/** `tools/list` — the projected tool catalogue (app operations + framework debug tools). */
|
|
127
127
|
listTools(): Promise<McpTool[]>;
|
|
128
|
-
/** `tools/call` — invoke a tool by name with its argument object.
|
|
129
|
-
|
|
128
|
+
/** `tools/call` — invoke a tool by name with its argument object. Optional `extraHeaders` are
|
|
129
|
+
* overlaid on the POST (e.g. an `x-hook-secret` shared-secret credential for a gated mutation). */
|
|
130
|
+
callTool(name: string, args?: Record<string, unknown>, extraHeaders?: Record<string, string>): Promise<McpToolResult>;
|
|
130
131
|
/** A raw JSON-RPC request against `/app/mcp` (escape hatch for a bespoke case). `params` omitted →
|
|
131
132
|
* no `params` field; a `notifications/*` method is sent as a notification (no `id`, no response). */
|
|
132
133
|
rpc(method: string, params?: unknown): Promise<McpRpcResult>;
|
|
@@ -175,13 +176,22 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
let idCounter = 0;
|
|
178
|
-
const rpc = async (
|
|
179
|
+
const rpc = async (
|
|
180
|
+
method: string,
|
|
181
|
+
params?: unknown,
|
|
182
|
+
sessionId?: string,
|
|
183
|
+
extraHeaders?: Record<string, string>,
|
|
184
|
+
): Promise<McpRpcResult> => {
|
|
179
185
|
const headers: Record<string, string> = {
|
|
180
186
|
"content-type": "application/json",
|
|
181
187
|
// The Streamable-HTTP transport inspects Accept; a real client offers both even when the
|
|
182
188
|
// server answers JSON (the runtime sets `enableJsonResponse`).
|
|
183
189
|
accept: "application/json, text/event-stream",
|
|
184
190
|
};
|
|
191
|
+
// Apply caller-supplied overlay headers FIRST so the session header stays authoritative — a
|
|
192
|
+
// caller passing auth headers (e.g. `x-hook-secret`) must not be able to clobber `mcp-session-id`
|
|
193
|
+
// and break the MCP handshake for this request.
|
|
194
|
+
if (extraHeaders) Object.assign(headers, extraHeaders);
|
|
185
195
|
if (sessionId) headers[SESSION_HEADER] = sessionId;
|
|
186
196
|
const isNotification = method.startsWith("notifications/");
|
|
187
197
|
const message: Record<string, unknown> = { jsonrpc: "2.0", method };
|
|
@@ -252,8 +262,8 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
252
262
|
}
|
|
253
263
|
return body.result.tools;
|
|
254
264
|
},
|
|
255
|
-
async callTool(name, args = {}): Promise<McpToolResult> {
|
|
256
|
-
const res = await rpc("tools/call", { name, arguments: args }, sessionId);
|
|
265
|
+
async callTool(name, args = {}, extraHeaders): Promise<McpToolResult> {
|
|
266
|
+
const res = await rpc("tools/call", { name, arguments: args }, sessionId, extraHeaders);
|
|
257
267
|
const body = res.body as
|
|
258
268
|
| { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
|
|
259
269
|
| undefined;
|
package/openapi.yaml
CHANGED
|
@@ -24,9 +24,16 @@ components:
|
|
|
24
24
|
type: apiKey
|
|
25
25
|
in: header
|
|
26
26
|
name: x-hook-secret
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
x-nano-secret-env: NANO_PR_WEBHOOK_SECRET
|
|
28
|
+
description: Shared secret. Enforced by the delegate (NOT the runtime) on this app's own
|
|
29
|
+
operations only when NANO_PR_WEBHOOK_SECRET is set; unset means those endpoints are open.
|
|
30
|
+
The `x-nano-secret-env` extension additionally makes this the app's canonical shared-secret
|
|
31
|
+
scheme for the Urban runtime's framework mutation guard (`authorizeMutation`), so the
|
|
32
|
+
framework-owned mutating `urban_debug_*` MCP tools (set_variables, retry_job,
|
|
33
|
+
resolve_incident, cancel_instance) are authorizable on a remote-exposed instance by
|
|
34
|
+
presenting the `x-hook-secret` header set to NANO_PR_WEBHOOK_SECRET. One shared secret, one
|
|
35
|
+
header, for both app-operation reads/mutations and framework mutations. Must remain the sole
|
|
36
|
+
apiKey scheme carrying `x-nano-secret-env` — the runtime throws on ambiguity.
|
|
30
37
|
schemas:
|
|
31
38
|
ErrorBody:
|
|
32
39
|
type: object
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.11",
|
|
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",
|
package/pages/overview.page.json
CHANGED
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"title": "Point your agent at Nano Workforce",
|
|
85
85
|
"description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`, or its addressable companion `getAgentGuide(section?)` \u2014 preferred over the ~43KB blob to avoid tool-result overrun), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
|
|
86
86
|
"copyLabel": "Copy prompt",
|
|
87
|
-
"copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
|
|
87
|
+
"copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it (including the framework-owned mutating urban_debug_* tools \u2014 set_variables, retry_job, resolve_incident, cancel_instance \u2014 which authorize against this same x-hook-secret on a remote-exposed instance):\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
},
|