@nanobpm/nano-workforce 0.190.0 → 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 +6 -0
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +355 -0
- package/app/deliveryGraphCompiler.test.ts +1465 -3
- package/app/deliveryGraphCompiler.ts +833 -36
- package/app/deliveryGraphDispatch.test.ts +118 -2
- package/app/deliveryGraphDispatch.ts +138 -10
- package/app/deliveryGraphProposals.test.ts +243 -0
- package/app/deliveryGraphProposals.ts +130 -56
- package/app/deliveryGraphRun.test.ts +42 -2
- package/app/deliveryGraphRun.ts +46 -6
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
- package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.190.0](https://github.com/nanobpm/nano-workforce/compare/v0.189.3...v0.190.0) (2026-09-21)
|
|
2
8
|
|
|
3
9
|
### 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: [
|