@nanobpm/nano-workforce 0.171.6 → 0.171.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.171.7](https://github.com/nanobpm/nano-workforce/compare/v0.171.6...v0.171.7) (2026-09-01)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **agentic:** seed repository isolation envelope on delivery-graph agent cells ([#687](https://github.com/nanobpm/nano-workforce/issues/687)) ([8c96a7d](https://github.com/nanobpm/nano-workforce/commit/8c96a7db34a6e389d09d5b9968fbcbc362d85cd5)), closes [#686](https://github.com/nanobpm/nano-workforce/issues/686) [#684](https://github.com/nanobpm/nano-workforce/issues/684) [#685](https://github.com/nanobpm/nano-workforce/issues/685) [#551](https://github.com/nanobpm/nano-workforce/issues/551) [#686](https://github.com/nanobpm/nano-workforce/issues/686)
6
+
1
7
  ## [0.171.6](https://github.com/nanobpm/nano-workforce/compare/v0.171.5...v0.171.6) (2026-09-01)
2
8
 
3
9
  ### Bug Fixes
@@ -0,0 +1,56 @@
1
+ // Base-branch name validation — the canonical, side-effect-free gate shared by every door that
2
+ // accepts a caller-supplied branch name (the epic launch doors via `app/plan.ts`, and the
3
+ // operator delivery-graph dispatch door in `operations/dispatchDeliveryGraph.ts`).
4
+ //
5
+ // This is a deliberate LEAF module: it imports nothing and runs no top-level initialization, so an
6
+ // API door can pull in the validator without dragging in `app/plan.ts`'s substantial transitive
7
+ // imports and its import-time env seeding (`ESCALATION_SLA_TIMEOUT`/`CAPS_WAIT_TIMEOUT`). `plan.ts`
8
+ // re-exports these symbols, so existing importers are unaffected — this is derivation over
9
+ // duplication (one implementation), just hoisted below the heavy module.
10
+
11
+ /** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
12
+ * value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
13
+ * shell snippets and inline-code Markdown), so a non-ref value could break the rendered
14
+ * instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
15
+ export class InvalidBaseBranchError extends Error {
16
+ readonly value: string;
17
+ constructor(value: string) {
18
+ super(`invalid base branch name: ${JSON.stringify(value)}`);
19
+ this.name = "InvalidBaseBranchError";
20
+ this.value = value;
21
+ }
22
+ }
23
+
24
+ /** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
25
+ * branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
26
+ * (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
27
+ export class MissingBaseBranchError extends Error {
28
+ constructor() {
29
+ super("base branch is required (blank/absent base branches are rejected)");
30
+ this.name = "MissingBaseBranchError";
31
+ }
32
+ }
33
+
34
+ /** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
35
+ * purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
36
+ * no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
37
+ * This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
38
+ export function isPlausibleBranchName(s: string): boolean {
39
+ if (s.length === 0 || s.length > 255) return false;
40
+ if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
41
+ if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
42
+ if (s.includes("..") || s.includes("//")) return false;
43
+ return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
44
+ }
45
+
46
+ /** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
47
+ * (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
48
+ * launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
49
+ * rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
50
+ * operation edge maps both to a 400. Always returns a non-null branch on success. */
51
+ export function normalizeBaseBranch(input: string | null | undefined): string {
52
+ const s = (input ?? "").trim();
53
+ if (s.length === 0) throw new MissingBaseBranchError();
54
+ if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
55
+ return s;
56
+ }
package/app/contracts.ts CHANGED
@@ -384,7 +384,7 @@ 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 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 omits it so each slice's agent branches per 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 `commitSha` — 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. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
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 `commitSha` — 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. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
388
388
  shape:
389
389
  '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string, branch?: { create: string } }',
390
390
  },
@@ -49,7 +49,7 @@ export type DispatchDeliveryGraphResult =
49
49
  export async function dispatchDeliveryGraphRun(
50
50
  app: Pick<AppApi, "data" | "engine" | "log">,
51
51
  graph: unknown,
52
- options: { runKey?: string | null; title?: string | null } & DeliveryRunTimeouts = {},
52
+ options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null } & DeliveryRunTimeouts = {},
53
53
  ): Promise<DispatchDeliveryGraphResult> {
54
54
  const validationErrors = validateDeliveryGraph(graph);
55
55
  if (validationErrors.length > 0) {
@@ -140,6 +140,11 @@ export async function dispatchDeliveryGraphRun(
140
140
  escalationSlaTimeout: options.escalationSlaTimeout,
141
141
  probePollEvery: options.probePollEvery,
142
142
  escalationAssignee: options.escalationAssignee,
143
+ // Host-git provisioning (#684/#686): forward the run-level repo/base so the runner seeds the
144
+ // `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job (absent → the
145
+ // runner emits no envelope and the harness keeps its legacy launch-dir behaviour).
146
+ repository: options.repository,
147
+ baseBranch: options.baseBranch,
143
148
  });
144
149
  } catch (err) {
145
150
  await markClaimFailed();
@@ -363,6 +363,62 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
363
363
  assertEquals(typeof r.handle.processInstanceKey, "string");
364
364
  });
