@nanobpm/nano-workforce 0.190.0 → 0.191.1

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.
Files changed (34) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +6 -6
  2. package/CHANGELOG.md +12 -0
  3. package/app/deliveryGraph.test.ts +447 -0
  4. package/app/deliveryGraph.ts +356 -1
  5. package/app/deliveryGraphCompiler.test.ts +1465 -3
  6. package/app/deliveryGraphCompiler.ts +833 -36
  7. package/app/deliveryGraphDispatch.test.ts +118 -2
  8. package/app/deliveryGraphDispatch.ts +138 -10
  9. package/app/deliveryGraphProposals.test.ts +243 -0
  10. package/app/deliveryGraphProposals.ts +130 -56
  11. package/app/deliveryGraphRun.test.ts +67 -4
  12. package/app/deliveryGraphRun.ts +62 -10
  13. package/app/deliveryHuman.test.ts +79 -2
  14. package/app/deliveryHuman.ts +92 -0
  15. package/app/deliveryRunner.test.ts +21 -0
  16. package/app/deliveryRunner.ts +9 -2
  17. package/app/mergeProtocol.ts +1 -1
  18. package/app/pollUserTasks.test.ts +39 -0
  19. package/app/readiness.test.ts +72 -0
  20. package/app/readiness.ts +234 -8
  21. package/app/service.ts +8 -3
  22. package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
  23. package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
  24. package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
  25. package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
  26. package/docs/adr/0003-epic-base-branch-admission.md +1 -1
  27. package/docs/adr/0004-shared-contract-coordination.md +1 -1
  28. package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
  29. package/docs/adr/0006-delivery-units-one-representation.md +1 -1
  30. package/operations/dispatchDeliveryGraph.test.ts +255 -0
  31. package/operations/dispatchDeliveryGraph.ts +156 -5
  32. package/package.json +1 -1
  33. package/workers/merge/worker.test.ts +2 -2
  34. package/workers/merge/worker.ts +1 -1
@@ -2,16 +2,16 @@
2
2
  #
3
3
  # https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
4
4
  # install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
5
- # built in a DIFFERENT repo (Magikcraft/nano-bpm) whose Pages workflow re-fetches
5
+ # built in a DIFFERENT repo (nanobpm/nano-bpm) whose Pages workflow re-fetches
6
6
  # install.sh on every deploy — but that deploy only triggers on changes to files
7
7
  # in that repo, so an edit here would otherwise not reach the published mirror
8
8
  # until the nightly backstop cron. This job pokes that repo's Pages deploy via a
9
9
  # `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
10
10
  # within minutes of merging an install.sh change.
11
11
  #
12
- # Requires a token that can write to Magikcraft/nano-bpm — the default
12
+ # Requires a token that can write to nanobpm/nano-bpm — the default
13
13
  # GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
14
- # App token) with `Contents: read and write` on Magikcraft/nano-bpm as the repo
14
+ # App token) with `Contents: read and write` on nanobpm/nano-bpm as the repo
15
15
  # secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
16
16
  # cron on the site keeps the mirror eventually-consistent).
17
17
  name: Refresh nanobpm.io install mirror
@@ -45,10 +45,10 @@ jobs:
45
45
  -H 'Accept: application/vnd.github+json' \
46
46
  -H "Authorization: Bearer ${DISPATCH_TOKEN}" \
47
47
  -H 'X-GitHub-Api-Version: 2022-11-28' \
48
- https://api.github.com/repos/Magikcraft/nano-bpm/dispatches \
48
+ https://api.github.com/repos/nanobpm/nano-bpm/dispatches \
49
49
  -d '{"event_type":"install-sh-updated"}')
50
50
  if [ "$code" != "204" ]; then
51
- echo "::error::repository_dispatch to Magikcraft/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
51
+ echo "::error::repository_dispatch to nanobpm/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
52
52
  exit 1
53
53
  fi
54
- echo "dispatched install-sh-updated to Magikcraft/nano-bpm (HTTP 204)"
54
+ echo "dispatched install-sh-updated to nanobpm/nano-bpm (HTTP 204)"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **tasks:** show full delivery human-step context and lift an embedded link ([#814](https://github.com/nanobpm/nano-workforce/issues/814)) ([cca3374](https://github.com/nanobpm/nano-workforce/commit/cca33740607f80816504dd2b5c60b785cb1440c6)), closes [#813](https://github.com/nanobpm/nano-workforce/issues/813) [#fragment](https://github.com/nanobpm/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#813](https://github.com/nanobpm/nano-workforce/issues/813)
6
+
7
+ ## [0.191.0](https://github.com/nanobpm/nano-workforce/compare/v0.190.0...v0.191.0) (2026-09-23)
8
+
9
+ ### Features
10
+
11
+ * **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)
12
+
1
13
  ## [0.190.0](https://github.com/nanobpm/nano-workforce/compare/v0.189.3...v0.190.0) (2026-09-21)
2
14
 
3
15
  ### Features
@@ -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: [