@nanobpm/nano-workforce 0.189.3 → 0.191.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.191.0](https://github.com/nanobpm/nano-workforce/compare/v0.190.0...v0.191.0) (2026-09-23)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** descriptive compiled node names + <bpmn:documentation> ([#783](https://github.com/nanobpm/nano-workforce/issues/783)) ([9e8be3c](https://github.com/nanobpm/nano-workforce/commit/9e8be3c5b047e501b6fbfd0e6ba82e78f64b2178)), closes [#778](https://github.com/nanobpm/nano-workforce/issues/778) [query/#fragment](https://github.com/query/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#releases](https://github.com/nanobpm/nano-workforce/issues/releases) [owner/repo#42](https://github.com/owner/repo/issues/42) [owner/repo#42](https://github.com/owner/repo/issues/42) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#716](https://github.com/nanobpm/nano-workforce/issues/716) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [query/#fragment](https://github.com/query/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#releases](https://github.com/nanobpm/nano-workforce/issues/releases) [owner/repo#42](https://github.com/owner/repo/issues/42) [query/#fragment](https://github.com/query/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [query/#fragment](https://github.com/query/nano-workforce/issues/fragment) [query/#fragment](https://github.com/query/nano-workforce/issues/fragment) [#42](https://github.com/nanobpm/nano-workforce/issues/42) [#778-review](https://github.com/nanobpm/nano-workforce/issues/778-review) [#778](https://github.com/nanobpm/nano-workforce/issues/778)
6
+
7
+ ## [0.190.0](https://github.com/nanobpm/nano-workforce/compare/v0.189.3...v0.190.0) (2026-09-21)
8
+
9
+ ### Features
10
+
11
+ * **ui:** make Convergence auto-merge opt-in ([#752](https://github.com/nanobpm/nano-workforce/issues/752)) ([db18b17](https://github.com/nanobpm/nano-workforce/commit/db18b17ceadc1cccd48e50a12c98d867f657f3d5)), closes [#751](https://github.com/nanobpm/nano-workforce/issues/751)
12
+
1
13
  ## [0.189.3](https://github.com/nanobpm/nano-workforce/compare/v0.189.2...v0.189.3) (2026-09-20)
2
14
 
3
15
  ### Bug Fixes
package/README.md CHANGED
@@ -285,9 +285,10 @@ branch before escalating (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3). Conflicts, an
285
285
  exhausted budget, or an agent that can't fix the build escalate to a human; answer in
286
286
  the UI and the process re-arms and retries.
287
287
 
288
- A single submission can pin **review-only** regardless of the global default by
289
- passing `convergeOnly: true` on the `start/convergence-loop` request — the PR stops at
290
- `converged` and is never handed to `merge-loop`, even with `NANO_PR_AUTO_MERGE` on.
288
+ A single submission can choose its merge behavior explicitly with the positive
289
+ `autoMerge` field on the `start/convergence-loop` request: `false` stops at
290
+ `converged` regardless of the global default, while `true` enables the merge-loop
291
+ after convergence only when `NANO_PR_AUTO_MERGE` is enabled.
291
292
 
292
293
  ### Fleet mode: hand it an issue (plan → implement → converge)
293
294
 
@@ -356,7 +357,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
356
357
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
357
358
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
358
359
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `GET /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
359
- | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
360
+ | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the positive `autoMerge` field on `start/convergence-loop` |
360
361
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
361
362
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
362
363
  | `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
package/SPEC.md CHANGED
@@ -582,10 +582,10 @@ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
582
582
  merge-queue branches and cross-PR dependencies, and reuses the review stage's
583
583
  escalation machinery for anything it can't resolve autonomously.
584
584
 
585
- A per-submit `convergeOnly: true` on the `start/convergence-loop` request pins that PR
586
- to review-only regardless of the global default: `pr.finalize` reads the flag off the
587
- instance and rests the PR at `converged` without starting `merge-loop`. The flag only
588
- ever narrows (it never forces the merge stage on when `NANO_PR_AUTO_MERGE` is off).
585
+ A per-submit `autoMerge` setting on the `start/convergence-loop` request is the preferred
586
+ positive control: `false` pins that PR to review-only regardless of the global default, while
587
+ `true` enables the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is on. No per-submit
588
+ setting forces the merge stage on when the global toggle is off.
589
589
 
590
590
  Flow:
591
591
 
@@ -691,7 +691,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
691
691
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
692
692
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
693
693
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
694
- | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
694
+ | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `autoMerge: false` override) |
695
695
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
696
696
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
697
697
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT30M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
package/app/agentGuide.ts CHANGED
@@ -31,7 +31,7 @@ const RAW_GUIDE: string = (() => {
31
31
  "Key endpoints (under the app control-API base `__BASE__`):",
32
32
  "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
33
33
  "- `GET /version` — which code is live.",
34
- "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
+ "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, autoMerge?, maxRounds?, dependsOn? }`; `autoMerge` is the positive per-request merge control).",
35
35
  "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
36
36
  "- `POST /actions/complete-user-task` — answer an escalation (`{ userTaskKey, variables }`); the parked user task's key comes from `GET /status`/the Tasks inbox, and the typed variables match its `.form` (e.g. a PR escalation's `{ answer }`).",
37
37
  "- `POST /actions/message` — publish a BPMN message (optionally correlated) into the engine.",
@@ -10,6 +10,7 @@ import {
10
10
  DELIVERY_NODE_KINDS,
11
11
  type DeliveryGraphError,
12
12
  type DeliveryGraphErrorCode,
13
+ redactConnectorValue,
13
14
  validateDeliveryGraph,
14
15
  } from "./deliveryGraph.ts";
15
16
 
@@ -646,6 +647,452 @@ test("S7 guard-type-mismatch: an `equals` whose type differs from the fact's dec
646
647
  hasCode(errors, "guard-type-mismatch");
647
648
  });
648
649
 
650
+ test("S7 guard-invalid-equals: a string `equals` carrying an XML-1.0-invalid character is rejected, not silently rewritten into a different FEEL guard (#778 review)", () => {
651
+ // A string `equals` is baked VERBATIM into the compiled `<bpmn:conditionExpression>` FEEL literal.
652
+ // An XML-1.0-forbidden character (here U+FFFE) cannot be entity-escaped, so the compiler's
653
+ // display-text sanitiser would STRIP it — turning the guard `bump_result = "a\uFFFEb"` into
654
+ // `bump_result = "ab"` and routing the split down the wrong edge. It must be rejected at validation
655
+ // instead of silently mutating executable FEEL.
656
+ const errors = validateDeliveryGraph({
657
+ nodes: [
658
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
659
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
660
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
661
+ ],
662
+ edges: [
663
+ { from: "bump", to: "a", when: "bump.result", equals: "a\uFFFEb" },
664
+ { from: "bump", to: "b", default: true },
665
+ ],
666
+ });
667
+ hasCode(errors, "guard-invalid-equals");
668
+ });
669
+
670
+ test("S7 guard-invalid-equals: a clean string `equals` (no XML-invalid characters) passes validation", () => {
671
+ assertEquals(
672
+ validateDeliveryGraph({
673
+ nodes: [
674
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
675
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
676
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
677
+ ],
678
+ edges: [
679
+ { from: "bump", to: "a", when: "bump.result", equals: "breaking" },
680
+ { from: "bump", to: "b", default: true },
681
+ ],
682
+ }).filter((e) => e.code === "guard-invalid-equals"),
683
+ [],
684
+ );
685
+ });
686
+
687
+ test("invalid-job-type: an `agent.jobType` carrying an XML-1.0-invalid character is rejected, not silently rewritten into a different executable worker type (#778 review)", () => {
688
+ // `agent.jobType` is emitted VERBATIM as the executable `<zeebe:taskDefinition type=…>` (and mirrored
689
+ // into `resolved.calledElement`). An XML-1.0-forbidden character (here a C0 control) cannot be
690
+ // entity-escaped, so the compiler's attribute sanitiser would STRIP it — deploying `senior:feature`
691
+ // for an authored `senior:\u0001feature` and routing the cell to the WRONG worker. It must be
692
+ // rejected at validation rather than silently mutated.
693
+ const errors = validateDeliveryGraph({
694
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "senior:\u0001feature" } }],
695
+ edges: [],
696
+ });
697
+ hasCode(errors, "invalid-job-type");
698
+ });
699
+
700
+ test("invalid-job-type: an `agent.jobType` carrying attribute whitespace (LF) is rejected — XML attribute-value normalization would fold it to a space, deploying a different worker type (#778 review)", () => {
701
+ // A literal TAB/LF/CR is a valid XML `Char` (so the invalid-char strip does NOT catch it), but XML
702
+ // attribute-value normalization rewrites it to a single space when emitted as `type="…"`. An authored
703
+ // `senior:\nfeature` would deploy as `senior: feature` — a DIFFERENT worker type — so it must be
704
+ // rejected at validation, not silently normalized.
705
+ const errors = validateDeliveryGraph({
706
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "senior:\nfeature" } }],
707
+ edges: [],
708
+ });
709
+ hasCode(errors, "invalid-job-type");
710
+ });
711
+
712
+ test("invalid-job-type: a clean `agent.jobType` (no XML-invalid characters, no attribute whitespace) passes validation", () => {
713
+ assertEquals(
714
+ validateDeliveryGraph({
715
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "senior:feature" } }],
716
+ edges: [],
717
+ }).filter((e) => e.code === "invalid-job-type"),
718
+ [],
719
+ );
720
+ });
721
+
722
+ test("invalid-job-type: the rejection message REDACTS a credential embedded in the (URL-shaped, XML-invalid) job type — a 400 never echoes a secret (#778 review)", () => {
723
+ // A job type that is BOTH URL-shaped (userinfo credential) AND carries an XML-1.0-invalid control char
724
+ // trips `invalid-job-type`. The message interpolates the value through `redactConnectorValue`, so the
725
+ // embedded `user:pass` must NOT survive into the error a text-ingress caller sees.
726
+ const errors = validateDeliveryGraph({
727
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "//user:pass@host\u0001/route" } }],
728
+ edges: [],
729
+ });
730
+ const err = hasCode(errors, "invalid-job-type");
731
+ assert(!err.message.includes("user:pass"), `the invalid-job-type message must redact the credential, got: ${err.message}`);
732
+ });
733
+
734
+ test("url-shaped-job-type: a URL-shaped `agent.jobType` is REJECTED at the semantic boundary — it is baked verbatim into the executable `<zeebe:taskDefinition type=…>`, so an embedded credential would leak into the compiled BPMN the preview door returns (#778 review)", () => {
735
+ const errors = validateDeliveryGraph({
736
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "https://user:pass@evil.example/route" } }],
737
+ edges: [],
738
+ });
739
+ const err = hasCode(errors, "url-shaped-job-type");
740
+ assert(!err.message.includes("user:pass"), `the url-shaped-job-type message must redact the credential, got: ${err.message}`);
741
+ });
742
+
743
+ test("url-shaped-job-type: a scheme-relative `//host` job type is rejected too; a plain routing token passes", () => {
744
+ assert(
745
+ hasCode(
746
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "//user:pass@host/x" } }], edges: [] }),
747
+ "url-shaped-job-type",
748
+ ) !== undefined,
749
+ );
750
+ assertEquals(
751
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [] }).filter(
752
+ (e) => e.code === "url-shaped-job-type",
753
+ ),
754
+ [],
755
+ );
756
+ });
757
+
758
+ test("credential-in-job-type: a plausible token with an EMBEDDED credential-bearing URL (`senior:feature //user:pass@host`, past the anchored url-shape and TAB/LF/CR checks) is REJECTED, message redacted (#778 review — thread deliveryGraph.ts:550)", () => {
759
+ const errors = validateDeliveryGraph({
760
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature //user:pass@evil.example/route" } }],
761
+ edges: [],
762
+ });
763
+ const err = hasCode(errors, "credential-in-job-type");
764
+ assert(!err.message.includes("user:pass"), `the credential-in-job-type message must redact the credential, got: ${err.message}`);
765
+ // A plain routing token with no embedded credential is untouched.
766
+ assertEquals(
767
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [] }).filter(
768
+ (e) => e.code === "credential-in-job-type",
769
+ ),
770
+ [],
771
+ );
772
+ });
773
+
774
+ test("invalid-job-type: a NON-url-shaped token that both embeds a credential AND carries an XML-invalid char (`senior:feature //user:pass@host\\x01`) redacts the credential in the message — `redactConnectorValue` would have echoed it verbatim (#778 review — thread deliveryGraph.ts:532)", () => {
775
+ const errors = validateDeliveryGraph({
776
+ nodes: [{ id: "impl", kind: "agent", agent: { jobType: "senior:feature //user:pass@evil.example\u0001/route" } }],
777
+ edges: [],
778
+ });
779
+ const err = hasCode(errors, "invalid-job-type");
780
+ assert(!err.message.includes("user:pass"), `the invalid-job-type message must redact the EMBEDDED credential, got: ${err.message}`);
781
+ });
782
+
783
+ test("credential-in-job-type: a literal SPACE inside the userinfo (`senior:feature //user:secret pass@host`) is caught — the whitespace-tolerant `//…@` span matches the display redactor, message redacted (#778 review — thread deliveryGraph.ts:570)", () => {
784
+ const errors = validateDeliveryGraph({
785
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature //user:secret pass@evil.example/route" } }],
786
+ edges: [],
787
+ });
788
+ const err = hasCode(errors, "credential-in-job-type");
789
+ assert(!err.message.includes("secret pass"), `the credential-in-job-type message must redact the space-bearing credential, got: ${err.message}`);
790
+ });
791
+
792
+ test("invalid-credential-env: a `wait.credentialEnv` that is not a DECLARED env-contract key is rejected at the semantic boundary — a raw secret can never reach the compiled BPMN the preview door returns (#778 review)", () => {
793
+ const errors = validateDeliveryGraph({
794
+ nodes: [
795
+ {
796
+ id: "g",
797
+ kind: "wait",
798
+ wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, credentialEnv: "sk-an-actual-secret-value" },
799
+ },
800
+ ],
801
+ edges: [],
802
+ });
803
+ hasCode(errors, "invalid-credential-env");
804
+ });
805
+
806
+ test("invalid-credential-env: a DECLARED env-contract key on an `http` probe passes (the secret is read from the ambient env at execution time, never carried here)", () => {
807
+ assertEquals(
808
+ validateDeliveryGraph({
809
+ nodes: [
810
+ {
811
+ id: "g",
812
+ kind: "wait",
813
+ wait: { kind: "http", target: "https://acme.example/health", credentialEnv: "GITHUB_TOKEN" },
814
+ },
815
+ ],
816
+ edges: [],
817
+ }).filter((e) => e.code === "invalid-credential-env"),
818
+ [],
819
+ );
820
+ });
821
+
822
+ test("invalid-credential-env: a well-formed `credentialEnv` key on a NON-`http` probe kind is rejected at the semantic boundary — `parseProbe` supports it only for `http`, so reject here rather than stage-then-throw at dispatch (#778 review, thread deliveryGraph.ts:474)", () => {
823
+ for (const kind of ["pr", "command", "npm", "github-check", "capability", "epic"] as const) {
824
+ const errors = validateDeliveryGraph({
825
+ nodes: [
826
+ {
827
+ id: "g",
828
+ kind: "wait",
829
+ wait: { kind, target: "acme/repo#1", credentialEnv: "GITHUB_TOKEN" },
830
+ },
831
+ ],
832
+ edges: [],
833
+ });
834
+ hasCode(errors, "invalid-credential-env");
835
+ }
836
+ });
837
+
838
+ test("invalid-credential-env: a padded-but-valid `credentialEnv` on `http` passes — validation trims like `parseProbe` does, so it agrees with execution (#778 review, suppressed advisory deliveryGraph.ts:466)", () => {
839
+ assertEquals(
840
+ validateDeliveryGraph({
841
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "http", target: "https://x/health", credentialEnv: " GITHUB_TOKEN " } }],
842
+ edges: [],
843
+ }).filter((e) => e.code === "invalid-credential-env"),
844
+ [],
845
+ );
846
+ // A whitespace-only credentialEnv is treated as ABSENT (parseProbe reads `.trim() || undefined`), so it
847
+ // is neither rejected nor carried — no error.
848
+ assertEquals(
849
+ validateDeliveryGraph({
850
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "http", target: "https://x/health", credentialEnv: " " } }],
851
+ edges: [],
852
+ }).filter((e) => e.code === "invalid-credential-env"),
853
+ [],
854
+ );
855
+ });
856
+
857
+ test("invalid-credential-env: a non-string `credentialEnv` (e.g. 123) is rejected at the semantic boundary — it can never name an env key and `parseProbe` throws at dispatch, so reject rather than stage-then-throw (#778 review, suppressed advisory deliveryGraph.ts:473)", () => {
858
+ for (const bad of [123, true, { k: "v" }, ["GITHUB_TOKEN"]]) {
859
+ const errors = validateDeliveryGraph({
860
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "http", target: "https://x/health", credentialEnv: bad } }],
861
+ edges: [],
862
+ });
863
+ hasCode(errors, "invalid-credential-env");
864
+ }
865
+ });
866
+
867
+ test("invalid-credential-env: a padded probe `kind` (\" http \") still accepts a `credentialEnv` — the http-only check trims `kind` like `parseProbe` does, so a valid padded-kind http probe is not false-rejected (#778 review — thread deliveryGraph.ts:488)", () => {
868
+ assertEquals(
869
+ validateDeliveryGraph({
870
+ nodes: [{ id: "g", kind: "wait", wait: { kind: " http ", target: "https://x/health", credentialEnv: "GITHUB_TOKEN" } }],
871
+ edges: [],
872
+ }).filter((e) => e.code === "invalid-credential-env"),
873
+ [],
874
+ );
875
+ });
876
+
877
+ test("invalid-backoff: an invalid `poll.backoff` (e.g. `linear`) is rejected at the semantic boundary — `normalizePoll` would silently default it to `exponential`, colliding its digest with a valid default-poll graph so a malformed proposal is marked dispatched instead of rejected; reject it before defaulting (#778 review, thread readiness.ts:432)", () => {
878
+ // `parseProbe`→`parsePoll` throws on `backoff: "linear"` at DISPATCH, but the compiler's display/digest
879
+ // path calls `normalizePoll` on the RAW graph, which maps every unrecognised backoff to `exponential`.
880
+ // With the doc suppressing a default backoff and `digestInvisibleRawValues` only fingerprinting
881
+ // XML-strip differences (`"linear"` is XML-clean), the malformed graph shares the omitted-poll graph's
882
+ // digest/key — letting keyless dispatch short-circuit onto a valid run and mark the malformed proposal
883
+ // dispatched. Reject it here (mirroring `parsePoll`'s canonical `isBackoff` guard) so it fails loudly
884
+ // at the preview/stage door, exactly like the `credentialEnv`/`onTimeout` semantic-boundary checks.
885
+ for (const backoff of ["linear", "LINEAR", "expo", "fixed ", "bogus"]) {
886
+ const errors = validateDeliveryGraph({
887
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", poll: { everyMs: 1000, backoff } } }],
888
+ edges: [],
889
+ });
890
+ // `"fixed "` (padded) is VALID — `parsePoll` trims before `isBackoff`, so validation must trim too
891
+ // and accept it (agreement with execution), while genuinely invalid values are rejected.
892
+ if (backoff.trim() === "fixed") {
893
+ assertEquals(errors.filter((e) => e.code === "invalid-backoff"), [], `padded-but-valid backoff ${JSON.stringify(backoff)} must pass`);
894
+ } else {
895
+ hasCode(errors, "invalid-backoff");
896
+ }
897
+ }
898
+ });
899
+
900
+ test("invalid-backoff: a valid or omitted `poll.backoff` passes — `fixed`/`exponential`/omitted are accepted, agreeing with `parsePoll` (#778 review, thread readiness.ts:432)", () => {
901
+ for (const poll of [{ everyMs: 1000 }, { everyMs: 1000, backoff: "fixed" }, { everyMs: 1000, backoff: "exponential" }, { everyMs: 1000, backoff: "" }]) {
902
+ assertEquals(
903
+ validateDeliveryGraph({
904
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", poll } }],
905
+ edges: [],
906
+ }).filter((e) => e.code === "invalid-backoff"),
907
+ [],
908
+ `backoff ${JSON.stringify(poll)} must pass`,
909
+ );
910
+ }
911
+ });
912
+
913
+ test("credential-in-job-type: a PASSWORDLESS userinfo token (`senior:feature //token@host`, no colon) is REJECTED — a bearer/OAuth token riding the userinfo is a credential too, and a routing key never contains `//…@` at all (#778 review push-back — thread readiness.ts:1170)", () => {
914
+ const errors = validateDeliveryGraph({
915
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature //tok3n@host" } }],
916
+ edges: [],
917
+ });
918
+ const err = hasCode(errors, "credential-in-job-type");
919
+ assert(!err.message.includes("tok3n"), `the credential-in-job-type message must redact the passwordless token, got: ${err.message}`);
920
+ });
921
+
922
+ test("#778 redactConnectorValue: an EMBEDDED `//user:pass@host` credential after a non-URL prefix (a `parsePrTarget` value like `prefix //user:pass@host#42`) is redacted, not echoed verbatim; a userinfo-bearing authority is a URL so its `#fragment`/`?query` is redacted too (round-7 strengthening — thread deliveryGraph.ts:188), while an OPAQUE `//host#42` (no userinfo) keeps its `#42` (#778 review — thread deliveryGraph.ts:162/566)", () => {
923
+ const out = redactConnectorValue("prefix //user:pass@host#42");
924
+ assert(!out.includes("user:pass"), `an embedded credential must be redacted even without a URL prefix: ${out}`);
925
+ assert(out.includes("//***@host"), `the userinfo collapses to the redaction marker: ${out}`);
926
+ // A userinfo-bearing `//…@` authority IS a URL, so its `#fragment` is redacted (round 7, Finding A) —
927
+ // an opaque PR ref never carries userinfo, so nothing meaningful is lost. The opaque `//host#42` case
928
+ // (no userinfo) that keeps its `#42` is asserted separately below.
929
+ assert(!out.includes("#42"), `a userinfo-bearing URL's fragment is redacted (safe direction): ${out}`);
930
+ // A passwordless `//token@host` bearer token embedded after a prefix is redacted too.
931
+ const bearer = redactConnectorValue("route //tok3n@host now");
932
+ assert(!bearer.includes("tok3n") && bearer.includes("//***@host"), `a passwordless embedded token must be redacted: ${bearer}`);
933
+ // An OPAQUE scheme-relative `//host#42` (no userinfo) keeps its meaningful `#42` PR ref.
934
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
935
+ // Opaque identifiers where `#`/`?`/`@` are MEANINGFUL and carry no embedded `//…@` are shown verbatim.
936
+ assertEquals(redactConnectorValue("slack:#releases"), "slack:#releases");
937
+ assertEquals(redactConnectorValue("owner/repo#42"), "owner/repo#42");
938
+ assertEquals(redactConnectorValue("pkg@1.2.3"), "pkg@1.2.3");
939
+ });
940
+
941
+ test("#778 redactConnectorValue: an EMBEDDED absolute-URL token (explicit `scheme://…`) after a non-URL prefix (`prefix https://host/path?token=secret`) has its `?query`/`#fragment` secret redacted, while a scheme-relative `//host#42` PR ref and opaque `#`/`?` identifiers survive (#778 review — thread deliveryGraph.ts:181)", () => {
942
+ const q = redactConnectorValue("prefix https://host/path?token=secret");
943
+ assert(!q.includes("token=secret"), `an embedded absolute-URL query secret must be redacted: ${q}`);
944
+ assert(q.includes("https://host/path?***"), `the query collapses to the redaction marker: ${q}`);
945
+ const frag = redactConnectorValue("see https://host/p#sig=zzz");
946
+ assert(!frag.includes("sig=zzz") && frag.includes("#***"), `an embedded absolute-URL fragment secret must be redacted: ${frag}`);
947
+ // userinfo AND query of an embedded absolute URL are both redacted.
948
+ const both = redactConnectorValue("go https://user:pass@host/p?token=x");
949
+ assert(
950
+ !both.includes("user:pass") && !both.includes("token=x") && both.includes("https://***@host/p?***"),
951
+ `embedded absolute-URL userinfo + query are both redacted: ${both}`,
952
+ );
953
+ // A scheme-RELATIVE `//host#42` (no explicit scheme) keeps its meaningful opaque `#42` PR ref — the
954
+ // `?`/`#` there are opaque-token characters, not URL syntax (parsePrTarget behaviour, unchanged).
955
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
956
+ assertEquals(redactConnectorValue("slack:#releases"), "slack:#releases");
957
+ assertEquals(redactConnectorValue("owner/repo#42"), "owner/repo#42");
958
+ });
959
+
960
+ test("#778 redactConnectorValue: an embedded absolute-URL token whose userinfo or path carries an XML-valid TAB/LF/CR (`prefix https://user:pa\\tss@host/path?token=secret`) still has its `?query`/`#fragment` secret redacted — the embedded-URL scan is bounded by a literal SPACE, so it crosses the internal whitespace the primary `[^\\s]+` token stopped at (#778 review — thread deliveryGraph.ts:188)", () => {
961
+ // The embedded-URL redactor used `[^\s]+`, which stops at an XML-valid TAB/LF/CR the value may still
962
+ // carry after `stripXmlInvalidChars`. So `https://user:pa\tss@host/path?token=secret` matched only up
963
+ // to the TAB; the credential pass then collapsed `//user:pa\tss@` to `//***@` but left the trailing
964
+ // `?token=secret` un-rescanned, leaking the query into the connector display. Bounding the token by a
965
+ // literal SPACE (like the free-text belt) makes the whole URL — userinfo AND query — one span.
966
+ const tabUserinfo = redactConnectorValue("prefix https://user:pa\tss@host/path?token=secret");
967
+ assert(!tabUserinfo.includes("token=secret"), `an embedded-URL query after an internal-TAB userinfo must be redacted: ${JSON.stringify(tabUserinfo)}`);
968
+ assert(!tabUserinfo.includes("user:pa"), `the split userinfo must be redacted: ${JSON.stringify(tabUserinfo)}`);
969
+ // Same leak without userinfo: whitespace inside the path/query must not truncate the scan.
970
+ const tabPath = redactConnectorValue("prefix https://host/pa\tth?token=secret");
971
+ assert(!tabPath.includes("token=secret"), `an embedded-URL query after an internal-TAB path must be redacted: ${JSON.stringify(tabPath)}`);
972
+ // A newline inside the embedded URL is crossed the same way.
973
+ const lf = redactConnectorValue("see https://host/p\n#sig=zzz");
974
+ assert(!lf.includes("sig=zzz"), `an embedded-URL fragment after an internal newline must be redacted: ${JSON.stringify(lf)}`);
975
+ // The scheme-relative `//host#42` PR ref (no explicit scheme) still keeps its meaningful `#42`.
976
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
977
+ });
978
+
979
+ test("#778 redactConnectorValue: an embedded absolute-URL whose scheme is separated from the `//` authority by XML-valid whitespace (`prefix https:\\t//user:pass@host?token=secret`) still has its userinfo AND `?query` secret redacted — the embedded-URL token now allows `scheme:\\s*//`, aligned with `isUrlShaped` (#778 review — thread readiness.ts:1239)", () => {
980
+ // The value is not whole-value URL-shaped (a non-URL prefix precedes it), so the anchored `isUrlShaped`
981
+ // check misses it and it falls to the embedded-URL scan. Before the fix that scan required a contiguous
982
+ // `://`, so `https:\t//user:pass@host?token=secret` was not matched as an absolute URL; the userinfo-only
983
+ // fallback then stripped `//…@` but LEFT the `?token=secret` query in the connector/probe display.
984
+ for (const ws of ["\t", "\n", "\r", " "]) {
985
+ const v = `prefix https:${ws}//user:pass@host?token=secret`;
986
+ const out = redactConnectorValue(v);
987
+ assert(!out.includes("token=secret"), `an embedded-URL query after a scheme${JSON.stringify(ws)}// gap must be redacted: ${JSON.stringify(out)}`);
988
+ assert(!out.includes("user:pass"), `the userinfo after a scheme${JSON.stringify(ws)}// gap must be redacted: ${JSON.stringify(out)}`);
989
+ }
990
+ // A scheme-relative `//host#42` PR ref (no explicit scheme) still keeps its meaningful `#42`.
991
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
992
+ });
993
+
994
+ test("#778 redactConnectorValue: a scheme-RELATIVE credential URL (`//user:pass@host/path?token=secret`) embedded after a non-URL prefix has its userinfo AND `?query`/`#fragment` secret redacted — userinfo marks it a URL, so its `?`/`#` are URL syntax, while an opaque `//host#42` (no userinfo) keeps its `#42` (#778 review — thread deliveryGraph.ts:188)", () => {
995
+ // Before the fix the non-URL branch used the userinfo-ONLY `redactEmbeddedCredential`, which collapsed
996
+ // `//user:pass@` to `//***@` but LEFT the trailing `?token=secret` query — leaking it into the display.
997
+ const q = redactConnectorValue("prefix //user:pass@host/path?token=secret");
998
+ assert(!q.includes("token=secret"), `a scheme-relative credential URL's query secret must be redacted: ${JSON.stringify(q)}`);
999
+ assert(!q.includes("user:pass"), `the scheme-relative userinfo must be redacted: ${JSON.stringify(q)}`);
1000
+ // A fragment on a scheme-relative credential URL is redacted the same way.
1001
+ const frag = redactConnectorValue("go //tok3n@host/p#sig=zzz");
1002
+ assert(!frag.includes("sig=zzz") && !frag.includes("tok3n"), `a scheme-relative credential URL's fragment secret must be redacted: ${JSON.stringify(frag)}`);
1003
+ // A query/fragment after an XML-valid internal TAB is still crossed (bounded by a literal SPACE).
1004
+ const tab = redactConnectorValue("prefix //user:pa\tss@host?token=secret");
1005
+ assert(!tab.includes("token=secret") && !tab.includes("user:pa"), `a scheme-relative credential URL split by a TAB must still redact its query: ${JSON.stringify(tab)}`);
1006
+ // A userinfo-LESS opaque `//host#42` PR ref keeps its meaningful `#42` — no userinfo ⇒ not a URL.
1007
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
1008
+ assertEquals(redactConnectorValue("slack:#releases"), "slack:#releases");
1009
+ assertEquals(redactConnectorValue("owner/repo#42"), "owner/repo#42");
1010
+ });
1011
+
1012
+ test("embedded-url-in-job-type: a plausible token with an embedded USERINFO-LESS absolute URL (`senior:feature https://host/path?token=secret`) is REJECTED — it passes the anchored url-shape check AND `hasEmbeddedCredential` (no `//…@`), yet its `?query` would land verbatim in `<zeebe:taskDefinition type=…>`; message redacted (#778 review — thread deliveryGraph.ts:602)", () => {
1013
+ const errors = validateDeliveryGraph({
1014
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature https://evil.example/route?token=secret" } }],
1015
+ edges: [],
1016
+ });
1017
+ const err = hasCode(errors, "embedded-url-in-job-type");
1018
+ assert(!err.message.includes("token=secret"), `the embedded-url-in-job-type message must redact the query secret, got: ${err.message}`);
1019
+ // A plain routing token with no embedded URL is untouched.
1020
+ assertEquals(
1021
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [] }).filter(
1022
+ (e) => e.code === "embedded-url-in-job-type",
1023
+ ),
1024
+ [],
1025
+ );
1026
+ // An opaque `owner/repo#42`-style token (no `scheme://`) is not a URL and is not rejected.
1027
+ assertEquals(
1028
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:review owner/repo#42" } }], edges: [] }).filter(
1029
+ (e) => e.code === "embedded-url-in-job-type",
1030
+ ),
1031
+ [],
1032
+ );
1033
+ });
1034
+
1035
+ test("scheme-relative-url-in-job-type: a plausible token with an embedded USERINFO-LESS SCHEME-RELATIVE URL (`senior:feature //host?token=secret`) is REJECTED — it slips past the anchored url-shape check, `hasEmbeddedCredential` (no `//…@`) AND `hasEmbeddedUrl` (no explicit `scheme://`), yet its `//authority?query` would land verbatim in `<zeebe:taskDefinition type=…>`; message redacted (#778 review — thread deliveryGraph.ts:633)", () => {
1036
+ const errors = validateDeliveryGraph({
1037
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature //evil.example/route?token=secret" } }],
1038
+ edges: [],
1039
+ });
1040
+ const err = hasCode(errors, "scheme-relative-url-in-job-type");
1041
+ assert(!err.message.includes("token=secret"), `the scheme-relative-url-in-job-type message must redact the query secret, got: ${err.message}`);
1042
+ // Exactly one error class fires — the residual check is gated behind the three prior url checks so a
1043
+ // token is never double-reported.
1044
+ assertEquals(errors.length, 1, `expected exactly one error, got ${JSON.stringify(errors)}`);
1045
+ // A plain routing token with no embedded scheme-relative URL is untouched.
1046
+ assertEquals(
1047
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [] }).filter(
1048
+ (e) => e.code === "scheme-relative-url-in-job-type",
1049
+ ),
1050
+ [],
1051
+ );
1052
+ // An opaque `owner/repo#42` PR ref (a `//`-less identifier) is not a URL and is not rejected.
1053
+ assertEquals(
1054
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:review owner/repo#42" } }], edges: [] }).filter(
1055
+ (e) => e.code === "scheme-relative-url-in-job-type",
1056
+ ),
1057
+ [],
1058
+ );
1059
+ });
1060
+
1061
+ test("#778 redactConnectorValue: an embedded USERINFO-LESS SCHEME-RELATIVE URL (`prefix //host/path?token=secret`, no explicit scheme, no `//…@` credential) has its `?query` secret redacted — the `?` after a `//authority` is unambiguously URL syntax — while an opaque `//host#42` PR ref (no `?`) keeps its meaningful `#42` (#778 review — thread deliveryGraph.ts:633)", () => {
1062
+ const q = redactConnectorValue("prefix //host/path?token=secret");
1063
+ assert(!q.includes("token=secret"), `a scheme-relative URL's query secret must be redacted: ${JSON.stringify(q)}`);
1064
+ // A userinfo-LESS opaque `//host#42` PR ref (no `?`) is preserved — nothing sensitive there.
1065
+ assertEquals(redactConnectorValue("prefix //host#42"), "prefix //host#42");
1066
+ assertEquals(redactConnectorValue("slack:#releases"), "slack:#releases");
1067
+ assertEquals(redactConnectorValue("owner/repo#42"), "owner/repo#42");
1068
+ });
1069
+
1070
+ test("#778 redactConnectorValue: a WHOLE-value opaque scheme-relative PR ref (`//host#42` — no explicit scheme, no `//…@` credential, no `?query`) keeps its meaningful `#42` exactly like the embedded `prefix //host#42` form; `isUrlShaped` matches the scheme-relative `//authority`, so without the opaque-PR-ref exception the whole-value branch would `redactString` the `#42` → `#***`, an inconsistency with the embedded contract (#778 review — thread deliveryGraph.ts:198)", () => {
1071
+ // Whole-value opaque `//host#42` PR ref survives — matches the embedded `prefix //host#42` contract.
1072
+ assertEquals(redactConnectorValue("//host#42"), "//host#42");
1073
+ // A leading-whitespace opaque ref (isUrlShaped trims before its anchored check) also survives.
1074
+ assertEquals(redactConnectorValue(" //host#42"), " //host#42");
1075
+ // But a whole-value URL that actually hides a credential is STILL fully redacted:
1076
+ // — an explicit `scheme://…#fragment` (fragment IS URL syntax):
1077
+ assertEquals(redactConnectorValue("https://host#42"), "https://host#***");
1078
+ // — a `//user:pass@host#42` userinfo credential (userinfo marks it a URL, so `#` is URL syntax):
1079
+ assertEquals(redactConnectorValue("//user:pass@host#42"), "//***@host#***");
1080
+ // — a userinfo-less `//host?token=secret` query (the `?` after `//authority` is unambiguously URL syntax):
1081
+ const wholeQ = redactConnectorValue("//host?token=secret");
1082
+ assert(!wholeQ.includes("token=secret"), `a whole-value scheme-relative URL's query secret must be redacted: ${JSON.stringify(wholeQ)}`);
1083
+ // — a NON-NUMERIC fragment is NOT a PR ref: only `#<digits>` (`parsePrTarget`) is a valid PR handle,
1084
+ // so `//host#access-token` is an ordinary scheme-relative URL whose fragment can hide a secret and
1085
+ // MUST be redacted like any other URL fragment — the opaque-PR-ref exception must not swallow it
1086
+ // (#778 review — thread deliveryGraph.ts:206).
1087
+ const nonNumericFrag = redactConnectorValue("//host#access-token");
1088
+ assert(
1089
+ !nonNumericFrag.includes("access-token"),
1090
+ `a whole-value //host#<non-numeric> fragment is not a PR ref and must be redacted: ${JSON.stringify(nonNumericFrag)}`,
1091
+ );
1092
+ // The numeric PR ref itself still survives (regression guard for the exception's happy path):
1093
+ assertEquals(redactConnectorValue("//host#42"), "//host#42");
1094
+ });
1095
+
649
1096
  test("S7 guard-default-conflict: an edge with both `default` and `when` is rejected", () => {
650
1097
  const errors = validateDeliveryGraph({
651
1098
  nodes: [