365
365
 
366
+ // Host-git provisioning (issue #684/#686): the delivery-graph runner must seed the canonical
367
+ // `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) as a run-root process
368
+ // variable so every agent cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
369
+ // instead of mutating the worker's launch dir — the delivery-graph analog of the plan.ts epic seed.
370
+ // These pin the createInstance variables the harness (headers ∪ variables) reads.
371
+ function captureCreateInstanceVars(): { engine: Parameters<typeof runDeliveryGraph>[0]; seen: () => Record<string, unknown> } {
372
+ let captured: Record<string, unknown> = {};
373
+ const engine = {
374
+ deployResources: async () => [],
375
+ createInstance: async (req: { variables?: Record<string, unknown> }) => {
376
+ captured = req.variables ?? {};
377
+ return { processInstanceKey: "1" };
378
+ },
379
+ };
380
+ return { engine, seen: () => captured };
381
+ }
382
+
383
+ test("runDeliveryGraph seeds the repository isolation envelope when repository + baseBranch are supplied (#684/#686)", async () => {
384
+ const { engine, seen } = captureCreateInstanceVars();
385
+ const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo", baseBranch: "main" });
386
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
387
+ const env = (seen() as Record<string, { repository?: Record<string, unknown> }>)["io.nanobpm.agentTask"];
388
+ assert(env?.repository, `expected the run-root vars to carry io.nanobpm.agentTask.repository, got ${JSON.stringify(seen())}`);
389
+ const repo = env.repository as Record<string, unknown>;
390
+ // PRE-PR shape: `ref = base` (the harness checks out the base; each agent cuts its own feat/<node.id>).
391
+ assertEquals(repo.ref, "main");
392
+ assertEquals(repo.url, "https://github.com/owner/repo.git");
393
+ assertEquals(repo.provider, "github");
394
+ // Branch-scoped blobless clone (#287) so large monorepos provision within the clone timeout.
395
+ assertEquals(repo.singleBranch, true);
396
+ assertEquals(repo.filter, "blob:none");
397
+ // baseRef = base too, so `origin/<base>` stays reachable for the review 3-dot diff.
398
+ assertEquals(repo.baseRef, "main");
399
+ // NO branch.create at the run root — a run fans out to many agent nodes, each needing its own
400
+ // feat/<node.id>, so a single run-level envelope names none (mirrors the plan.ts epic seed).
401
+ assertEquals("branch" in repo, false);
402
+ });
403
+
404
+ test("runDeliveryGraph emits NO envelope when repository/baseBranch are absent — repo-less graphs unchanged (#686)", async () => {
405
+ for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
406
+ const { engine, seen } = captureCreateInstanceVars();
407
+ const r = await runDeliveryGraph(engine, GRAPH, options);
408
+ assert(r.ok, `expected ok:true for ${JSON.stringify(options)}, got ${JSON.stringify(r)}`);
409
+ assertEquals("io.nanobpm.agentTask" in seen(), false, `no envelope expected for ${JSON.stringify(options)}`);
410
+ }
411
+ });
412
+
413
+ test("runDeliveryGraph drops a malformed repository rather than emitting a bogus clone URL (#686)", async () => {
414
+ const { engine, seen } = captureCreateInstanceVars();
415
+ // A value that is not exactly `owner/repo` (a trailing `.git`) must degrade to NO envelope — the
416
+ // helper's defence-in-depth guard — never a double-suffixed `…/owner/repo.git.git` clone URL.
417
+ const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" });
418
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
419
+ assertEquals("io.nanobpm.agentTask" in seen(), false);
420
+ });
421
+
366
422
  test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
367
423
  // Regression for #570: a `wait[pr]` node whose `target` is a fact reference (`open.pr`, the #548
368
424
  // late-binding shape the guide documents as canonical) COMPILED+staged but threw at dispatch —
