@nanobpm/nano-workforce 0.171.10 → 0.172.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/CHANGELOG.md +12 -0
- package/app/contracts.ts +10 -2
- package/app/repoEnvelope.ts +21 -0
- package/app/service.test.ts +34 -0
- 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.172.0](https://github.com/nanobpm/nano-workforce/compare/v0.171.11...v0.172.0) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **service:** emit repository.cloneTimeoutMs in the agent-task envelope ([#697](https://github.com/nanobpm/nano-workforce/issues/697)) ([0d8a358](https://github.com/nanobpm/nano-workforce/commit/0d8a3582769f6561d647563168a8a50d0d54f3df)), closes [#694](https://github.com/nanobpm/nano-workforce/issues/694)
|
|
6
|
+
|
|
7
|
+
## [0.171.11](https://github.com/nanobpm/nano-workforce/compare/v0.171.10...v0.171.11) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.171.10](https://github.com/nanobpm/nano-workforce/compare/v0.171.9...v0.171.10) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -96,6 +96,14 @@ export const ENV_CONTRACTS = {
|
|
|
96
96
|
semantics: "Poller cadence in milliseconds for the self-scheduling reconciliation loop.",
|
|
97
97
|
default: "60000",
|
|
98
98
|
},
|
|
99
|
+
NANO_PR_CLONE_TIMEOUT_MS: {
|
|
100
|
+
category: "env",
|
|
101
|
+
name: "NANO_PR_CLONE_TIMEOUT_MS",
|
|
102
|
+
owner: "app/repoEnvelope.ts",
|
|
103
|
+
semantics:
|
|
104
|
+
"Clone timeout in milliseconds emitted as `repository.cloneTimeoutMs` in the agent-task envelope so the c8ctl harness raises its 120s default for large-repo provisioning (branch-scoped blobless clones of big monorepos still approach/exceed 120s; issue #694). Inherited by both the review-round and merge paths via the one `repoEnvelopeVars` builder.",
|
|
105
|
+
default: "600000",
|
|
106
|
+
},
|
|
99
107
|
NANO_PR_MAX_ROUNDS: {
|
|
100
108
|
category: "env",
|
|
101
109
|
name: "NANO_PR_MAX_ROUNDS",
|
|
@@ -384,9 +392,9 @@ export const WIRE_CONTRACTS = {
|
|
|
384
392
|
name: "io.nanobpm.agentTask.repository",
|
|
385
393
|
owner: "app/repoEnvelope.ts",
|
|
386
394
|
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 `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).",
|
|
395
|
+
"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) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). 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
396
|
shape:
|
|
389
|
-
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, sha?: string, branch?: { create: string } }',
|
|
397
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }',
|
|
390
398
|
},
|
|
391
399
|
"epicSet.submit": {
|
|
392
400
|
category: "wire",
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
|
|
9
9
|
// `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
|
|
10
10
|
// `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
|
|
11
|
+
import { readEnvOr } from "./contracts.ts";
|
|
11
12
|
import { isCommitSha } from "./world/index.ts";
|
|
12
13
|
|
|
13
14
|
/** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
|
|
@@ -78,6 +79,13 @@ export function repoEnvelopeVars(
|
|
|
78
79
|
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
79
80
|
singleBranch: true,
|
|
80
81
|
filter: "blob:none",
|
|
82
|
+
// Raise the harness's 120s default clone timeout for large-repo provisioning (issue #694).
|
|
83
|
+
// A branch-scoped blobless clone of a 24.6k-file monorepo (`camunda/camunda`) still
|
|
84
|
+
// approaches/exceeds 120s, so we emit `cloneTimeoutMs` (default 600000 = 10 min, via the
|
|
85
|
+
// `NANO_PR_CLONE_TIMEOUT_MS` knob) rather than let the harness fall back to its 120000ms
|
|
86
|
+
// default. Emitted on ALL paths from this one builder, so BOTH the review-round path
|
|
87
|
+
// (`service.ts` `submitPr`) and the merge path (`service.ts` `startMerge`) inherit it.
|
|
88
|
+
cloneTimeoutMs: cloneTimeoutMs(),
|
|
81
89
|
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
82
90
|
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
83
91
|
...(baseRef ? { baseRef } : {}),
|
|
@@ -105,3 +113,16 @@ export function repoEnvelopeVars(
|
|
|
105
113
|
},
|
|
106
114
|
};
|
|
107
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Resolve the clone timeout (ms) the harness applies to provisioning, from the one typed knob
|
|
118
|
+
* `NANO_PR_CLONE_TIMEOUT_MS` (default 600000 = 10 min; issue #694). A branch-scoped blobless clone
|
|
119
|
+
* of a large monorepo still approaches/exceeds the harness's 120s default, so we raise it here. A
|
|
120
|
+
* non-numeric or non-positive override degrades to the registered default rather than emitting a
|
|
121
|
+
* bogus (0 / NaN) timeout the harness would then treat as "use the 120s default". */
|
|
122
|
+
function cloneTimeoutMs(): number {
|
|
123
|
+
const raw = Number(readEnvOr("NANO_PR_CLONE_TIMEOUT_MS"));
|
|
124
|
+
return Number.isFinite(raw) && raw > 0 ? raw : Number(envDefaultCloneTimeoutMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The registered default, resolved once from the schema so the fallback stays single-sourced. */
|
|
128
|
+
const envDefaultCloneTimeoutMs = readEnvOr("NANO_PR_CLONE_TIMEOUT_MS", "600000", {});
|
package/app/service.test.ts
CHANGED
|
@@ -483,6 +483,38 @@ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch
|
|
|
483
483
|
assertEquals(env.repository.filter, "blob:none");
|
|
484
484
|
// The base branch is emitted so the harness fetches its tip, keeping `origin/<base>` reachable.
|
|
485
485
|
assertEquals(env.repository.baseRef, "main");
|
|
486
|
+
// The clone timeout is raised above the harness's 120s default for large-repo provisioning (#694).
|
|
487
|
+
assertEquals(env.repository.cloneTimeoutMs, 600000);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("repoEnvelopeVars emits cloneTimeoutMs from NANO_PR_CLONE_TIMEOUT_MS, default 600000 (#694)", () => {
|
|
491
|
+
// Default (knob unset): 600000ms = 10 min, raising the harness's 120000ms default so a
|
|
492
|
+
// branch-scoped blobless clone of a large monorepo provisions instead of dying at 120s.
|
|
493
|
+
const prev = process.env.NANO_PR_CLONE_TIMEOUT_MS;
|
|
494
|
+
delete process.env.NANO_PR_CLONE_TIMEOUT_MS;
|
|
495
|
+
try {
|
|
496
|
+
const def = (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"];
|
|
497
|
+
assertEquals(def.repository.cloneTimeoutMs, 600000);
|
|
498
|
+
// Override wins.
|
|
499
|
+
process.env.NANO_PR_CLONE_TIMEOUT_MS = "900000";
|
|
500
|
+
const over = (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"];
|
|
501
|
+
assertEquals(over.repository.cloneTimeoutMs, 900000);
|
|
502
|
+
// A non-positive / non-numeric override degrades to the registered default rather than emitting
|
|
503
|
+
// a bogus 0/NaN the harness would treat as "use the 120s default".
|
|
504
|
+
process.env.NANO_PR_CLONE_TIMEOUT_MS = "0";
|
|
505
|
+
assertEquals(
|
|
506
|
+
((repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"]).repository.cloneTimeoutMs,
|
|
507
|
+
600000,
|
|
508
|
+
);
|
|
509
|
+
process.env.NANO_PR_CLONE_TIMEOUT_MS = "notanumber";
|
|
510
|
+
assertEquals(
|
|
511
|
+
((repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"]).repository.cloneTimeoutMs,
|
|
512
|
+
600000,
|
|
513
|
+
);
|
|
514
|
+
} finally {
|
|
515
|
+
if (prev === undefined) delete process.env.NANO_PR_CLONE_TIMEOUT_MS;
|
|
516
|
+
else process.env.NANO_PR_CLONE_TIMEOUT_MS = prev;
|
|
517
|
+
}
|
|
486
518
|
});
|
|
487
519
|
|
|
488
520
|
test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () => {
|
|
@@ -492,6 +524,8 @@ test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () =>
|
|
|
492
524
|
assertEquals(env.repository.filter, "blob:none");
|
|
493
525
|
// …but `baseRef` is omitted entirely rather than emitted as null (no key at all).
|
|
494
526
|
assertEquals("baseRef" in env.repository, false);
|
|
527
|
+
// The clone timeout is still emitted (it's base-ref-independent).
|
|
528
|
+
assertEquals(env.repository.cloneTimeoutMs, 600000);
|
|
495
529
|
});
|
|
496
530
|
|
|
497
531
|
test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
|
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.
|
|
3
|
+
"version": "0.172.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",
|
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
|
},
|