@@ -19,6 +19,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
19
19
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
20
20
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
21
21
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
22
+ import { repoEnvelopeVars } from "./repoEnvelope.ts";
22
23
  import { isoDuration } from "./reviewWait.ts";
23
24
 
24
25
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
@@ -52,6 +53,18 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
52
53
  * cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
53
54
  * scope. */
54
55
  runKey?: string;
56
+ /** OPTIONAL `owner/repo` the run's `agent` nodes implement against. When supplied together with
57
+ * `baseBranch`, the runner seeds the canonical repository-provisioning envelope
58
+ * (`io.nanobpm.agentTask.repository`, via `repoEnvelopeVars`) as a run-root `createInstance` process
59
+ * variable so each `agent` cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
60
+ * instead of inheriting the worker's launch dir (issue #684/#686 — the same isolation the legacy
61
+ * feature/plan paths got in #685). Absent/unresolved → NO envelope is emitted and the harness falls
62
+ * back to the legacy launch-dir behaviour, so today's repo-less graphs are unchanged. */
63
+ repository?: string | null;
64
+ /** OPTIONAL base branch the run's `agent` nodes branch off — the `ref` the harness checks out in the
65
+ * isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
66
+ * branch off this base inside the clone). Only consulted when `repository` is also set. */
67
+ baseBranch?: string | null;
55
68
  }
56
69
 
57
70
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
@@ -146,6 +159,8 @@ export async function runDeliveryGraph(
146
159
  const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
147
160
 
148
161
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
162
+ const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
163
+ const repo = typeof options.repository === "string" && options.repository.trim() !== "" ? options.repository.trim() : null;
149
164
  const { processInstanceKey } = await engine.createInstance({
150
165
  processDefinitionId,
151
166
  variables: {
@@ -155,6 +170,20 @@ export async function runDeliveryGraph(
155
170
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
156
171
  // every node — and read down into each agent job via `=transcriptUrlBase`.
157
172
  [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
173
+ // Host-git provisioning (c8ctl, issue #684/#686): deliver the ONE canonical repository envelope
174
+ // (`repoEnvelopeVars`, app/repoEnvelope.ts) so every `agent` node's servicing `senior:*` job gets
175
+ // an ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
176
+ // copilot workers on one host share (and clobber) a single checkout, the exact field failure #684
177
+ // described. This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single
178
+ // run-root `createInstance` process variable that propagates through each agent cell's subProcess
179
+ // into its job. Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run
180
+ // fans out to MANY agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a
181
+ // single run-level envelope can't name one; each agent cuts its own branch off `base` inside the
182
+ // isolated clone (the agent-guide's `feat/*` convention, kept idempotent by the #551 preflight).
183
+ // `baseRef = base` too, so the harness keeps `origin/<base>` reachable for the review 3-dot diff.
184
+ // Spread last so an unresolved repo/base (`{}`) leaves the other run-root vars untouched — a
185
+ // repo-less graph is then dispatched exactly as before (legacy launch-dir behaviour).
186
+ ...repoEnvelopeVars(repo ?? "", base, base),
158
187
  },
159
188
  });
160
189
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
package/app/plan.ts CHANGED
@@ -10,6 +10,12 @@
10
10
  // the process. Data access goes through the record gateway (`data.table`), never
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
+ import {
14
+ InvalidBaseBranchError,
15
+ isPlausibleBranchName,
16
+ MissingBaseBranchError,
17
+ normalizeBaseBranch,
18
+ } from "./baseBranch.ts";
13
19
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
20
  import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
15
21
  import { EPIC_PHASE } from "./epicPhase.ts";
@@ -486,52 +492,11 @@ export function parseIssue(input: string): ParsedIssue | null {
486
492
  return null;
487
493
  }
488
494
 
489
- /** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
490
- * value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
491
- * shell snippets and inline-code Markdown), so a non-ref value could break the rendered
492
- * instructions or smuggle in a command/prompt fragment reject it at the edge instead. */
493
- export class InvalidBaseBranchError extends Error {
494
- readonly value: string;
495
- constructor(value: string) {
496
- super(`invalid base branch name: ${JSON.stringify(value)}`);
497
- this.name = "InvalidBaseBranchError";
498
- this.value = value;
499
- }
500
- }
501
-
502
- /** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
503
- * branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
504
- * (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
505
- export class MissingBaseBranchError extends Error {
506
- constructor() {
507
- super("base branch is required (blank/absent base branches are rejected)");
508
- this.name = "MissingBaseBranchError";
509
- }
510
- }
511
-
512
- /** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
513
- * purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
514
- * no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
515
- * This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
516
- function isPlausibleBranchName(s: string): boolean {
517
- if (s.length === 0 || s.length > 255) return false;
518
- if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
519
- if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
520
- if (s.includes("..") || s.includes("//")) return false;
521
- return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
522
- }
523
-
524
- /** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
525
- * (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
526
- * launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
527
- * rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
528
- * operation edge maps both to a 400. Always returns a non-null branch on success. */
529
- export function normalizeBaseBranch(input: string | null | undefined): string {
530
- const s = (input ?? "").trim();
531
- if (s.length === 0) throw new MissingBaseBranchError();
532
- if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
533
- return s;
534
- }
495
+ /** The base-branch validation gate lives in the side-effect-free leaf `./baseBranch.ts` so an API
496
+ * door (e.g. `operations/dispatchDeliveryGraph.ts`) can reuse it without importing this heavy module
497
+ * and its import-time env seeding. Re-exported here so existing importers keep resolving through
498
+ * `plan.ts` one implementation (derivation over duplication), just hoisted below the heavy module. */
499
+ export { InvalidBaseBranchError, isPlausibleBranchName, MissingBaseBranchError, normalizeBaseBranch };
535
500
 
536
501
  /** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
537
502
  * branch. It is authoritative over the static "branch off the default branch" wording in
package/openapi.yaml CHANGED
@@ -2090,6 +2090,29 @@ components:
2090
2090
  description: >-
2091
2091
  OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
2092
2092
  outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
2093
+ repository:
2094
+ type: string
2095
+ maxLength: 255
2096
+ pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
2097
+ description: >-
2098
+ OPTIONAL `owner/repo` the run's `agent` nodes implement against (#684/#686). When supplied
2099
+ together with `baseBranch`, the runner seeds the canonical `io.nanobpm.agentTask.repository`
2100
+ provisioning envelope (`repoEnvelopeVars`) as a run-root process variable so every agent
2101
+ node's servicing `senior:*` job gets an ISOLATED throwaway clone instead of inheriting the
2102
+ worker's launch dir. Absent → no envelope (legacy launch-dir behaviour, unchanged). A value
2103
+ that is not exactly `owner/repo` is rejected at submit.
2104
+ baseBranch:
2105
+ type: string
2106
+ maxLength: 255
2107
+ pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
2108
+ description: >-
2109
+ OPTIONAL base branch the run's `agent` nodes branch off (#684/#686) — the `ref` the harness
2110
+ checks out in the isolated clone (the PRE-PR shape: each agent cuts its own `feat/<node.id>`
2111
+ branch off this base). Only consulted when `repository` is also set; absent → no envelope. A
2112
+ value that is not a plausible git branch name (whitespace, shell metacharacters, a leading
2113
+ `-`, `..`/`//`, a path segment starting with `.` or ending in `.lock`, etc.) is rejected at
2114
+ submit. This pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`,
2115
+ app/baseBranch.ts) so the documented contract and the door agree.
2093
2116
  DeliveryGraphDismissRequest:
2094
2117
  description: >-
2095
2118
  The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
@@ -247,4 +247,66 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
247
247
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
248
248
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
249
249
  });
250
+
251
+ test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
252
+ const app = await boot();
253
+ assert.ok(app.api);
254
+ const api = app.api;
255
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
256
+ // Not an `owner/repo` reference — refused at submit (by the edge `pattern` or the door's own guard),
257
+ // rather than silently dropped into a bogus clone URL. Nothing launches; the proposal stays staged.
258
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
259
+ body: { digest: staged.body.digest, repository: "not a repo!", baseBranch: "main" },
260
+ });
261
+ assert.equal(res.status, 400);
262
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
263
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
264
+ });
265
+
266
+ test("a malformed `baseBranch` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
267
+ const app = await boot();
268
+ assert.ok(app.api);
269
+ const api = app.api;
270
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
271
+ // Not a plausible git branch name (a leading dash reads as a CLI flag / shell metacharacters) — the
272
+ // door's conservative allowlist refuses it at submit rather than seeding an invalid-ref envelope.
273
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
274
+ body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "-rf; rm main" },
275
+ });
276
+ assert.equal(res.status, 400);
277
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
278
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
279
+ });
280
+
281
+ test("a `.lock`-suffixed `baseBranch` segment is rejected at submit → 400, nothing launched (#684/#686)", async () => {
282
+ const app = await boot();
283
+ assert.ok(app.api);
284
+ const api = app.api;
285
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
286
+ // A path segment ending in `.lock` (or one starting with `.`) is a valid-looking ref the loose
287
+ // charset would admit but `isPlausibleBranchName` rejects — the door must refuse it, matching the
288
+ // (now tightened) OpenAPI `baseBranch` pattern rather than seeding an invalid-ref envelope.
289
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
290
+ body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "feat/x.lock" },
291
+ });
292
+ assert.equal(res.status, 400);
293
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
294
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
295
+ });
296
+
297
+ test("a valid repository + baseBranch dispatches the run for isolated provisioning → 202 running (#684/#686)", async () => {
298
+ const app = await boot();
299
+ assert.ok(app.api);
300
+ const api = app.api;
301
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
302
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
303
+ body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "main" },
304
+ });
305
+ assert.equal(res.status, 202);
306
+ assert.equal(res.body.ok, true);
307
+ assert.equal(res.body.status, "running");
308
+ await app.settle();
309
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
310
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
311
+ });
250
312
  });
@@ -10,6 +10,7 @@
10
10
  // re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
11
11
  // superseded / already-dispatched digest is a clean 400.
12
12
 
13
+ import { isPlausibleBranchName } from "../app/baseBranch.ts";
13
14
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
14
15
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
15
16
  import { isValidIsoDuration } from "../app/reviewWait.ts";
@@ -75,6 +76,38 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
75
76
  if (parsed.value !== undefined) timeouts[field] = parsed.value;
76
77
  }
77
78
 
79
+ // Host-git provisioning override (#684/#686) — the OPTIONAL `owner/repo` + base branch the run's
80
+ // `agent` nodes implement against. When both are present the runner seeds the canonical
81
+ // `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) onto every agent cell's
82
+ // job so it provisions a throwaway clone instead of mutating the worker's launch dir. `repository` is
83
+ // shape-validated here (mirrors `repoEnvelopeVars`' own `owner/repo` allowlist) so a malformed value
84
+ // is a clean 400 rather than a silently-dropped envelope; both absent → no envelope (legacy behaviour).
85
+ const repoRaw = body && typeof body === "object" && "repository" in body && typeof body.repository === "string" ? body.repository.trim() : "";
86
+ let repository: string | undefined;
87
+ if (repoRaw !== "") {
88
+ if (repoRaw.length > 255 || !/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repoRaw) || /\.git$/i.test(repoRaw)) {
89
+ const shown = truncateForEcho(repoRaw);
90
+ app.log.warn("dispatch-delivery-graph rejected: invalid repository", { value: shown });
91
+ return { status: 400, body: { ok: false, error: `\`repository\` must be an \`owner/repo\` reference; got \`${shown}\`` } };
92
+ }
93
+ repository = repoRaw;
94
+ }
95
+ const baseRaw = body && typeof body === "object" && "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch.trim() : "";
96
+ let baseBranch: string | undefined;
97
+ if (baseRaw !== "") {
98
+ // `baseBranch` becomes the isolation envelope's `ref` — a real Git ref the harness checks out and
99
+ // branches off. Gate it with the canonical conservative branch-name allowlist (`app/plan.ts`,
100
+ // shared with the epic/feature launch paths) so whitespace, shell metacharacters, newlines, a
101
+ // leading `-`, `..`/`//`, etc. are a clean 400 rather than an invalid-ref/argument-parsing edge
102
+ // case in a downstream git invocation.
103
+ if (baseRaw.length > 255 || !isPlausibleBranchName(baseRaw)) {
104
+ const shown = truncateForEcho(baseRaw);
105
+ app.log.warn("dispatch-delivery-graph rejected: invalid baseBranch", { value: shown });
106
+ return { status: 400, body: { ok: false, error: `\`baseBranch\` must be a plausible git branch name; got \`${shown}\`` } };
107
+ }
108
+ baseBranch = baseRaw;
109
+ }
110
+
78
111
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
79
112
  // dispatched digest cleanly (no run is launched).
80
113
  const proposal = await getStagedProposal(app.data, digest);
@@ -98,7 +131,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
98
131
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
99
132
  }
100
133
 
101
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
134
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, ...timeouts });
102
135
  if (!dispatched.ok) {
103
136
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
104
137
  const outBody: DeliveryGraphTextResult = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.171.6",
3
+ "version": "0.171.7",
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",