@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.
- package/.github/workflows/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +356 -1
- 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 +67 -4
- package/app/deliveryGraphRun.ts +62 -10
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/app/service.ts +8 -3
- package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
- package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// • humanNodes[] and sideEffects[] extraction.
|
|
14
14
|
import { test } from "node:test";
|
|
15
15
|
import { assert, assertEquals } from "#test-assert";
|
|
16
|
-
import {
|
|
16
|
+
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
17
|
+
import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
17
18
|
|
|
18
19
|
/** Compile and assert success, returning the narrowed ok-result. */
|
|
19
20
|
async function compileOk(graph: unknown) {
|
|
@@ -279,9 +280,11 @@ const CAP_GATE = {
|
|
|
279
280
|
};
|
|
280
281
|
|
|
281
282
|
/** The subProcess element id the compiler assigned to a node (elements are positional `n<k>`, not the
|
|
282
|
-
* node id). Located via the subProcess `name="
|
|
283
|
+
* node id). Located via the subProcess `name="… · <nodeId>"` — the node id is retained as a stable
|
|
284
|
+
* ` · ` suffix on the descriptive label (issue #778). */
|
|
283
285
|
function elementForNode(bpmn: string, nodeId: string): string {
|
|
284
|
-
const
|
|
286
|
+
const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
287
|
+
const m = bpmn.match(new RegExp(`<bpmn:subProcess id="([^"]+)" name="[^"]* · ${escaped}"`));
|
|
285
288
|
assert(m, `a subProcess for node ${nodeId} exists`);
|
|
286
289
|
return m![1];
|
|
287
290
|
}
|
|
@@ -448,6 +451,48 @@ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing
|
|
|
448
451
|
assertEquals(ack?.prompt, undefined);
|
|
449
452
|
});
|
|
450
453
|
|
|
454
|
+
test("#778 humanNodes: a credential-bearing formKey is redacted in the preview projection (raw only reaches runtime form resolution)", async () => {
|
|
455
|
+
// `humanNodes[]` is persisted into the staged proposal `preview` and rendered verbatim on the Delivery
|
|
456
|
+
// Graphs page, so a credential in a human `formKey` (`//user:pass@…`) must be stripped here with the
|
|
457
|
+
// SAME helper the BPMN `Form:` doc uses — else it leaks unredacted through the preview. The RAW formKey
|
|
458
|
+
// still drives runtime form resolution (`deliveryHuman` reads `node.human.formKey` directly).
|
|
459
|
+
const r = await compileOk({
|
|
460
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "//user:pass@forms.example.com/approve?token=abc" } }],
|
|
461
|
+
edges: [],
|
|
462
|
+
});
|
|
463
|
+
const h = r.humanNodes.find((n) => n.nodeId === "h");
|
|
464
|
+
assert(h?.formKey !== undefined && !h.formKey.includes("user:pass") && !h.formKey.includes("token=abc"), `credential stripped from preview formKey: ${h?.formKey}`);
|
|
465
|
+
// An ordinary opaque form id is preserved verbatim.
|
|
466
|
+
const plain = await compileOk({ nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "deploy-approval" } }], edges: [] });
|
|
467
|
+
assertEquals(plain.humanNodes.find((n) => n.nodeId === "h")?.formKey, "deploy-approval");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("#778 humanNodes: a credential-bearing emit `description` is redacted in the preview projection (raw only reaches runtime)", async () => {
|
|
471
|
+
// `humanNodes[].emits` is persisted into the staged proposal `preview` and rendered on the Delivery
|
|
472
|
+
// Graphs page, so a `//user:pass@…` URL in a free-form fact `description` must be stripped here with the
|
|
473
|
+
// SAME `redactFreeText` the BPMN emit label uses — else it leaks unredacted through the preview even
|
|
474
|
+
// though the label path redacts it. The RAW description still reaches the runtime `appendPrompt` (#778).
|
|
475
|
+
const r = await compileOk({
|
|
476
|
+
nodes: [
|
|
477
|
+
{
|
|
478
|
+
id: "h",
|
|
479
|
+
kind: "human",
|
|
480
|
+
human: { prompt: "approve" },
|
|
481
|
+
emits: [{ name: "pr", type: "pr", description: "post to //user:pass@host/x?token=abc when done" }],
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
edges: [],
|
|
485
|
+
});
|
|
486
|
+
const desc = r.humanNodes.find((n) => n.nodeId === "h")?.emits.find((e) => e.name === "pr")?.description;
|
|
487
|
+
assert(desc !== undefined && !desc.includes("user:pass") && !desc.includes("token=abc"), `credential stripped from preview emit description: ${desc}`);
|
|
488
|
+
// An ordinary (credential-free) description is preserved verbatim.
|
|
489
|
+
const plain = await compileOk({
|
|
490
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve" }, emits: [{ name: "pr", type: "pr", description: "the merged PR ref" }] }],
|
|
491
|
+
edges: [],
|
|
492
|
+
});
|
|
493
|
+
assertEquals(plain.humanNodes.find((n) => n.nodeId === "h")?.emits.find((e) => e.name === "pr")?.description, "the merged PR ref");
|
|
494
|
+
});
|
|
495
|
+
|
|
451
496
|
test("sideEffects: agent + connector only; connector carries its dedupeKey", async () => {
|
|
452
497
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
453
498
|
const agent = r.sideEffects.find((s) => s.nodeId === "open-b");
|
|
@@ -534,6 +579,30 @@ test("a wait node with a LITERAL pr target compiles the probe unchanged (no spur
|
|
|
534
579
|
assert(/source="=nodeInputs\.[^"]+\.probe" target="probe"/.test(r.bpmn), "the probe is seeded directly from nodeInputs");
|
|
535
580
|
});
|
|
536
581
|
|
|
582
|
+
test("a wait node whose fact-reference target is PADDED still late-binds via context put (target is trimmed before the bind match)", async () => {
|
|
583
|
+
// Regression (#778 review, thread deliveryGraphCompiler.ts:1476): `parseProbe` trims `wait.target`
|
|
584
|
+
// before the worker uses it, and the display/digest render the trimmed form — but the compiler's
|
|
585
|
+
// late-bind match compared `node.wait.target` VERBATIM, so a padded `" open.pr "` rendered/digested
|
|
586
|
+
// like the trimmed ref yet skipped the `context put` binding, leaving the runtime with the literal
|
|
587
|
+
// `open.pr` (which the wait can never resolve). Trimming the target before the match keeps display,
|
|
588
|
+
// digest, and the executable model in agreement.
|
|
589
|
+
const graph = {
|
|
590
|
+
name: "padded fact-ref target",
|
|
591
|
+
nodes: [
|
|
592
|
+
{
|
|
593
|
+
id: "open",
|
|
594
|
+
kind: "agent",
|
|
595
|
+
agent: { jobType: "senior:feature", prompt: "Implement and open a PR." },
|
|
596
|
+
emits: [{ name: "pr", type: "pr" }],
|
|
597
|
+
},
|
|
598
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: " open.pr ", match: { prState: "merged" } } },
|
|
599
|
+
],
|
|
600
|
+
edges: [{ from: "open.pr", to: "merged" }],
|
|
601
|
+
};
|
|
602
|
+
const r = await compileOk(graph);
|
|
603
|
+
assert(/context put\([^)]*\.probe, "target",/.test(r.bpmn), "the padded fact-ref target is late-bound via context put");
|
|
604
|
+
});
|
|
605
|
+
|
|
537
606
|
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
538
607
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
539
608
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
|
@@ -773,6 +842,22 @@ test("a wait node's onTimeout: continue proceeds past the gate with NO escalatio
|
|
|
773
842
|
);
|
|
774
843
|
});
|
|
775
844
|
|
|
845
|
+
test("#778 a wait node's onTimeout authored as a padded `\" continue \"` compiles to the SAME topology as the trimmed `\"continue\"` — `parseProbe` trims `onTimeout` before the worker runs, so the padded value routes past the gate with NO escalation task; comparing the raw value would spuriously emit the escalation branch the runtime never takes (thread deliveryGraphCompiler.ts:2012)", async () => {
|
|
846
|
+
const mk = (onTimeout: string): DeliveryGraph =>
|
|
847
|
+
JSON.parse(
|
|
848
|
+
JSON.stringify({
|
|
849
|
+
name: "g",
|
|
850
|
+
nodes: [{ id: "soft", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout } }],
|
|
851
|
+
edges: [],
|
|
852
|
+
}),
|
|
853
|
+
);
|
|
854
|
+
const padded = await compileOk(mk(" continue "));
|
|
855
|
+
const tight = await compileOk(mk("continue"));
|
|
856
|
+
assertEquals(padded.bpmn, tight.bpmn, "a padded onTimeout `\" continue \"` compiles byte-identically to the trimmed `\"continue\"`");
|
|
857
|
+
const softEl = elementForNode(padded.bpmn, "soft");
|
|
858
|
+
assert(!padded.bpmn.includes(`delivery-human-task__${softEl}__esc`), "a padded `continue` still emits no escalation user task");
|
|
859
|
+
});
|
|
860
|
+
|
|
776
861
|
test("a wait node's onTimeout: fail is rejected at compile with a path-qualified error (blocked on engine terminate-end, #462/#978)", async () => {
|
|
777
862
|
const errors = await compileFail({
|
|
778
863
|
name: "fail not yet supported",
|
|
@@ -874,3 +959,1380 @@ test("#731 routing-only emits stay optional: a fact referenced ONLY by an edge `
|
|
|
874
959
|
// The guarded split that routes `result` downstream is untouched (default branch preserved).
|
|
875
960
|
assert(r.bpmn.includes('=classify_result = "breaking"') || r.bpmn.includes(`${el}_result = "breaking"`), "the routing guard on the emitted fact is preserved");
|
|
876
961
|
});
|
|
962
|
+
|
|
963
|
+
// ---------------------------------------------------------------------------------------------------
|
|
964
|
+
// Issue #778: descriptive compiled node names + `<bpmn:documentation>`. Every compiled node renders a
|
|
965
|
+
// human-readable name derived from its typed config (not the bare id/kind), with the node id retained
|
|
966
|
+
// as a ` · <id>` suffix, and carries a `<bpmn:documentation>` describing what it does. ONE `nodeDisplay`
|
|
967
|
+
// helper is the single source for both the label and the documentation (derivation over duplication).
|
|
968
|
+
|
|
969
|
+
test("#778 nodeDisplay derives a descriptive label + documentation per kind, with the node id as a suffix", () => {
|
|
970
|
+
const agent = nodeDisplay({
|
|
971
|
+
id: "impl",
|
|
972
|
+
kind: "agent",
|
|
973
|
+
agent: { jobType: "senior:feature", prompt: "Implement the change and open a PR.", converge: true, merge: true, repository: "o/r", baseBranch: "main" },
|
|
974
|
+
emits: [{ name: "pr", type: "pr" }],
|
|
975
|
+
});
|
|
976
|
+
assertEquals(agent.name, "Implement the change and open a PR. & converge+merge · impl");
|
|
977
|
+
assert(agent.documentation.includes("Agent job: senior:feature"), "agent doc names the job type");
|
|
978
|
+
assert(agent.documentation.includes("Target: o/r@main"), "agent doc names the repo@branch");
|
|
979
|
+
assert(agent.documentation.includes("Policy: converge+merge"), "agent doc names the land policy");
|
|
980
|
+
assert(agent.documentation.includes("Emits: pr (pr)"), "agent doc lists emits");
|
|
981
|
+
|
|
982
|
+
const connector = nodeDisplay({
|
|
983
|
+
id: "land",
|
|
984
|
+
kind: "connector",
|
|
985
|
+
connector: { target: "converge-merge", dedupeKey: "land-1", payload: { pr: "o/r#42" } },
|
|
986
|
+
});
|
|
987
|
+
assertEquals(connector.name, "Converge & merge PR · land");
|
|
988
|
+
assert(connector.documentation.includes("Connector target: converge-merge"), "connector doc names the raw target");
|
|
989
|
+
assert(connector.documentation.includes("Dedupe key: land-1"), "connector doc names the dedupe key");
|
|
990
|
+
assert(connector.documentation.includes("PR: o/r#42"), "connector doc names the bound PR");
|
|
991
|
+
|
|
992
|
+
const wait = nodeDisplay({
|
|
993
|
+
id: "gate",
|
|
994
|
+
kind: "wait",
|
|
995
|
+
wait: { kind: "github-check", target: "o/r@main", match: { conclusion: "neutral" }, poll: { everyMs: 60000, timeoutMs: 3600000 } },
|
|
996
|
+
});
|
|
997
|
+
assertEquals(wait.name, "Wait: github-check o/r@main · gate");
|
|
998
|
+
assert(wait.documentation.includes("Readiness probe: github-check"), "wait doc names the probe kind");
|
|
999
|
+
assert(wait.documentation.includes("Match: conclusion=neutral"), "wait doc names the match criteria");
|
|
1000
|
+
assert(wait.documentation.includes("Poll: every 60000ms, timeout 3600000ms"), "wait doc names the poll budget");
|
|
1001
|
+
|
|
1002
|
+
const human = nodeDisplay({ id: "otp", kind: "human", human: { prompt: "Run the manual OTP publish", formKey: "publish-form" } });
|
|
1003
|
+
assertEquals(human.name, "Run the manual OTP publish · otp");
|
|
1004
|
+
assert(human.documentation.includes("Form: publish-form"), "human doc names the attached form");
|
|
1005
|
+
|
|
1006
|
+
// Degenerate fallbacks: an agent with no prompt falls back to the job type; a bare human to a default.
|
|
1007
|
+
assertEquals(nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j" } }).name, "j · a");
|
|
1008
|
+
assertEquals(nodeDisplay({ id: "h", kind: "human" }).name, "Human decision · h");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test("#778 nodeDisplay TRIMS a padded reserved connector target — the worker trims before reserved-vocab matching, so `\" converge-merge \"` must render the descriptive label + collapse to the trimmed digest, not the generic `Connector:` fallback (thread deliveryGraphCompiler.ts:1277)", () => {
|
|
1012
|
+
const padded = nodeDisplay({
|
|
1013
|
+
id: "land",
|
|
1014
|
+
kind: "connector",
|
|
1015
|
+
connector: { target: " converge-merge ", dedupeKey: "land-1", payload: { pr: "o/r#42" } },
|
|
1016
|
+
});
|
|
1017
|
+
assertEquals(padded.name, "Converge & merge PR · land");
|
|
1018
|
+
assert(padded.documentation.includes("Connector target: converge-merge"), "the padded reserved target is trimmed in the doc");
|
|
1019
|
+
assert(!padded.documentation.includes(" converge-merge "), "no padded target survives into the doc");
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("#778 the compiled subProcess carries the descriptive name + a `<bpmn:documentation>` first child", async () => {
|
|
1023
|
+
const graph = {
|
|
1024
|
+
name: "descriptive",
|
|
1025
|
+
nodes: [
|
|
1026
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement #567 and open a PR.", converge: true, merge: true }, emits: [{ name: "pr", type: "pr" }] },
|
|
1027
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", dedupeKey: "land-1", payload: { pr: "impl.pr" } } },
|
|
1028
|
+
{ id: "gate", kind: "wait", wait: { kind: "pr", target: "impl.pr", match: { prState: "merged" } } },
|
|
1029
|
+
],
|
|
1030
|
+
edges: [
|
|
1031
|
+
{ from: "impl.pr", to: "land" },
|
|
1032
|
+
{ from: "land", to: "gate" },
|
|
1033
|
+
{ from: "impl.pr", to: "gate" },
|
|
1034
|
+
],
|
|
1035
|
+
};
|
|
1036
|
+
const r = await compileOk(graph);
|
|
1037
|
+
const el = elementForNode(r.bpmn, "impl");
|
|
1038
|
+
// The subProcess wrapper is named from the typed config (not `agent: impl`) and the id is a suffix.
|
|
1039
|
+
assert(
|
|
1040
|
+
r.bpmn.includes(`<bpmn:subProcess id="${el}" name="Implement #567 and open a PR. & converge+merge · impl">`),
|
|
1041
|
+
"the agent subProcess wrapper carries the descriptive name",
|
|
1042
|
+
);
|
|
1043
|
+
// The bare `agent: impl` / `wait: gate` naming is gone.
|
|
1044
|
+
assert(!r.bpmn.includes('name="agent: impl"'), "no bare kind:id subProcess name remains");
|
|
1045
|
+
// `<bpmn:documentation>` is the FIRST child of the subProcess (before `<bpmn:incoming>`), and describes it.
|
|
1046
|
+
const wrapperOpen = r.bpmn.indexOf(`<bpmn:subProcess id="${el}" `);
|
|
1047
|
+
const beforeFlowRefs = r.bpmn.slice(wrapperOpen, r.bpmn.indexOf("<bpmn:incoming>", wrapperOpen));
|
|
1048
|
+
assert(beforeFlowRefs.includes("<bpmn:documentation>"), "the documentation precedes the flow refs (first child)");
|
|
1049
|
+
assert(!beforeFlowRefs.includes("<bpmn:extensionElements>"), "the documentation precedes extensionElements");
|
|
1050
|
+
assert(r.bpmn.includes("<bpmn:documentation>Agent job: senior:feature"), "the documentation describes the node");
|
|
1051
|
+
// The inner service task is named from the SAME nodeDisplay source (not the bare node id).
|
|
1052
|
+
assert(r.bpmn.includes(`<bpmn:serviceTask id="${el}_task" name="Implement #567 and open a PR. & converge+merge · impl">`), "the inner task shares the descriptive name");
|
|
1053
|
+
// Every node kind carries a documentation child.
|
|
1054
|
+
const waitEl = elementForNode(r.bpmn, "gate");
|
|
1055
|
+
assert(r.bpmn.includes(`<bpmn:subProcess id="${waitEl}" name="Wait: pr impl.pr · gate">`), "the wait wrapper carries the descriptive name");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("#778 nodeDisplay redacts a wait probe whose `kind` is PADDED (` command `) — the runtime trims the kind so it runs as a command probe; the display must trim too and route its arbitrary shell target to <redacted>, not leak it (thread deliveryGraphCompiler.ts:70)", async () => {
|
|
1059
|
+
// `kind: " command "` passes the validator (non-empty string) and the worker trims it to `command`, so
|
|
1060
|
+
// it runs as a COMMAND probe. A raw-kind compare here would miss that and send the shell snippet through
|
|
1061
|
+
// the URL-only redactor, leaking it into the compiled BPMN documentation. Compile through the real path
|
|
1062
|
+
// (unknown-typed input, so no cast is needed for the padded kind) and assert the secret never surfaces.
|
|
1063
|
+
const r = await compileOk({
|
|
1064
|
+
name: "padded-kind",
|
|
1065
|
+
nodes: [{ id: "g", kind: "wait", wait: { kind: " command ", target: "curl -H 'Authorization: SUPER_SECRET' https://api.example.com" } }],
|
|
1066
|
+
edges: [],
|
|
1067
|
+
});
|
|
1068
|
+
assert(!r.semanticBpmn.includes("SUPER_SECRET"), "a padded-kind command probe's shell target must not leak into the compiled BPMN");
|
|
1069
|
+
assert(r.semanticBpmn.includes("<redacted>"), "a padded-kind command probe's target must render as <redacted>");
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("#778 nodeDisplay redacts a wait probe's credential-bearing target in the name + documentation", () => {
|
|
1073
|
+
// A `command` target is an arbitrary shell snippet that can embed a secret — it is never surfaced.
|
|
1074
|
+
const cmd = nodeDisplay({
|
|
1075
|
+
id: "g",
|
|
1076
|
+
kind: "wait",
|
|
1077
|
+
wait: { kind: "command", target: "curl -H 'Authorization: Bearer SUPER_SECRET' https://api.example.com" },
|
|
1078
|
+
});
|
|
1079
|
+
assertEquals(cmd.name, "Wait: command <redacted> · g");
|
|
1080
|
+
assert(cmd.documentation.includes("Target: <redacted>"), "command target is redacted in the doc");
|
|
1081
|
+
assert(!cmd.name.includes("SUPER_SECRET") && !cmd.documentation.includes("SUPER_SECRET"), "no secret leaks");
|
|
1082
|
+
|
|
1083
|
+
// An HTTP target's `user:pass@` userinfo and `?query` (where a token often rides) are stripped.
|
|
1084
|
+
const http = nodeDisplay({
|
|
1085
|
+
id: "h",
|
|
1086
|
+
kind: "wait",
|
|
1087
|
+
wait: { kind: "http", target: "https://user:s3cr3t@example.com/health?token=abc123" },
|
|
1088
|
+
});
|
|
1089
|
+
assert(!http.name.includes("s3cr3t") && !http.name.includes("abc123"), "http name drops userinfo + query secret");
|
|
1090
|
+
assert(
|
|
1091
|
+
!http.documentation.includes("s3cr3t") && !http.documentation.includes("abc123"),
|
|
1092
|
+
"http doc drops userinfo + query secret",
|
|
1093
|
+
);
|
|
1094
|
+
assert(http.documentation.includes("Target: https://***@example.com/health?***"), "http target rendered redacted");
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test("#778 nodeDisplay redacts a URL-SHAPED non-http probe target (smuggled credential) while leaving a legitimate structured ref verbatim", () => {
|
|
1098
|
+
// The graph validator only requires a non-empty `target`, and `parsePrTarget` accepts any prefix
|
|
1099
|
+
// before `#<digits>` — so a URL-shaped value can reach a `pr`/`capability` target. It must still be
|
|
1100
|
+
// URL-redacted in the display path, while an ordinary `owner/repo#42` (whose `#`/`@` are meaningful,
|
|
1101
|
+
// not a URL) stays verbatim.
|
|
1102
|
+
const smuggled = nodeDisplay({
|
|
1103
|
+
id: "g",
|
|
1104
|
+
kind: "wait",
|
|
1105
|
+
wait: { kind: "pr", target: "//user:pass@example.com/repo#42", match: { prState: "merged" } },
|
|
1106
|
+
});
|
|
1107
|
+
assert(!smuggled.name.includes("user:pass") && !smuggled.documentation.includes("user:pass"), "smuggled userinfo is redacted");
|
|
1108
|
+
assert(smuggled.documentation.includes("Target: //***@example.com/repo#***"), "URL-shaped pr target rendered redacted");
|
|
1109
|
+
|
|
1110
|
+
const clean = nodeDisplay({
|
|
1111
|
+
id: "g2",
|
|
1112
|
+
kind: "wait",
|
|
1113
|
+
wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } },
|
|
1114
|
+
});
|
|
1115
|
+
assertEquals(clean.name, "Wait: pr owner/repo#42 · g2");
|
|
1116
|
+
assert(clean.documentation.includes("Target: owner/repo#42"), "legitimate structured pr ref is verbatim");
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
test("#778 nodeDisplay redacts a connector value even when a leading space would bypass the anchored URL check (trim classification)", () => {
|
|
1120
|
+
const c = nodeDisplay({
|
|
1121
|
+
id: "n",
|
|
1122
|
+
kind: "connector",
|
|
1123
|
+
connector: { target: " //user:pass@hooks.example.com?token=abc123" },
|
|
1124
|
+
});
|
|
1125
|
+
assert(!c.documentation.includes("user:pass") && !c.documentation.includes("abc123"), "leading-space URL still has its credential stripped");
|
|
1126
|
+
assert(c.documentation.includes("***@hooks.example.com?***"), "whitespace-prefixed connector target rendered redacted");
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
test("#778 a connector `timeout` differing ONLY in case/whitespace compiles to byte-identical BPMN and the SAME invisible-value set — the run-key digest collapses instead of forking on authored casing (Fix D, thread deliveryGraphCompiler.ts:1243)", async () => {
|
|
1130
|
+
const mk = (timeout: string): DeliveryGraph => ({
|
|
1131
|
+
name: "g",
|
|
1132
|
+
nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#releases", timeout } }],
|
|
1133
|
+
edges: [],
|
|
1134
|
+
});
|
|
1135
|
+
const canonical = await compileOk(mk("PT1H"));
|
|
1136
|
+
const variant = await compileOk(mk("pt1h "));
|
|
1137
|
+
assertEquals(variant.bpmn, canonical.bpmn, "case/whitespace-only timeout variants compile to identical BPMN (display renders the isoDuration-normalised value)");
|
|
1138
|
+
assertEquals(digestInvisibleRawValues(mk("pt1h ")), digestInvisibleRawValues(mk("PT1H")), "no invisible token forks the two timeout variants");
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("#783 a connector bound-`pr` payload differing ONLY in surrounding whitespace FORKS for a GENERIC connector (payload forwarded raw) but COLLAPSES for a CONVERGE target (runtime trims `pr`) — the run-key digest matches runtime dispatch identity (thread deliveryGraphCompiler.ts:1376)", async () => {
|
|
1142
|
+
const mk = (target: string, pr: string): DeliveryGraph => ({
|
|
1143
|
+
name: "g",
|
|
1144
|
+
nodes: [{ id: "c", kind: "connector", connector: { target, payload: { pr } } }],
|
|
1145
|
+
edges: [],
|
|
1146
|
+
});
|
|
1147
|
+
// GENERIC (forward-declared) target: the worker forwards `payload` UNCHANGED (no `resolveConvergePr`),
|
|
1148
|
+
// so `{pr:" x "}` and `{pr:"x"}` are DISTINCT runtime dispatches and MUST fork the display/digest.
|
|
1149
|
+
const genTight = await compileOk(mk("slack:#releases", "owner/repo#1"));
|
|
1150
|
+
const genPadded = await compileOk(mk("slack:#releases", " owner/repo#1 "));
|
|
1151
|
+
assert(genPadded.bpmn !== genTight.bpmn, "a generic connector's whitespace-only pr variants compile to DIFFERENT BPMN (payload forwarded raw, so they are distinct runtime dispatches)");
|
|
1152
|
+
// A clean (non-credential) pr is digest-VISIBLE (redaction is a no-op on `{pr}`), so the disambiguator
|
|
1153
|
+
// is the display/semanticBpmn itself — which now forks — not the digest-INVISIBLE raw-value set (that set
|
|
1154
|
+
// is empty here, exercised instead by the credential test below).
|
|
1155
|
+
assert(genPadded.semanticBpmn !== genTight.semanticBpmn, "a generic connector's whitespace-only pr variants fork the semanticBpmn (distinct run identity)");
|
|
1156
|
+
// CONVERGE target: `resolveConvergePr`/`parsePr` trim `pr` before use, so the padded and trimmed twins
|
|
1157
|
+
// drive the SAME dispatch and MUST collapse to one BPMN/digest.
|
|
1158
|
+
const cvgTight = await compileOk(mk("converge-merge", "owner/repo#1"));
|
|
1159
|
+
const cvgPadded = await compileOk(mk("converge-merge", " owner/repo#1 "));
|
|
1160
|
+
assertEquals(cvgPadded.bpmn, cvgTight.bpmn, "a converge target's whitespace-only pr variants compile to identical BPMN (display trims the bound pr, matching the runtime parse)");
|
|
1161
|
+
assertEquals(digestInvisibleRawValues(mk("converge-merge", " owner/repo#1 ")), digestInvisibleRawValues(mk("converge-merge", "owner/repo#1")), "no invisible token forks the two converge pr variants");
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("#778 a wait `poll` that NORMALISES to the runtime defaults compiles to byte-identical BPMN as an OMITTED poll — the display renders the CANONICAL effective policy (`normalizePoll`), not the authored fields, so a runtime-equivalent re-stage shares one digest instead of bypassing the idempotency fence (thread deliveryGraphCompiler.ts:1414)", async () => {
|
|
1165
|
+
const mk = (poll?: Record<string, unknown>): DeliveryGraph => ({
|
|
1166
|
+
name: "g",
|
|
1167
|
+
nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" }, ...(poll ? { poll } : {}) } }],
|
|
1168
|
+
edges: [],
|
|
1169
|
+
});
|
|
1170
|
+
const omitted = await compileOk(mk());
|
|
1171
|
+
// `everyMs:0` falls back to DEFAULT_EVERY_MS, `timeoutMs:0` to DEFAULT_TIMEOUT_MS, and an explicit
|
|
1172
|
+
// `backoff:"exponential"` IS the default — so this poll runs identically to an omitted one.
|
|
1173
|
+
const defaulted = await compileOk(mk({ everyMs: 0, timeoutMs: 0, backoff: "exponential" }));
|
|
1174
|
+
assertEquals(defaulted.bpmn, omitted.bpmn, "a poll normalising entirely to the defaults renders identically to an omitted poll (no `Poll:` line forks the digest)");
|
|
1175
|
+
|
|
1176
|
+
// A NON-default poll still renders — the canonical effective (truncated/clamped) values, so two
|
|
1177
|
+
// authored encodings of the same effective budget still collapse, but a genuinely different budget
|
|
1178
|
+
// forks as it should.
|
|
1179
|
+
const fractional = await compileOk(mk({ everyMs: 60000.9, timeoutMs: 3600000 }));
|
|
1180
|
+
const whole = await compileOk(mk({ everyMs: 60000, timeoutMs: 3600000 }));
|
|
1181
|
+
assertEquals(fractional.bpmn, whole.bpmn, "a fractional everyMs truncates to the same effective value, so the two encodings share one digest");
|
|
1182
|
+
assert(whole.bpmn !== omitted.bpmn, "a genuinely non-default poll budget still renders (and forks) distinctly from an omitted poll");
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("#778 a wait `poll.backoff` differing ONLY in surrounding whitespace does not fork the digest — `normalizePoll` trims `backoff` like `parseProbe`, so a padded `\" exponential \"` (the default) renders no `Poll:` line and a padded `\" fixed \"` matches the trimmed `\"fixed\"` (thread deliveryGraphCompiler.ts:1414)", async () => {
|
|
1186
|
+
const mk = (backoff: string): DeliveryGraph =>
|
|
1187
|
+
JSON.parse(
|
|
1188
|
+
JSON.stringify({
|
|
1189
|
+
name: "g",
|
|
1190
|
+
nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" }, poll: { backoff } } }],
|
|
1191
|
+
edges: [],
|
|
1192
|
+
}),
|
|
1193
|
+
);
|
|
1194
|
+
const omitted: DeliveryGraph = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } } }], edges: [] };
|
|
1195
|
+
const omittedR = await compileOk(omitted);
|
|
1196
|
+
// A padded `" exponential "` IS the default backoff once trimmed, so it must render identically to an
|
|
1197
|
+
// omitted poll (no `Poll:` line) — an untrimmed compare would emit a spurious non-default line.
|
|
1198
|
+
const paddedDefault = await compileOk(mk(" exponential "));
|
|
1199
|
+
assertEquals(paddedDefault.bpmn, omittedR.bpmn, "a padded default backoff renders identically to an omitted poll");
|
|
1200
|
+
// A padded non-default `" fixed "` matches the trimmed `"fixed"` (same effective policy, one digest).
|
|
1201
|
+
const paddedFixed = await compileOk(mk(" fixed "));
|
|
1202
|
+
const tightFixed = await compileOk(mk("fixed"));
|
|
1203
|
+
assertEquals(paddedFixed.bpmn, tightFixed.bpmn, "a padded `\" fixed \"` backoff compiles byte-identically to the trimmed `\"fixed\"`");
|
|
1204
|
+
assert(tightFixed.bpmn !== omittedR.bpmn, "a genuinely non-default `fixed` backoff still renders (and forks) distinctly from an omitted poll");
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("#783 a connector bound-`pr` payload carrying a CREDENTIAL differing ONLY in surrounding whitespace FORKS the invisible-value set for a GENERIC connector — the payload is forwarded raw, so the padded and trimmed twins are DISTINCT run keys (thread deliveryGraphCompiler.ts:1376)", () => {
|
|
1208
|
+
// A credential-bearing `pr` makes the payload digest-INVISIBLE (redaction drops content), so the whole
|
|
1209
|
+
// payload is fingerprinted as the disambiguator. For a GENERIC (forward-declared) connector the worker
|
|
1210
|
+
// forwards `payload` UNCHANGED (no `resolveConvergePr`/`parsePr`), so a padded `pr` and its trimmed twin
|
|
1211
|
+
// are DIFFERENT runtime payloads and MUST get DIFFERENT run keys — trimming them here would collapse two
|
|
1212
|
+
// distinct dispatches into one key and let the keyless dispatch fence reuse the wrong payload (issue #783
|
|
1213
|
+
// review). Only a CONVERGE target trims `pr`, so only there do the twins collapse.
|
|
1214
|
+
const mk = (target: string, pr: string): DeliveryGraph => ({
|
|
1215
|
+
name: "g",
|
|
1216
|
+
nodes: [{ id: "c", kind: "connector", connector: { target, payload: { pr } } }],
|
|
1217
|
+
edges: [],
|
|
1218
|
+
});
|
|
1219
|
+
const genPadded = digestInvisibleRawValues(mk("slack:#releases", " //user:pass@host "));
|
|
1220
|
+
const genTight = digestInvisibleRawValues(mk("slack:#releases", "//user:pass@host"));
|
|
1221
|
+
assert(
|
|
1222
|
+
JSON.stringify(genPadded) !== JSON.stringify(genTight),
|
|
1223
|
+
"a generic connector's padded credential-bearing pr must FORK the run key from its trimmed twin (payload forwarded raw)",
|
|
1224
|
+
);
|
|
1225
|
+
assert(genPadded.some((t) => t.includes("connector.payload")), `the credential-bearing payload is fingerprinted: ${JSON.stringify(genPadded)}`);
|
|
1226
|
+
assert(genPadded.join("").includes("//user:pass@host "), `the generic fingerprint carries the RAW untrimmed pr: ${JSON.stringify(genPadded)}`);
|
|
1227
|
+
// A CONVERGE target trims `pr` (runtime does too), so its padded/trimmed credential twins still collapse.
|
|
1228
|
+
const cvgPadded = digestInvisibleRawValues(mk("converge-merge", " //user:pass@host "));
|
|
1229
|
+
const cvgTight = digestInvisibleRawValues(mk("converge-merge", "//user:pass@host"));
|
|
1230
|
+
assertEquals(cvgPadded, cvgTight, "a converge target's padded credential-bearing pr must not fork the run key from its trimmed twin");
|
|
1231
|
+
assert(!cvgPadded.join("").includes("//user:pass@host "), `the converge fingerprint carries the trimmed pr, not the padded raw: ${JSON.stringify(cvgPadded)}`);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
test("#778 a github-check `match.conclusion` authored as the NUMBER 1 vs the STRING \"1\" does not fork the run key — `parseMatch` coerces every string field through `str().trim()`, so they are the SAME runtime probe; only num()-backed fields (`status`/`exitCode`) stay type-sensitive (thread deliveryGraphCompiler.ts:1251)", () => {
|
|
1235
|
+
// `matchValueTypeMismatch` flagged EVERY non-string value on a declared string field as a digest-invisible
|
|
1236
|
+
// fork, but `parseMatch` runs those fields through `str(v).trim()`, so `conclusion: 1` and `conclusion: "1"`
|
|
1237
|
+
// probe identically and render identically — flagging one forked the stable run key of equivalent graphs.
|
|
1238
|
+
// A JSON round-trip injects the runtime cross-type value the untyped ingress produces.
|
|
1239
|
+
const mkConclusion = (conclusion: string | number): DeliveryGraph =>
|
|
1240
|
+
JSON.parse(JSON.stringify({ name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "o/r@main", match: { conclusion } } }], edges: [] }));
|
|
1241
|
+
assertEquals(
|
|
1242
|
+
digestInvisibleRawValues(mkConclusion(1)),
|
|
1243
|
+
digestInvisibleRawValues(mkConclusion("1")),
|
|
1244
|
+
"a string-field match value coerces uniformly, so a number and its string twin share one run key",
|
|
1245
|
+
);
|
|
1246
|
+
// A num()-backed field STAYS type-sensitive: `status: 200` (probes 200) and `status: "200"` (coerces to
|
|
1247
|
+
// undefined = any-2xx) are runtime-DIFFERENT yet render identically, so they MUST still be disambiguated.
|
|
1248
|
+
const mkStatus = (status: string | number): DeliveryGraph =>
|
|
1249
|
+
JSON.parse(JSON.stringify({ name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://x/health", match: { status } } }], edges: [] }));
|
|
1250
|
+
assert(
|
|
1251
|
+
JSON.stringify(digestInvisibleRawValues(mkStatus(200))) !== JSON.stringify(digestInvisibleRawValues(mkStatus("200"))),
|
|
1252
|
+
"a numeric-field cross-type value stays a distinct run key (its runtime meaning genuinely differs)",
|
|
1253
|
+
);
|
|
1254
|
+
// But TWO non-number variants of a numeric field BOTH coerce to `undefined` (unset / any-2xx) — the
|
|
1255
|
+
// SAME runtime match — and display identically, so they must NOT fork the run key. Before the fix the
|
|
1256
|
+
// mismatch branch fingerprinted the RAW value, forking `" 200 "` from `"200"` and double-dispatching.
|
|
1257
|
+
assertEquals(
|
|
1258
|
+
digestInvisibleRawValues(mkStatus(" 200 ")),
|
|
1259
|
+
digestInvisibleRawValues(mkStatus("200")),
|
|
1260
|
+
"two non-number status variants coerce to the same unset match and must share one run key",
|
|
1261
|
+
);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("#778 a REDACTED match field (`verifyCommand`) authored as the NUMBER 1 vs the STRING \"1\" does not fork the run key — its display is a constant `<redacted>`, so the fingerprint must use the SAME `str().trim()` runtime coercion (thread deliveryGraphCompiler.ts:1251, same class)", () => {
|
|
1265
|
+
const mk = (verifyCommand: string | number): DeliveryGraph =>
|
|
1266
|
+
JSON.parse(JSON.stringify({ name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "@nanobpm/urban", match: { package: "@nanobpm/urban", capabilityRef: "#1", verifyCommand } } }], edges: [] }));
|
|
1267
|
+
assertEquals(
|
|
1268
|
+
digestInvisibleRawValues(mk(1)),
|
|
1269
|
+
digestInvisibleRawValues(mk("1")),
|
|
1270
|
+
"a redacted string field coerces uniformly, so a number and its string twin share one run key",
|
|
1271
|
+
);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("#778 a NON-redacted `wait.match` string predicate (`capabilityRef`) differing ONLY in surrounding whitespace does not fork the run key — `parseMatch`/`describeProbeMatch` trim it before use, so the padded and trimmed twins are the SAME runtime probe and must share one staged run key (thread deliveryGraphCompiler.ts:1591)", () => {
|
|
1275
|
+
// Before the fix the non-redacted branch compared/fingerprinted the UNTRIMMED `String(v)`, so a
|
|
1276
|
+
// credential-bearing predicate padded with whitespace (`" //user:pass@host#1 "`) forked the stable run
|
|
1277
|
+
// key from its trimmed twin — letting a keyless re-stage double-dispatch an identical graph.
|
|
1278
|
+
const mk = (capabilityRef: string): DeliveryGraph =>
|
|
1279
|
+
JSON.parse(
|
|
1280
|
+
JSON.stringify({ name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "@nanobpm/urban", match: { capabilityRef } } }], edges: [] }),
|
|
1281
|
+
);
|
|
1282
|
+
assertEquals(
|
|
1283
|
+
digestInvisibleRawValues(mk(" //user:pass@host#1 ")),
|
|
1284
|
+
digestInvisibleRawValues(mk("//user:pass@host#1")),
|
|
1285
|
+
"a padded non-redacted string predicate must not fork the run key from its trimmed twin",
|
|
1286
|
+
);
|
|
1287
|
+
// The credential is still fingerprinted (it IS digest-invisible after display redaction), just at its
|
|
1288
|
+
// trimmed value — so a genuinely different credential still forks.
|
|
1289
|
+
assert(
|
|
1290
|
+
JSON.stringify(digestInvisibleRawValues(mk("//user:pass@host#1"))) !== JSON.stringify(digestInvisibleRawValues(mk("//user:pass@host#2"))),
|
|
1291
|
+
"a genuinely different credential predicate still forks the run key",
|
|
1292
|
+
);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("#778 nodeDisplay redacts a connector value whose URL is prefixed by an XML-invalid control char (strip-before-classify)", () => {
|
|
1296
|
+
// A control char (U+0001) that XML 1.0 forbids gets stripped by `escapeXml`/`stripXmlInvalidChars` at
|
|
1297
|
+
// render time. If classification/redaction ran on the RAW value, the anchored `^(scheme:)?//` check
|
|
1298
|
+
// would miss the URL (it starts with the control char), leaving the credential to surface once the
|
|
1299
|
+
// prefix is dropped. Stripping before classifying closes that bypass.
|
|
1300
|
+
const c = nodeDisplay({
|
|
1301
|
+
id: "n",
|
|
1302
|
+
kind: "connector",
|
|
1303
|
+
connector: { target: "\u0001//user:pass@hooks.example.com?token=abc123" },
|
|
1304
|
+
});
|
|
1305
|
+
assert(!c.documentation.includes("user:pass") && !c.documentation.includes("abc123"), "control-char-prefixed URL still has its credential stripped");
|
|
1306
|
+
assert(c.documentation.includes("***@hooks.example.com?***"), "control-char-prefixed connector target rendered redacted");
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("#778 nodeDisplay redacts a credential-bearing URL embedded in an agent/human prompt (in place, prose intact) — the raw prompt only reaches the runtime job input", () => {
|
|
1310
|
+
const agent = nodeDisplay({
|
|
1311
|
+
id: "impl",
|
|
1312
|
+
kind: "agent",
|
|
1313
|
+
agent: { jobType: "senior:feature", prompt: "Fetch https://user:pass@api.example.com/data?token=abc123 then open a PR. Ready?" },
|
|
1314
|
+
});
|
|
1315
|
+
assert(!agent.name.includes("user:pass") && !agent.name.includes("abc123"), "agent name drops the embedded URL credential");
|
|
1316
|
+
assert(!agent.documentation.includes("user:pass") && !agent.documentation.includes("abc123"), "agent doc drops the embedded URL credential");
|
|
1317
|
+
// The prose around the URL — including a legitimate trailing `?` — survives (redaction is in place,
|
|
1318
|
+
// NOT a truncation from the first `?` to end-of-string).
|
|
1319
|
+
assert(agent.documentation.includes("then open a PR. Ready?"), "prose after the URL (and a sentence `?`) is preserved");
|
|
1320
|
+
|
|
1321
|
+
const human = nodeDisplay({ id: "otp", kind: "human", human: { prompt: "Publish using //deploy:s3cr3t@registry.example.com" } });
|
|
1322
|
+
assert(!human.name.includes("s3cr3t") && !human.documentation.includes("s3cr3t"), "human prompt drops the embedded credential");
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("#778 an XML-forbidden control INSIDE a credential-bearing URL cannot survive redaction in any display path (strip-before-redact)", async () => {
|
|
1326
|
+
// The control (`\x0B`, XML-1.0-forbidden) splits `redactString`'s `//user:pass@` / `//[^\s]+` match, so
|
|
1327
|
+
// redacting the RAW value would miss the userinfo; the renderer then strips the control, re-joining a
|
|
1328
|
+
// live credential. Every display path must sanitise BEFORE redacting. Cover the three that redact URLs.
|
|
1329
|
+
// (a) http wait probe → `redactString(stripXmlInvalidChars(target))`.
|
|
1330
|
+
const wait = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "//us\u000Ber:p4ss@api.example.com/health?token=abc123", match: { status: 200 } } });
|
|
1331
|
+
assert(!wait.documentation.includes("p4ss") && !wait.documentation.includes("abc123"), "http probe target drops the split credential");
|
|
1332
|
+
assert(!/[\u000B]/.test(wait.documentation), "no raw control char remains in the probe display");
|
|
1333
|
+
// (b) free-form agent prompt → `redactFreeText` (strip-before-tokenize).
|
|
1334
|
+
const agent = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "curl //us\u000Ber:p4ss@api.example.com/d?token=abc123 then report" } });
|
|
1335
|
+
assert(!agent.documentation.includes("p4ss") && !agent.documentation.includes("abc123"), "agent prompt drops the split credential");
|
|
1336
|
+
// (c) end-to-end: the compiled BPMN never carries the reconstructed secret.
|
|
1337
|
+
const r = await compileOk({ name: "split-cred", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "//us\u000Ber:p4ss@api.example.com/health?token=abc123", match: { status: 200 } } }], edges: [] });
|
|
1338
|
+
assert(!r.bpmn.includes("p4ss") && !r.bpmn.includes("abc123"), "no split credential is reconstructed into the compiled BPMN");
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test("#778 redactString consumes the query/fragment across an embedded line break (newline-safe)", () => {
|
|
1342
|
+
// `[?#].*$` cannot cross a `\n`, so a target whose query is followed by another line kept the token.
|
|
1343
|
+
const wait = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "https://host/api?token=s3cr3t\nX-Extra: leak", match: { status: 200 } } });
|
|
1344
|
+
assert(!wait.documentation.includes("s3cr3t"), "the token before the line break is redacted");
|
|
1345
|
+
assert(wait.documentation.includes("?***"), "the query is redacted through the line break");
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test("#778 redactFreeText consumes a `//user:pass@` userinfo that embeds a raw line break (newline-safe)", () => {
|
|
1349
|
+
// A CR/LF smuggled INSIDE the userinfo stops the whitespace-delimited `//[^\s]+` token, so a plain
|
|
1350
|
+
// scan left `ss@host` visible in the display doc (XML preserves the break). The whole-string userinfo
|
|
1351
|
+
// re-scan must strip it while a space/tab still bounds ordinary prose.
|
|
1352
|
+
const agent = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "deploy via //user:pa\nss@registry.example.com now" } });
|
|
1353
|
+
assert(!agent.documentation.includes("ss@registry") && !agent.documentation.includes("user:pa"), "the newline-split userinfo is redacted");
|
|
1354
|
+
assert(agent.documentation.includes("//***@"), "the userinfo collapses to the redaction marker");
|
|
1355
|
+
// Canonical alignment (#783 review — thread deliveryGraphCompiler.ts:1140): redaction now DERIVES from
|
|
1356
|
+
// the ONE canonical `redactEmbeddedCredential` (`//[^/?#]*@`), which classifies ANY `//<userinfo>@` span
|
|
1357
|
+
// as credential-bearing regardless of intervening words/spaces. So a prose `//b then email admin@…` is
|
|
1358
|
+
// also collapsed to `//***@corp.example` — the SAFE direction: the RAW prompt still reaches the runtime
|
|
1359
|
+
// job input unmodified, only the operator-visible display doc loses the ambiguous span. (This supersedes
|
|
1360
|
+
// the earlier bespoke belt's prose-survival carve-out, a second "what is a credential" impl that drifted.)
|
|
1361
|
+
const prose = nodeDisplay({ id: "b", kind: "agent", agent: { jobType: "j", prompt: "compare a//b then email admin@corp.example" } });
|
|
1362
|
+
assert(!prose.documentation.includes("admin@corp.example"), "a `//…@` span in prose is collapsed by the canonical redactor (safe direction)");
|
|
1363
|
+
assert(prose.documentation.includes("//***@corp.example"), "the `//…@` prose span collapses to the redaction marker");
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("#778 redactFreeText consumes a `//user:pass@` userinfo that embeds a raw TAB (tab-safe)", () => {
|
|
1367
|
+
// A TAB is a VALID XML character `stripXmlInvalidChars` does not remove, so the whitespace-delimited
|
|
1368
|
+
// `//[^\s]+` token splits `//user:pa\tss@host` before the `@`, leaking `ss@host` into the display doc
|
|
1369
|
+
// (XML preserves the TAB). The credential-shaped belt scan (`//…:…@`, space-bounded so it may cross the
|
|
1370
|
+
// tab inside the userinfo) strips the tab-split userinfo — and its `?token=…` tail (issue #778).
|
|
1371
|
+
const agent = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "deploy via //user:pa\tss@registry.example.com/p?token=s3cr3t now" } });
|
|
1372
|
+
assert(!agent.documentation.includes("ss@registry") && !agent.documentation.includes("user:pa"), "the tab-split userinfo is redacted");
|
|
1373
|
+
assert(!agent.documentation.includes("s3cr3t"), "the query token is redacted");
|
|
1374
|
+
assert(agent.documentation.includes("//***@"), "the userinfo collapses to the redaction marker");
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test("#778 redactFreeText: a credential span whose `?query` carries a literal `@` does NOT let the belt userinfo class cross the delimiter and leak the tail (thread deliveryGraphCompiler.ts:1183)", () => {
|
|
1378
|
+
// A `//user:secret pass@host?x=y@tail` span — the literal SPACE in the userinfo forces the belt (the
|
|
1379
|
+
// primary `//[^\s]+` token stops at the space, so the belt owns the whole span). An unbounded `[^/]*@`
|
|
1380
|
+
// belt userinfo class consumed the `?` marker (`…?x=y@` → `//***@`), so the follow-on query strip found
|
|
1381
|
+
// no `?` and left `tail` visible. Bounding the class before `?`/`#` keeps the `?` in place so the whole
|
|
1382
|
+
// `?query` (incl. the `@tail`) is redacted (issue #778 review).
|
|
1383
|
+
const out = redactFreeText("run //user:secret pass@host?x=y@tail now");
|
|
1384
|
+
assert(!out.includes("tail"), `the query tail past the @ must not leak: ${out}`);
|
|
1385
|
+
assert(!out.includes("secret") && !out.includes("x=y"), `userinfo + query both redacted: ${out}`);
|
|
1386
|
+
assert(out.includes("//***@"), `the userinfo collapses to //***@: ${out}`);
|
|
1387
|
+
assert(out.includes("?***"), `the query collapses to the marker: ${out}`);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test("#778 redactFreeText: prose AFTER a `?query`/`#fragment` split across a line break is CONSERVATIVELY redacted, not preserved (thread :1115 supersedes :1083)", () => {
|
|
1391
|
+
// A `?token=VALUE` whose VALUE rides across a newline (`?token=\nsecret`) is a SPLIT credential: the
|
|
1392
|
+
// far side of the break is the secret's continuation, indistinguishable from ordinary prose resuming
|
|
1393
|
+
// after the URL. Round-18 preserved that tail (suppressed advisory :1083); the higher-confidence
|
|
1394
|
+
// inline finding :1115 showed preservation leaks the split value, so the belt now redacts the whole
|
|
1395
|
+
// `?query`/`#fragment` tail through the span's space boundary — trailing continuation included. The RAW
|
|
1396
|
+
// prompt still reaches the runtime job input intact; only the operator-visible display doc loses the
|
|
1397
|
+
// ambiguous tail (issue #778 review).
|
|
1398
|
+
const out = redactFreeText("fetch //host/api?token=s3cr3t\nthen review the results please");
|
|
1399
|
+
assert(!out.includes("s3cr3t"), `the query token is redacted: ${out}`);
|
|
1400
|
+
assert(out.includes("?***"), `the query collapses to the marker: ${out}`);
|
|
1401
|
+
assert(!out.includes("then"), `the same-span continuation after the break is redacted away, not kept: ${out}`);
|
|
1402
|
+
// Prose beyond the span's SPACE boundary (a genuinely separate word) is untouched.
|
|
1403
|
+
assert(out.includes("review the results please"), `prose past the span boundary survives: ${out}`);
|
|
1404
|
+
// A `#fragment` split the same way is likewise redacted through the span.
|
|
1405
|
+
const frag = redactFreeText("open //host/p#sig=zzz\nand confirm the deploy");
|
|
1406
|
+
assert(!frag.includes("sig=zzz") && !frag.includes("and"), `fragment + in-span continuation redacted: ${frag}`);
|
|
1407
|
+
assert(frag.includes("confirm the deploy"), `prose past the span boundary survives: ${frag}`);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test("#783 redactFreeText: a break-spanning `//<word>@host` span IS redacted by the canonical redactor (safe direction, thread deliveryGraphCompiler.ts:1140)", () => {
|
|
1411
|
+
// Canonical alignment (#783 review): `redactEmbeddedCredential`'s `//[^/?#]*@` class spans the embedded
|
|
1412
|
+
// newline, so a `//comment\nowner@example.com` span is a `//<userinfo>@` shape and collapses to
|
|
1413
|
+
// `//***@example.com`. The earlier bespoke belt tried to distinguish "prose" from "credential" here — a
|
|
1414
|
+
// drifting SECOND classifier — and leaked the wrapped userinfo shape it misjudged. A `//…@` span is
|
|
1415
|
+
// UNAMBIGUOUSLY credential-shaped wherever it sits, so redacting it is the SAFE direction (the RAW prompt
|
|
1416
|
+
// still reaches the runtime job input; only the operator-visible display loses the span).
|
|
1417
|
+
const out = redactFreeText("Use //comment\nowner@example.com for context");
|
|
1418
|
+
assert(!out.includes("owner@example.com"), `a break-spanning //...@ span is collapsed by the canonical redactor: ${out}`);
|
|
1419
|
+
assert(out.includes("//***@example.com"), `the span collapses to the redaction marker: ${out}`);
|
|
1420
|
+
// A genuine `user:pass@` userinfo split across the same newline IS still redacted (and its query too).
|
|
1421
|
+
const cred = redactFreeText("Use //user:pass\nx@host/x?token=abc for context");
|
|
1422
|
+
assert(!cred.includes("token=abc") && !cred.includes("user:pass"), `a real split credential must be redacted: ${cred}`);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test("#778 redactFreeText consumes a `?query` split from its URL by a line break (query-across-break)", () => {
|
|
1426
|
+
// The whitespace-delimited `//[^\s]+` primary token STOPS at the break, so a `//host/?` followed by a
|
|
1427
|
+
// new-line `TOKEN=secret` left the secret visible in the XML-preserved display doc even though it is the
|
|
1428
|
+
// URL's query. The linear belt (space-bounded, so it crosses the break) redacts the whole `?…` tail
|
|
1429
|
+
// (issue #778 review — the credential-shaped-`@`-only belt regex was blind to a query with no userinfo).
|
|
1430
|
+
const out = redactFreeText("see //host/?\nTOKEN=secret now");
|
|
1431
|
+
assert(!out.includes("TOKEN=secret"), `a break-spanning query token must be redacted: ${out}`);
|
|
1432
|
+
assert(out.includes("?***"), `the query collapses to the redaction marker: ${out}`);
|
|
1433
|
+
// A bare `#fragment` split the same way is likewise redacted.
|
|
1434
|
+
const frag = redactFreeText("open //host/#\nsig=zzz please");
|
|
1435
|
+
assert(!frag.includes("sig=zzz"), `a break-spanning fragment must be redacted: ${frag}`);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
test("#783 redactFreeText: a PASSWORDLESS `//<userinfo>@host` bearer wrapped across a whitespace break before its `@` is redacted, AND a break-spanning prose email `//comment\\nowner@…` is ALSO collapsed by the canonical redactor (safe direction, #783 review — thread deliveryGraphCompiler.ts:1140/:1460)", () => {
|
|
1439
|
+
// The canonical `redactEmbeddedCredential` (`//[^/?#]*@`) spans the embedded break, so BOTH a colon-less
|
|
1440
|
+
// wrapped bearer (`//token\n@host`) and a prose email after a `//comment` reference (`//comment\nowner@…`)
|
|
1441
|
+
// are `//<userinfo>@` shapes that collapse to `//***@`. The earlier bespoke belt tried to keep the prose
|
|
1442
|
+
// email — a drifting second classifier that mislabelled some real credentials — so we now redact any
|
|
1443
|
+
// `//…@` span uniformly (safe direction: RAW prompt still reaches runtime, only the display loses it).
|
|
1444
|
+
const out = redactFreeText("use //token\n@host please");
|
|
1445
|
+
assert(!out.includes("//token") && !out.includes("token\n@"), `a wrapped passwordless userinfo must be redacted: ${JSON.stringify(out)}`);
|
|
1446
|
+
assert(out.includes("//***@"), `the userinfo collapses to the redaction marker: ${JSON.stringify(out)}`);
|
|
1447
|
+
// A TAB-wrapped passwordless userinfo is likewise caught.
|
|
1448
|
+
const tab = redactFreeText("use //token\t@host please");
|
|
1449
|
+
assert(!tab.includes("//token"), `a tab-wrapped passwordless userinfo must be redacted: ${JSON.stringify(tab)}`);
|
|
1450
|
+
// A break-spanning prose email is now ALSO collapsed (canonical alignment, no prose carve-out).
|
|
1451
|
+
const prose = redactFreeText("Use //comment\nowner@example.com for context");
|
|
1452
|
+
assert(!prose.includes("owner@example.com"), `a //...@ prose span is collapsed by the canonical redactor: ${prose}`);
|
|
1453
|
+
assert(prose.includes("//***@example.com"), "the prose span collapses to the redaction marker");
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("#778 redactFreeText: a `//user:pass@ss@host` userinfo with MULTIPLE raw `@` collapses the WHOLE authority credential, not just the prefix (multi-@ suffix-leak — thread deliveryGraphCompiler.ts:1056)", () => {
|
|
1457
|
+
// The belt's `redactCredentialSpan` rewrite regex stopped at the FIRST `@`, so an embedded
|
|
1458
|
+
// `//user:pass@ss@host` in prose left `ss@host` — the userinfo suffix — visible in the display doc.
|
|
1459
|
+
// The class is now `[^/]` so the span reaches the LAST `@` before the path and the whole credential
|
|
1460
|
+
// authority collapses to `//***@`.
|
|
1461
|
+
const out = redactFreeText("clone //user:pass@ss@host/repo now");
|
|
1462
|
+
assert(!out.includes("ss@host"), `the multi-@ userinfo suffix must not leak: ${JSON.stringify(out)}`);
|
|
1463
|
+
assert(!out.includes("user:pass"), "the userinfo prefix is redacted");
|
|
1464
|
+
assert(out.includes("//***@host"), `the whole authority credential collapses to the marker: ${JSON.stringify(out)}`);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test("#778 redactFreeText is linear on an adversarial `//…:…` prompt (no catastrophic backtracking)", () => {
|
|
1468
|
+
// The old `/\/\/[^ @]*:[^ @]*@[^ ]*/g` belt regex backtracked quadratically on a long run of `//…:…`
|
|
1469
|
+
// segments that never reach an `@`. The linear span scanner walks each `//`-run once, so a 20 000-char
|
|
1470
|
+
// adversarial prompt completes near-instantly. Guard the class with a generous wall-clock bound.
|
|
1471
|
+
const adversarial = `deploy ${"//a:".repeat(5000)} then done`;
|
|
1472
|
+
const t0 = Date.now();
|
|
1473
|
+
const out = redactFreeText(adversarial);
|
|
1474
|
+
assert(Date.now() - t0 < 1000, "redactFreeText must not exhibit catastrophic backtracking");
|
|
1475
|
+
// A run with no `@`, no `?`, no `#` is not credential-shaped, so it is left intact (not over-redacted).
|
|
1476
|
+
assert(out.includes("//a:"), "a non-credential `//…:` run is not redacted");
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
test("#778 redactFreeText consumes a `//user:secret pass@host` userinfo split by a literal SPACE (space-safe belt)", () => {
|
|
1480
|
+
// The SPACE that bounds a belt span also cuts a `user:secret pass@host` userinfo carrying a literal
|
|
1481
|
+
// space BEFORE its `@`, so the span (`//user:secret`) has a `:` but no `@` and escaped redaction —
|
|
1482
|
+
// leaking `user:secret` into the display doc. The belt now looks PAST the space for the userinfo `@`
|
|
1483
|
+
// before any URL/line boundary and extends the span through it (issue #778 review — thread
|
|
1484
|
+
// deliveryGraphCompiler.ts:1080).
|
|
1485
|
+
const agent = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "deploy via //user:secret pass@registry.example.com/p?token=s3cr3t now" } });
|
|
1486
|
+
assert(!agent.documentation.includes("user:secret") && !agent.documentation.includes("secret pass"), `the space-split userinfo is redacted: ${agent.documentation}`);
|
|
1487
|
+
assert(!agent.documentation.includes("s3cr3t"), "the query token is redacted");
|
|
1488
|
+
assert(agent.documentation.includes("//***@"), "the userinfo collapses to the redaction marker");
|
|
1489
|
+
// A `//foo:bar` prose run with NO reachable `@` (the space-bounded lookahead hits end-of-prose, not an
|
|
1490
|
+
// `@`) is NOT over-redacted.
|
|
1491
|
+
const prose = nodeDisplay({ id: "b", kind: "agent", agent: { jobType: "j", prompt: "ratio //foo:bar then continue" } });
|
|
1492
|
+
assert(prose.documentation.includes("//foo:bar"), `a colon run with no reachable @ survives: ${prose.documentation}`);
|
|
1493
|
+
assert(!prose.documentation.includes("//***@"), `no credential is fabricated from a bare colon run: ${prose.documentation}`);
|
|
1494
|
+
// A `//user:pass` prefix whose lookahead hits a `/` boundary (a new path segment) before any `@` is not
|
|
1495
|
+
// extended — the colon run is left intact rather than collapsed to a phantom `//***@`.
|
|
1496
|
+
const bounded = nodeDisplay({ id: "c", kind: "agent", agent: { jobType: "j", prompt: "cmp //user:pass /next then done" } });
|
|
1497
|
+
assert(bounded.documentation.includes("//user:pass"), `a '/'-bounded lookahead leaves the colon run intact: ${bounded.documentation}`);
|
|
1498
|
+
assert(!bounded.documentation.includes("//***@"), `a '/'-bounded lookahead does not fabricate a credential: ${bounded.documentation}`);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
test("#783 redactFreeText: a colon-less bearer `//<userinfo>@host` split from its `@host` by a SPACE or a WORD is redacted, AND a spaced prose email `//comment owner@…` is ALSO collapsed (canonical alignment, #783 review — thread deliveryGraphCompiler.ts:1140/:1108)", () => {
|
|
1502
|
+
// The canonical `redactEmbeddedCredential` (`//[^/?#]*@`) spans intervening spaces AND words up to the
|
|
1503
|
+
// `@`, so `//token @host`, `//token part@host` (WORD between), and even a spaced prose email
|
|
1504
|
+
// `//comment owner@example.com` are all `//<userinfo>@` shapes that collapse to `//***@`. The earlier
|
|
1505
|
+
// bespoke belt's "bridge only across whitespace, keep the WORD-separated form as prose" carve-out was a
|
|
1506
|
+
// drifting second classifier that leaked `//token part@host`; deriving from the ONE canonical redactor
|
|
1507
|
+
// fixes the leak and redacts any `//…@` span uniformly (safe direction).
|
|
1508
|
+
const out = redactFreeText("use //token @host please");
|
|
1509
|
+
assert(!out.includes("//token ") && out.includes("//***@host"), `a space-split passwordless userinfo must be redacted: ${JSON.stringify(out)}`);
|
|
1510
|
+
const multi = redactFreeText("use //token @host please");
|
|
1511
|
+
assert(!multi.includes("//token ") && multi.includes("//***@host"), `a multi-space bridge must redact: ${JSON.stringify(multi)}`);
|
|
1512
|
+
// A WORD between the userinfo and its `@host` (the leak this review fixed) is now redacted too.
|
|
1513
|
+
const word = redactFreeText("publish //token part@host");
|
|
1514
|
+
assert(!word.includes("token part@") && !word.includes("//token part"), `a WORD-split bearer must not leak: ${JSON.stringify(word)}`);
|
|
1515
|
+
assert(word.includes("//***@host"), `the WORD-split userinfo collapses to the marker: ${JSON.stringify(word)}`);
|
|
1516
|
+
// A spaced prose email is now ALSO collapsed (canonical alignment, no prose carve-out).
|
|
1517
|
+
const prose = redactFreeText("Use //comment owner@example.com for context");
|
|
1518
|
+
assert(!prose.includes("owner@example.com"), `a //...@ prose span is collapsed by the canonical redactor: ${prose}`);
|
|
1519
|
+
assert(prose.includes("//***@example.com"), "the prose span collapses to the redaction marker");
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
test("#778 wait.target digest fingerprint is TRIMMED so a whitespace-only variant shares one staged run key (all kinds)", () => {
|
|
1523
|
+
// `parseProbe` trims `target` for every kind before the worker keys on it, so ` run-task ` and
|
|
1524
|
+
// `run-task` are the SAME runtime probe. A `command` probe's display is a constant `<redacted>`, so
|
|
1525
|
+
// the untrimmed raw was the sole digest disambiguator — forking the staged run key on pure whitespace
|
|
1526
|
+
// and bypassing the idempotency fence. The fingerprint is now `target.trim()` (issue #778 review —
|
|
1527
|
+
// thread deliveryGraphCompiler.ts:1372).
|
|
1528
|
+
const mk = (target: string): DeliveryGraph => ({ nodes: [{ id: "w", kind: "wait", wait: { kind: "command", target } }], edges: [] });
|
|
1529
|
+
const padded = digestInvisibleRawValues(mk(" run-task ")).filter((t) => t.includes("wait.target"));
|
|
1530
|
+
const tight = digestInvisibleRawValues(mk("run-task")).filter((t) => t.includes("wait.target"));
|
|
1531
|
+
assertEquals(padded, tight);
|
|
1532
|
+
assert(tight.length === 1 && tight[0].endsWith("run-task"), `the fingerprint is the trimmed target: ${tight[0]}`);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test("#778 agent.jobType validation error quotes the offending value with JSON.stringify (control-char-safe)", async () => {
|
|
1536
|
+
// A jobType carrying a raw control char (here a TAB) was interpolated between literal double quotes in
|
|
1537
|
+
// the error message, so the control survived verbatim into the rendered error. `JSON.stringify` escapes
|
|
1538
|
+
// it (and matches every other jobType/emit error in the validator) (issue #778 review).
|
|
1539
|
+
const errors = await compileFail({ nodes: [{ id: "n", kind: "agent", agent: { jobType: "bad\ttype" } }], edges: [] });
|
|
1540
|
+
const msg = errors.map((e) => e.message).join("\n");
|
|
1541
|
+
assert(msg.includes('"bad\\ttype"'), `the error quotes the value via JSON.stringify: ${msg}`);
|
|
1542
|
+
assert(!msg.includes("bad\ttype"), "the raw TAB does not survive into the error message");
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("#778 nodeDisplay surfaces a wait probe's credentialEnv key NAME so a credential-differing graph gets a DISTINCT digest (not a collision)", () => {
|
|
1546
|
+
// `credentialEnv` names a validated env-contract KEY (never a secret) but selects which credential a
|
|
1547
|
+
// probe uses at runtime; omitting it from the display let two otherwise-identical graphs collapse to
|
|
1548
|
+
// one content-address. Surfacing the key name makes the digest faithful.
|
|
1549
|
+
const withCred = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "https://api.example.com/health", credentialEnv: "PROD_API_TOKEN", match: { status: 200 } } });
|
|
1550
|
+
const otherCred = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "https://api.example.com/health", credentialEnv: "STAGING_API_TOKEN", match: { status: 200 } } });
|
|
1551
|
+
const noCred = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "https://api.example.com/health", match: { status: 200 } } });
|
|
1552
|
+
assert(withCred.documentation.includes("Credential env: PROD_API_TOKEN"), "the env-key name is surfaced in the display doc");
|
|
1553
|
+
assert(withCred.documentation !== otherCred.documentation, "two graphs differing only in credentialEnv render distinctly (distinct digest)");
|
|
1554
|
+
assert(withCred.documentation !== noCred.documentation, "presence of credentialEnv changes the display (distinct digest)");
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
test("#778 firstLine falls back to the job type / decision label when a prompt is only XML-forbidden control chars", () => {
|
|
1559
|
+
// A prompt of only `\x01` would otherwise be selected and render as a blank ` · <id>` label. firstLine
|
|
1560
|
+
// now skips a line whose SANITISED content is empty, so the fallback (`jobType` / `Human decision`) wins.
|
|
1561
|
+
const agent = nodeDisplay({ id: "n", kind: "agent", agent: { jobType: "senior:build", prompt: "\u0001\u0007" } });
|
|
1562
|
+
assert(agent.name.startsWith("senior:build · "), "an all-control-char prompt falls back to the job type label");
|
|
1563
|
+
const human = nodeDisplay({ id: "h", kind: "human", human: { prompt: "\u0001" } });
|
|
1564
|
+
assert(human.name.startsWith("Human decision · "), "an all-control-char human prompt falls back to `Human decision`");
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
test("#778 the wait + human inner tasks carry the descriptive display name (not the bare id)", async () => {
|
|
1568
|
+
const graph = {
|
|
1569
|
+
name: "inner names",
|
|
1570
|
+
nodes: [
|
|
1571
|
+
{ id: "gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } } },
|
|
1572
|
+
{ id: "otp", kind: "human", human: { prompt: "Run the manual OTP publish", formKey: "publish-form" } },
|
|
1573
|
+
],
|
|
1574
|
+
edges: [{ from: "gate", to: "otp" }],
|
|
1575
|
+
};
|
|
1576
|
+
const r = await compileOk(graph);
|
|
1577
|
+
const waitEl = elementForNode(r.bpmn, "gate");
|
|
1578
|
+
// All three wait inner elements (loop, probe, boundary retry) now use the descriptive display name.
|
|
1579
|
+
assert(r.bpmn.includes(`<bpmn:subProcess id="${waitEl}_probeLoop" name="Probe readiness loop: Wait: pr owner/repo#42 · gate">`), "probe loop uses the display name");
|
|
1580
|
+
assert(r.bpmn.includes(`<bpmn:serviceTask id="${waitEl}_task" name="Probe readiness: Wait: pr owner/repo#42 · gate">`), "probe task uses the display name");
|
|
1581
|
+
assert(r.bpmn.includes(`<bpmn:serviceTask id="${waitEl}_lastAttempt" name="Probe readiness at boundary: Wait: pr owner/repo#42 · gate">`), "boundary probe uses the display name");
|
|
1582
|
+
// The old bare `...: <id>` inner names are gone.
|
|
1583
|
+
assert(!r.bpmn.includes('name="Probe readiness: gate"'), "no bare probe id name remains");
|
|
1584
|
+
// The human user task uses the descriptive display name too.
|
|
1585
|
+
const humanEl = `delivery-human-task__${humanTaskSubEl(r.bpmn)}`;
|
|
1586
|
+
assert(r.bpmn.includes(`<bpmn:userTask id="${humanEl}" name="Delivery: human step — Run the manual OTP publish · otp">`), "the human user task uses the display name");
|
|
1587
|
+
assert(!r.bpmn.includes('name="Delivery: human step — otp"'), "no bare human id name remains");
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
test("#778 nodeDisplay redacts a capability probe's verifyCommand (arbitrary shell) in name + documentation", () => {
|
|
1591
|
+
const cap = nodeDisplay({
|
|
1592
|
+
id: "g",
|
|
1593
|
+
kind: "wait",
|
|
1594
|
+
wait: {
|
|
1595
|
+
kind: "capability",
|
|
1596
|
+
target: "github-releases:owner/repo",
|
|
1597
|
+
match: { package: "@scope/pkg", capabilityRef: "owner/repo#7", verifyCommand: "curl -H \"Authorization: Bearer $SECRET\" x" },
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
assert(cap.documentation.includes("verifyCommand=<redacted>"), "verifyCommand is redacted in the doc");
|
|
1601
|
+
assert(!cap.documentation.includes("$SECRET"), "the raw shell command never reaches the doc");
|
|
1602
|
+
// Non-secret match fields stay verbatim.
|
|
1603
|
+
assert(cap.documentation.includes("package=@scope/pkg"), "structured match fields stay verbatim");
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
test("#778 the mermaid diagram reuses the descriptive nodeDisplay name (not the bare `kind: id`)", async () => {
|
|
1607
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
1608
|
+
// One display source: the diagram box carries the same descriptive label the BPMN uses.
|
|
1609
|
+
assert(r.diagram.includes("Wait: pr owner/repo#42 · watch-b"), "wait box uses the descriptive display name");
|
|
1610
|
+
assert(!/"wait: watch-b"/i.test(r.diagram), "no bare `kind: id` box label remains");
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test("#778 a bounded node's escalation task carries the descriptive display name (nodeId kept for correlation)", async () => {
|
|
1614
|
+
const graph = {
|
|
1615
|
+
name: "escalation names",
|
|
1616
|
+
nodes: [{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement the widget" } }],
|
|
1617
|
+
edges: [],
|
|
1618
|
+
};
|
|
1619
|
+
const r = await compileOk(graph);
|
|
1620
|
+
const esc = escBlockForNode(r.bpmn, "impl");
|
|
1621
|
+
assert(esc.includes('name="Escalate: Implement the widget · impl"'), "escalation task uses the descriptive display name");
|
|
1622
|
+
assert(!esc.includes('name="Escalate: impl"'), "no bare-id escalation name remains");
|
|
1623
|
+
// nodeId is still threaded for runtime correlation.
|
|
1624
|
+
assert(esc.includes('target="nodeId"'), "nodeId is retained as an input for correlation");
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
test("#778 XML-1.0-forbidden control characters are stripped from user-authored display strings", () => {
|
|
1628
|
+
const display = nodeDisplay({ id: "n", kind: "agent", agent: { jobType: "j", prompt: "do\u0001 the\u0007 thing" } });
|
|
1629
|
+
// `redactFreeText` (the display path for agent/human prompts) strips XML-1.0-forbidden control chars
|
|
1630
|
+
// BEFORE tokenizing, so the name carries the SANITISED text — never a raw control char (which would
|
|
1631
|
+
// make layoutBpmn/deploy reject the model, and could otherwise reconstruct a split-redaction secret).
|
|
1632
|
+
assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(display.name), "nodeDisplay emits no raw control char");
|
|
1633
|
+
assert(display.name.includes("do the thing"), "the sanitised prompt text is preserved as the label");
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
test("#778 the compiled BPMN never emits an XML-1.0-forbidden control character", async () => {
|
|
1637
|
+
const graph = {
|
|
1638
|
+
name: "control\u0001 chars",
|
|
1639
|
+
nodes: [{ id: "n", kind: "agent", agent: { jobType: "j", prompt: "line one\u0007 with a bell\u0000 and null" } }],
|
|
1640
|
+
edges: [],
|
|
1641
|
+
};
|
|
1642
|
+
const r = await compileOk(graph);
|
|
1643
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the sanitiser removed them.
|
|
1644
|
+
assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(r.bpmn), "no forbidden control char survives into the compiled BPMN");
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
test("#778 nodeDisplay redacts free-form probe match fields (bodyIncludes/stdoutIncludes) that can carry response tokens", () => {
|
|
1648
|
+
const http = nodeDisplay({
|
|
1649
|
+
id: "g",
|
|
1650
|
+
kind: "wait",
|
|
1651
|
+
wait: { kind: "http", target: "https://api.example.com/health", match: { status: 200, bodyIncludes: "session=SUPER_SECRET_TOKEN" } },
|
|
1652
|
+
});
|
|
1653
|
+
assert(http.documentation.includes("bodyIncludes=<redacted>"), "bodyIncludes is redacted");
|
|
1654
|
+
assert(!http.documentation.includes("SUPER_SECRET_TOKEN"), "the raw body substring never reaches the doc");
|
|
1655
|
+
assert(http.documentation.includes("status=200"), "structured match fields stay verbatim");
|
|
1656
|
+
|
|
1657
|
+
const cmd = nodeDisplay({
|
|
1658
|
+
id: "c",
|
|
1659
|
+
kind: "wait",
|
|
1660
|
+
wait: { kind: "command", target: "check.sh", match: { exitCode: 1, stdoutIncludes: "apikey=SUPER_SECRET_STDOUT" } },
|
|
1661
|
+
});
|
|
1662
|
+
assert(cmd.documentation.includes("stdoutIncludes=<redacted>"), "stdoutIncludes is redacted");
|
|
1663
|
+
assert(!cmd.documentation.includes("SUPER_SECRET_STDOUT"), "the raw stdout substring never reaches the doc");
|
|
1664
|
+
assert(cmd.documentation.includes("exitCode=1"), "structured match fields stay verbatim");
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
test("#778 describeProbeMatch DROPS an undeclared `wait.match` key so a text-ingress graph cannot leak a secret through an attacker-named field (thread :1121)", async () => {
|
|
1668
|
+
// `validateDeliveryGraph` does NOT reject an unknown extra `wait.match` key, so a graph submitted as
|
|
1669
|
+
// text can smuggle an attacker-named field whose value the display would otherwise render VERBATIM
|
|
1670
|
+
// (an unknown key bypasses `REDACTED_MATCH_FIELDS`). Only the declared `ProbeMatch` fields are shown.
|
|
1671
|
+
const graph = {
|
|
1672
|
+
name: "g",
|
|
1673
|
+
nodes: [
|
|
1674
|
+
{
|
|
1675
|
+
id: "w",
|
|
1676
|
+
kind: "wait",
|
|
1677
|
+
wait: { kind: "command", target: "check.sh", match: { exitCode: 1, "x-smuggled": "LEAK_SECRET_VALUE" } },
|
|
1678
|
+
},
|
|
1679
|
+
],
|
|
1680
|
+
edges: [],
|
|
1681
|
+
};
|
|
1682
|
+
const r = await compileOk(graph);
|
|
1683
|
+
assert(!r.bpmn.includes("LEAK_SECRET_VALUE"), "an undeclared match key's value never reaches the compiled documentation");
|
|
1684
|
+
assert(!r.bpmn.includes("x-smuggled"), "the undeclared match key itself is dropped from the display");
|
|
1685
|
+
assert(r.bpmn.includes("exitCode=1"), "a declared match field still renders verbatim");
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("#778 redactProbeTargetForDisplay redacts a command-like probe kind smuggled behind an XML-invalid control char (thread :78)", async () => {
|
|
1689
|
+
// `validateDeliveryGraph` only requires a non-empty `wait.kind`, so `kind: "command\x01"` compiles.
|
|
1690
|
+
// The raw kind is not `=== "command"`, so before the fix its arbitrary shell target took the URL-only
|
|
1691
|
+
// `redactConnectorValue` display path and leaked into the staged BPMN documentation. It must be
|
|
1692
|
+
// recognised as a command (the control char sanitises away) and its target shown only as `<redacted>`.
|
|
1693
|
+
const display = nodeDisplay({
|
|
1694
|
+
id: "g",
|
|
1695
|
+
kind: "wait",
|
|
1696
|
+
wait: { kind: "command\u0001", target: "curl -H 'Authorization: Bearer sk-SUPER_SECRET'", poll: { everyMs: 1000 } },
|
|
1697
|
+
});
|
|
1698
|
+
assert(!display.documentation.includes("sk-SUPER_SECRET"), "a command-like kind's shell target never reaches the doc");
|
|
1699
|
+
assert(display.documentation.includes("Target: <redacted>"), "a control-char-smuggled command kind is redacted");
|
|
1700
|
+
|
|
1701
|
+
// An entirely unrecognised (malformed) kind also redacts its target rather than leaking it verbatim.
|
|
1702
|
+
const unknown = nodeDisplay({
|
|
1703
|
+
id: "h",
|
|
1704
|
+
kind: "wait",
|
|
1705
|
+
wait: { kind: "cmd", target: "run --token=SUPER_SECRET_UNKNOWN", poll: { everyMs: 1000 } },
|
|
1706
|
+
});
|
|
1707
|
+
assert(!unknown.documentation.includes("SUPER_SECRET_UNKNOWN"), "an unrecognised kind's target never reaches the doc");
|
|
1708
|
+
assert(unknown.documentation.includes("Target: <redacted>"), "an unrecognised probe kind is redacted");
|
|
1709
|
+
|
|
1710
|
+
// A genuine structured kind still shows its (credential-free) target verbatim — no over-redaction.
|
|
1711
|
+
const pr = nodeDisplay({ id: "p", kind: "wait", wait: { kind: "pr", target: "o/r#42", poll: { everyMs: 1000 } } });
|
|
1712
|
+
assert(pr.documentation.includes("Target: o/r#42"), "a structured probe kind still shows its target");
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test("#778 redactProbeTargetForDisplay redacts a STRUCTURED kind smuggled behind an XML-invalid control char (`pr\\x01`) — the runtime rejects the raw kind, so its arbitrary target must not take the verbatim structured-target path (thread :81)", async () => {
|
|
1716
|
+
// `validateDeliveryGraph` only requires a non-empty `wait.kind` (the probe-kind ENUM is owned by
|
|
1717
|
+
// `readiness.ts`, not re-enforced at the graph boundary), so `kind: "pr\x01"` compiles. At runtime
|
|
1718
|
+
// `parseProbe` keys on `str(raw.kind).trim()` = `"pr\x01"`, which is NOT a known probe kind, so it is
|
|
1719
|
+
// REJECTED and never runs. The display must classify on that SAME trimmed-raw kind: before the fix it
|
|
1720
|
+
// XML-stripped the kind first (`"pr\x01"` → `"pr"`), took the structured `redactConnectorValue` path,
|
|
1721
|
+
// and leaked a non-URL secret-bearing target verbatim into the staged BPMN documentation.
|
|
1722
|
+
const smuggled = nodeDisplay({
|
|
1723
|
+
id: "g",
|
|
1724
|
+
kind: "wait",
|
|
1725
|
+
wait: { kind: "pr\u0001", target: "run --token=SUPER_SECRET_PR_SMUGGLE", poll: { everyMs: 1000 } },
|
|
1726
|
+
});
|
|
1727
|
+
assert(
|
|
1728
|
+
!smuggled.documentation.includes("SUPER_SECRET_PR_SMUGGLE"),
|
|
1729
|
+
"a control-char-smuggled structured kind's target must never reach the doc",
|
|
1730
|
+
);
|
|
1731
|
+
assert(smuggled.documentation.includes("Target: <redacted>"), "a control-char-smuggled structured kind is redacted");
|
|
1732
|
+
|
|
1733
|
+
// A genuine `pr` kind still shows its structured (credential-free) target verbatim — no over-redaction.
|
|
1734
|
+
const pr = nodeDisplay({ id: "p", kind: "wait", wait: { kind: "pr", target: "o/r#42", poll: { everyMs: 1000 } } });
|
|
1735
|
+
assert(pr.documentation.includes("Target: o/r#42"), "a genuine structured probe kind still shows its target");
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
test("#778 describeProbeMatch drops an authored match value equal to its kind's runtime default so an equivalent graph does not fork the digest (thread :1267)", async () => {
|
|
1739
|
+
// `matchPr`/`matchEpic`/`matchGithubCheck`/`matchCommand` default `prState`/`epicState`/`conclusion`/
|
|
1740
|
+
// `exitCode` to `merged`/`merged`/`success`/`0`, so authoring that value is IDENTICAL to omitting it.
|
|
1741
|
+
// Rendering it would fork `semanticBpmn`/the digest from the omitted-equivalent graph → a double
|
|
1742
|
+
// dispatch. The explicit-default and the omitted graph must compile to the SAME `semanticBpmn`.
|
|
1743
|
+
const cases: { kind: string; target: string; field: string; def: string | number; nonDefault: string }[] = [
|
|
1744
|
+
{ kind: "pr", target: "o/r#1", field: "prState", def: "merged", nonDefault: "open" },
|
|
1745
|
+
{ kind: "epic", target: "o/r#1", field: "epicState", def: "merged", nonDefault: "settled" },
|
|
1746
|
+
{ kind: "github-check", target: "o/r@main", field: "conclusion", def: "success", nonDefault: "neutral" },
|
|
1747
|
+
{ kind: "command", target: "check.sh", field: "exitCode", def: 0, nonDefault: "7" },
|
|
1748
|
+
];
|
|
1749
|
+
for (const c of cases) {
|
|
1750
|
+
const withDefault = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, match: { [c.field]: c.def }, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1751
|
+
const omitted = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1752
|
+
const a = await compileOk(withDefault);
|
|
1753
|
+
const b = await compileOk(omitted);
|
|
1754
|
+
assertEquals(a.semanticBpmn, b.semanticBpmn, `${c.kind}: an authored default \`${c.field}\` must render identically to omitting it`);
|
|
1755
|
+
assert(!a.bpmn.includes(`${c.field}=`), `${c.kind}: the defaulted \`${c.field}\` is dropped from the doc`);
|
|
1756
|
+
// A NON-default value still renders (and forks the digest) — the drop is default-only.
|
|
1757
|
+
const nonDef = c.field === "exitCode"
|
|
1758
|
+
? { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, match: { [c.field]: Number(c.nonDefault) }, poll: { everyMs: 1000 } } }], edges: [] }
|
|
1759
|
+
: { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, match: { [c.field]: c.nonDefault }, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1760
|
+
const n = await compileOk(nonDef);
|
|
1761
|
+
assert(n.bpmn.includes(`${c.field}=${c.nonDefault}`), `${c.kind}: a non-default \`${c.field}\` still renders`);
|
|
1762
|
+
assert(n.semanticBpmn !== b.semanticBpmn, `${c.kind}: a non-default \`${c.field}\` genuinely forks the digest`);
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
test("#778 describeProbeMatch drops an authored match string predicate whose runtime-normalized (trimmed) value is EMPTY so an equivalent graph does not fork the digest — `parseMatch` coerces `\"\"`/`\" \"` to `undefined` (omitted), so rendering it would fork `semanticBpmn`/the run key from the omitted-equivalent graph → a double dispatch (thread deliveryGraphCompiler.ts:1254)", async () => {
|
|
1767
|
+
// A blank/whitespace string predicate is runtime-equivalent to OMITTING it: `parseMatch`
|
|
1768
|
+
// (`app/readiness.ts`) normalizes every string field with `str(v).trim() || undefined`. Both a REDACTED
|
|
1769
|
+
// field (`bodyIncludes` → rendered `<redacted>`) and a plain field (`capabilityRef` → rendered raw) must
|
|
1770
|
+
// collapse to the omitted graph.
|
|
1771
|
+
const cases: { kind: string; target: string; field: string; nonEmpty: string }[] = [
|
|
1772
|
+
{ kind: "http", target: "https://api.example.com/health", field: "bodyIncludes", nonEmpty: "ok" },
|
|
1773
|
+
{ kind: "capability", target: "@nanobpm/urban", field: "capabilityRef", nonEmpty: "cap:x" },
|
|
1774
|
+
];
|
|
1775
|
+
for (const c of cases) {
|
|
1776
|
+
const blank = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, match: { [c.field]: " " }, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1777
|
+
const omitted = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1778
|
+
const a = await compileOk(blank);
|
|
1779
|
+
const b = await compileOk(omitted);
|
|
1780
|
+
assertEquals(a.semanticBpmn, b.semanticBpmn, `${c.kind}: a whitespace-only \`${c.field}\` must render identically to omitting it`);
|
|
1781
|
+
assert(!a.bpmn.includes(`${c.field}=`), `${c.kind}: a blank \`${c.field}\` is dropped from the doc`);
|
|
1782
|
+
// A NON-empty value still renders (and forks the digest) — the drop is empty-only.
|
|
1783
|
+
const nonEmpty = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: c.kind, target: c.target, match: { [c.field]: c.nonEmpty }, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1784
|
+
const n = await compileOk(nonEmpty);
|
|
1785
|
+
assert(n.semanticBpmn !== b.semanticBpmn, `${c.kind}: a non-empty \`${c.field}\` genuinely forks the digest`);
|
|
1786
|
+
}
|
|
1787
|
+
// Direct unit-level assertion on the display, independent of the compile pipeline: a blank redacted
|
|
1788
|
+
// predicate must NOT surface a `<redacted>` marker (it is omitted, not present-but-hidden).
|
|
1789
|
+
const disp = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "http", target: "https://api.example.com/health", match: { status: 200, bodyIncludes: " " } } });
|
|
1790
|
+
assert(!disp.documentation.includes("bodyIncludes"), "nodeDisplay drops a whitespace-only redacted predicate rather than rendering `bodyIncludes=<redacted>`");
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test("#778 nodeDisplay drops an explicit `onTimeout: escalate` (the effective default) so it renders identically to omitting it and does not fork the digest (thread :1398)", async () => {
|
|
1794
|
+
// `parseProbe` defaults an omitted `onTimeout` to `escalate` and `waitBodyLines` only changes topology
|
|
1795
|
+
// for `continue`, so an EXPLICIT `escalate` is behaviourally identical to omitting it. Rendering the
|
|
1796
|
+
// line only for the explicit form would fork `semanticBpmn`/the digest from the omitted-equivalent
|
|
1797
|
+
// graph → a double dispatch. Explicit-default and omitted must compile to the SAME `semanticBpmn`.
|
|
1798
|
+
const explicit = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" }, onTimeout: "escalate", poll: { everyMs: 1000 } } }], edges: [] };
|
|
1799
|
+
const omitted = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" }, poll: { everyMs: 1000 } } }], edges: [] };
|
|
1800
|
+
const a = await compileOk(explicit);
|
|
1801
|
+
const b = await compileOk(omitted);
|
|
1802
|
+
assertEquals(a.semanticBpmn, b.semanticBpmn, "an explicit `onTimeout: escalate` must render identically to omitting it");
|
|
1803
|
+
assert(!a.bpmn.includes("On timeout:"), "the defaulted `escalate` is dropped from the doc");
|
|
1804
|
+
// A NON-default `continue` still renders (and forks the digest) — the drop is default-only.
|
|
1805
|
+
const cont = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" }, onTimeout: "continue", poll: { everyMs: 1000 } } }], edges: [] };
|
|
1806
|
+
const n = await compileOk(cont);
|
|
1807
|
+
assert(n.bpmn.includes("On timeout: continue"), "a non-default `continue` still renders");
|
|
1808
|
+
assert(n.semanticBpmn !== b.semanticBpmn, "a non-default `continue` genuinely forks the digest");
|
|
1809
|
+
// Directly assert the display too (unit-level, independent of the compile pipeline).
|
|
1810
|
+
assert(
|
|
1811
|
+
!nodeDisplay({ id: "w", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" }, onTimeout: "escalate" } }).documentation.includes("On timeout:"),
|
|
1812
|
+
"nodeDisplay drops an explicit-default onTimeout",
|
|
1813
|
+
);
|
|
1814
|
+
assert(
|
|
1815
|
+
nodeDisplay({ id: "w", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" }, onTimeout: "continue" } }).documentation.includes("On timeout: continue"),
|
|
1816
|
+
"nodeDisplay keeps a non-default onTimeout",
|
|
1817
|
+
);
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
test("#778 digestInvisibleRawValues fingerprints an XML-invalid `agent`/`connector` timeout so two runtime-different graphs do not collide (thread :1251)", () => {
|
|
1822
|
+
// The node `timeout` is displayed as `trimmedOrEmpty(timeout)` then XML-sanitised at serialisation, but
|
|
1823
|
+
// the RAW value drives the runtime SLA — so `PT1H` and `PT1H\x01` share a digest while the runtime SLA
|
|
1824
|
+
// differs (an invalid duration silently falls back to the run default). The raw must be fingerprinted.
|
|
1825
|
+
const clean = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "p", timeout: "PT1H" } }], edges: [] };
|
|
1826
|
+
const dirty = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "p", timeout: "PT1H\x01" } }], edges: [] };
|
|
1827
|
+
const invisible = digestInvisibleRawValues(dirty);
|
|
1828
|
+
assert(invisible.some((e) => e.includes("agent.timeout")), "an XML-invalid agent timeout is flagged digest-invisible");
|
|
1829
|
+
// The clean timeout is fully digest-visible → not flagged.
|
|
1830
|
+
assert(!digestInvisibleRawValues(clean).some((e) => e.includes("agent.timeout")), "a clean timeout is not spuriously flagged");
|
|
1831
|
+
// Same for a connector timeout.
|
|
1832
|
+
const dirtyConn = { name: "g", nodes: [{ id: "c", kind: "connector", connector: { target: "converge-merge", timeout: "PT2H\x01" } }], edges: [] };
|
|
1833
|
+
assert(digestInvisibleRawValues(dirtyConn).some((e) => e.includes("connector.timeout")), "an XML-invalid connector timeout is flagged digest-invisible");
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
test("#778 digestInvisibleRawValues collapses a whitespace-only timeout variant (`PT1H` vs `PT1H `) so it does not spuriously fork the run-key (thread :1307)", () => {
|
|
1837
|
+
// `isoDuration` TRIMS the timeout before it becomes the runtime `nodeTimeout`, and the display doc shows
|
|
1838
|
+
// `trimmedOrEmpty(timeout)` — so `PT1H` and `PT1H ` have the SAME semantic BPMN/digest AND the same SLA.
|
|
1839
|
+
// Fingerprinting the RAW string forked them (one pushed `"PT1H "`, the other nothing), so a
|
|
1840
|
+
// whitespace-only re-stage got a different run key and launched a second run. Fingerprinting the
|
|
1841
|
+
// NORMALISED value collapses them (issue #778 review).
|
|
1842
|
+
const canon = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", timeout: "PT1H" } }], edges: [] };
|
|
1843
|
+
const trailingWs = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", timeout: "PT1H " } }], edges: [] };
|
|
1844
|
+
assertEquals(
|
|
1845
|
+
JSON.stringify(digestInvisibleRawValues(canon)),
|
|
1846
|
+
JSON.stringify(digestInvisibleRawValues(trailingWs)),
|
|
1847
|
+
"a trailing-whitespace timeout produces the identical fingerprint set (no spurious fork)",
|
|
1848
|
+
);
|
|
1849
|
+
assert(!digestInvisibleRawValues(canon).some((e) => e.includes("agent.timeout")), "a valid canonical timeout is not fingerprinted at all");
|
|
1850
|
+
// A genuinely-invalid duration DOES still fork from a valid one — it falls back to a different SLA.
|
|
1851
|
+
const invalid = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", timeout: "PT1H\x01" } }], edges: [] };
|
|
1852
|
+
assert(
|
|
1853
|
+
JSON.stringify(digestInvisibleRawValues(invalid)) !== JSON.stringify(digestInvisibleRawValues(canon)),
|
|
1854
|
+
"an invalid timeout (distinct runtime SLA) is still disambiguated from a valid one",
|
|
1855
|
+
);
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
test("#778 digestInvisibleRawValues collapses a CASE-only timeout variant (`pt1h` vs `PT1H`) — the runtime ISO-normalises BOTH, so it must not spuriously fork the run-key (thread :1331)", () => {
|
|
1859
|
+
// `isoDuration` upper-cases a valid timeout before it becomes the runtime `nodeTimeout`, so `pt1h` and
|
|
1860
|
+
// `PT1H` drive the IDENTICAL runtime SLA and identical semantic BPMN. The fix normalises BOTH the raw
|
|
1861
|
+
// fingerprint AND the digest-visible display value; without normalising the display side, `pt1h` (digest
|
|
1862
|
+
// sees `pt1h`, runtime canonicalises to `PT1H`) was falsely flagged digest-invisible, forcing an
|
|
1863
|
+
// idempotency key and double-launching a graph the runtime treats identically.
|
|
1864
|
+
const lower = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", timeout: "pt1h" } }], edges: [] };
|
|
1865
|
+
const upper = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", timeout: "PT1H" } }], edges: [] };
|
|
1866
|
+
assert(!digestInvisibleRawValues(lower).some((e) => e.includes("agent.timeout")), "a case-only timeout variant is not falsely flagged digest-invisible");
|
|
1867
|
+
assertEquals(
|
|
1868
|
+
JSON.stringify(digestInvisibleRawValues(lower)),
|
|
1869
|
+
JSON.stringify(digestInvisibleRawValues(upper)),
|
|
1870
|
+
"a case-only timeout produces the identical fingerprint set (no spurious fork)",
|
|
1871
|
+
);
|
|
1872
|
+
// The same for a connector timeout.
|
|
1873
|
+
const lowerConn = { name: "g", nodes: [{ id: "c", kind: "connector", connector: { target: "converge-merge", timeout: "pt2h" } }], edges: [] };
|
|
1874
|
+
const upperConn = { name: "g", nodes: [{ id: "c", kind: "connector", connector: { target: "converge-merge", timeout: "PT2H" } }], edges: [] };
|
|
1875
|
+
assertEquals(
|
|
1876
|
+
JSON.stringify(digestInvisibleRawValues(lowerConn)),
|
|
1877
|
+
JSON.stringify(digestInvisibleRawValues(upperConn)),
|
|
1878
|
+
"a case-only connector timeout collapses too",
|
|
1879
|
+
);
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
test("#778 compileDeliveryGraph REJECTS a URL-shaped agent `jobType` at the validation boundary — a credential embedded in the executable job type never reaches the compiled BPMN, and the compile error is redacted (#778 review)", async () => {
|
|
1883
|
+
const errors = await compileFail({
|
|
1884
|
+
name: "g",
|
|
1885
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "//user:pass@evil.example/route", prompt: "p" } }],
|
|
1886
|
+
edges: [],
|
|
1887
|
+
});
|
|
1888
|
+
const joined = JSON.stringify(errors);
|
|
1889
|
+
assert(joined.includes("URL-shaped"), `rejected as URL-shaped: ${joined}`);
|
|
1890
|
+
assert(!joined.includes("user:pass"), `no compile error echoes the embedded credential: ${joined}`);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
test("#778 nodeDisplay + digestInvisibleRawValues redact a credential-bearing agent `jobType` (thread :1202)", () => {
|
|
1894
|
+
// `jobType` is emitted verbatim into the agent node's display label and (as of #778) the compiled BPMN,
|
|
1895
|
+
// so a URL-shaped jobType carrying a credential must be redacted the SAME way a connector target is —
|
|
1896
|
+
// and the redacted-away raw fingerprinted so a secret-differing jobType is not digest-collapsed.
|
|
1897
|
+
const secret = "//user:s3cr3t@host/?token=abc";
|
|
1898
|
+
const d = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: secret } });
|
|
1899
|
+
const rendered = `${d.name}\n${d.documentation}`;
|
|
1900
|
+
assert(!rendered.includes("s3cr3t") && !rendered.includes("token=abc"), `the credential is redacted from the display: ${rendered}`);
|
|
1901
|
+
assert(rendered.includes("***"), `the display carries the redaction marker: ${rendered}`);
|
|
1902
|
+
const graph = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: secret } }], edges: [] };
|
|
1903
|
+
assert(digestInvisibleRawValues(graph).some((e) => e.includes("agent.jobType")), "the redacted-away jobType is fingerprinted digest-invisible");
|
|
1904
|
+
// An ordinary `senior:feature` jobType (not URL-shaped) is untouched and not fingerprinted.
|
|
1905
|
+
const ordinary = { name: "g", nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [] };
|
|
1906
|
+
assert(nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }).documentation.includes("senior:feature"), "a normal jobType renders verbatim");
|
|
1907
|
+
assert(!digestInvisibleRawValues(ordinary).some((e) => e.includes("agent.jobType")), "a normal jobType is not spuriously fingerprinted");
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test("#778 digestInvisibleRawValues fingerprints an XML-invalid `wait.credentialEnv` so a malformed env key does not collide with a valid one (suppressed advisory :1355)", () => {
|
|
1911
|
+
const clean = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://x", credentialEnv: "GITHUB_TOKEN" } }], edges: [] };
|
|
1912
|
+
const dirty = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://x", credentialEnv: "GITHUB_TOKEN\x01" } }], edges: [] };
|
|
1913
|
+
assert(!digestInvisibleRawValues(clean).some((e) => e.includes("wait.credentialEnv")), "a clean credentialEnv is not fingerprinted");
|
|
1914
|
+
assert(digestInvisibleRawValues(dirty).some((e) => e.includes("wait.credentialEnv")), "an XML-invalid credentialEnv is fingerprinted digest-invisible");
|
|
1915
|
+
// The two graphs (identical display/digest) therefore produce DIFFERENT invisible-value sets.
|
|
1916
|
+
assert(
|
|
1917
|
+
JSON.stringify(digestInvisibleRawValues(clean)) !== JSON.stringify(digestInvisibleRawValues(dirty)),
|
|
1918
|
+
"the malformed credentialEnv graph is disambiguated from the valid one",
|
|
1919
|
+
);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
test("#778 digestInvisibleRawValues fingerprints an XML-invalid `wait.kind`/`onTimeout`/`poll.backoff` so a malformed probe does not collide with a valid one (thread :1366)", () => {
|
|
1923
|
+
const clean = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://x", onTimeout: "escalate", poll: { backoff: "linear" } } }], edges: [] };
|
|
1924
|
+
for (const [field, dirtyWait] of [
|
|
1925
|
+
["wait.kind", { kind: "http\x01", target: "https://x" }],
|
|
1926
|
+
["wait.onTimeout", { kind: "http", target: "https://x", onTimeout: "escalate\x01" }],
|
|
1927
|
+
["wait.poll.backoff", { kind: "http", target: "https://x", poll: { backoff: "linear\x01" } }],
|
|
1928
|
+
] as const) {
|
|
1929
|
+
const dirty = { name: "g", nodes: [{ id: "w", kind: "wait", wait: dirtyWait }], edges: [] };
|
|
1930
|
+
assert(digestInvisibleRawValues(dirty).some((e) => e.includes(field)), `an XML-invalid ${field} is fingerprinted digest-invisible`);
|
|
1931
|
+
}
|
|
1932
|
+
// A clean probe's enum fields are never fingerprinted (display == raw), so a valid graph is not forked.
|
|
1933
|
+
assert(!digestInvisibleRawValues(clean).some((e) => e.includes("wait.kind") || e.includes("wait.onTimeout") || e.includes("wait.poll.backoff")), "clean probe enum fields are not spuriously fingerprinted");
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
test("#778 describeProbeMatch + digestInvisibleRawValues redact a credential-bearing `capabilityRef` match field (thread :1169)", () => {
|
|
1937
|
+
// `capabilityRef` (and `package`/`checkName`) are free-form string predicates `parseProbe` accepts for a
|
|
1938
|
+
// trailing id, so a URL-shaped value can smuggle a credential into the operator-visible display. It must
|
|
1939
|
+
// be redacted the SAME URL-only way a connector target is, and the redacted-away raw fingerprinted so a
|
|
1940
|
+
// secret-differing probe is not digest-collapsed.
|
|
1941
|
+
const secret = "//user:s3cr3t@host#274";
|
|
1942
|
+
const d = nodeDisplay({ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { capabilityRef: secret } } });
|
|
1943
|
+
const rendered = `${d.name}\n${d.documentation}`;
|
|
1944
|
+
assert(!rendered.includes("s3cr3t"), `the credential is redacted from the match display: ${rendered}`);
|
|
1945
|
+
assert(rendered.includes("***"), `the display carries the redaction marker: ${rendered}`);
|
|
1946
|
+
const graph = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { capabilityRef: secret } } }], edges: [] };
|
|
1947
|
+
assert(digestInvisibleRawValues(graph).some((e) => e.includes("wait.match.capabilityRef")), "the redacted-away capabilityRef is fingerprinted digest-invisible");
|
|
1948
|
+
// An ordinary (non-URL) capabilityRef is untouched and not fingerprinted.
|
|
1949
|
+
const ordinary = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { capabilityRef: "owner/repo#7" } } }], edges: [] };
|
|
1950
|
+
assert(nodeDisplay({ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { capabilityRef: "owner/repo#7" } } }).documentation.includes("owner/repo#7"), "an ordinary capabilityRef renders verbatim");
|
|
1951
|
+
assert(!digestInvisibleRawValues(ordinary).some((e) => e.includes("wait.match.capabilityRef")), "an ordinary capabilityRef is not spuriously fingerprinted");
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
test("#778 digestInvisibleRawValues collapses a whitespace-only redacted-match variant (`verifyCommand`) — parseMatch trims it (suppressed advisory :1363)", () => {
|
|
1955
|
+
const canon = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { verifyCommand: "curl x" } } }], edges: [] };
|
|
1956
|
+
const trailingWs = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { verifyCommand: "curl x " } } }], edges: [] };
|
|
1957
|
+
// The redacted field is ALWAYS fingerprinted (display is `<redacted>`), but the trimmed value is used,
|
|
1958
|
+
// so a trailing-whitespace variant produces the IDENTICAL fingerprint set (no spurious run-key fork).
|
|
1959
|
+
assert(digestInvisibleRawValues(canon).some((e) => e.includes("wait.match.verifyCommand")), "a redacted match field is fingerprinted");
|
|
1960
|
+
assertEquals(
|
|
1961
|
+
JSON.stringify(digestInvisibleRawValues(canon)),
|
|
1962
|
+
JSON.stringify(digestInvisibleRawValues(trailingWs)),
|
|
1963
|
+
"a whitespace-only difference in a trimmed redacted match field does not fork the fingerprint",
|
|
1964
|
+
);
|
|
1965
|
+
// An internal invalid char still distinguishes (runtime carries it).
|
|
1966
|
+
const invalid = { name: "g", nodes: [{ id: "w", kind: "wait", wait: { kind: "capability", target: "github-releases:o/r", match: { verifyCommand: "curl\x01x" } } }], edges: [] };
|
|
1967
|
+
assert(
|
|
1968
|
+
JSON.stringify(digestInvisibleRawValues(invalid)) !== JSON.stringify(digestInvisibleRawValues(canon)),
|
|
1969
|
+
"an internal invalid char in a redacted match field is still disambiguated",
|
|
1970
|
+
);
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
test("#778 describeProbeMatch renders match fields in a stable order regardless of JSON insertion order (byte-determinism)", () => {
|
|
1974
|
+
const a = nodeDisplay({ id: "g", kind: "wait", wait: { kind: "http", target: "https://x", match: { status: 200, version: "1.2.3" } } });
|
|
1975
|
+
const b = nodeDisplay({ id: "g", kind: "wait", wait: { kind: "http", target: "https://x", match: { version: "1.2.3", status: 200 } } });
|
|
1976
|
+
assertEquals(a.documentation, b.documentation);
|
|
1977
|
+
// The stable order is code-unit ascending on the key ("status" < "version").
|
|
1978
|
+
const match = a.documentation.split("\n").find((l) => l.startsWith("Match: "));
|
|
1979
|
+
assertEquals(match, "Match: status=200, version=1.2.3");
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
test("#778 nodeDisplay redacts a credential-bearing connector target + dedupeKey (forward-declared / free-form values)", () => {
|
|
1983
|
+
const c = nodeDisplay({
|
|
1984
|
+
id: "call",
|
|
1985
|
+
kind: "connector",
|
|
1986
|
+
connector: { target: "https://user:p4ss@hooks.example.com/deploy?token=abc123", dedupeKey: "https://k:s3cr3t@idem.example.com/key?sig=zzz" },
|
|
1987
|
+
});
|
|
1988
|
+
assert(!c.name.includes("p4ss") && !c.name.includes("abc123"), "connector name drops userinfo + query secret");
|
|
1989
|
+
assert(!c.documentation.includes("p4ss") && !c.documentation.includes("abc123"), "connector doc target drops userinfo + query secret");
|
|
1990
|
+
assert(!c.documentation.includes("s3cr3t") && !c.documentation.includes("zzz"), "connector doc dedupeKey drops userinfo + query secret");
|
|
1991
|
+
assert(c.documentation.includes("Connector target: https://***@hooks.example.com/deploy?***"), "connector target rendered redacted");
|
|
1992
|
+
assert(c.documentation.includes("Dedupe key: https://***@idem.example.com/key?***"), "connector dedupeKey rendered redacted");
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
test("#778 nodeDisplay preserves an OPAQUE (non-URL) connector target/dedupeKey/payload.pr verbatim — `#`/`?` are not URL redaction points", () => {
|
|
1996
|
+
// A forward-declared connector identifier such as `slack:#releases` is NOT a `scheme://authority`
|
|
1997
|
+
// URL, so blind redactString would mangle its meaningful `#`/`?` (e.g. `slack:#releases` →
|
|
1998
|
+
// `slack:#***`). Only real URLs are redacted; opaque identifiers and `owner/repo#42`/`<node>.pr`
|
|
1999
|
+
// PR references survive verbatim.
|
|
2000
|
+
const c = nodeDisplay({
|
|
2001
|
+
id: "call",
|
|
2002
|
+
kind: "connector",
|
|
2003
|
+
connector: { target: "slack:#releases?thread=42", dedupeKey: "idem#deploy?v=1", payload: { pr: "owner/repo#42" } },
|
|
2004
|
+
});
|
|
2005
|
+
assert(c.name.includes("Connector: slack:#releases?thread=42"), "opaque target name is verbatim (not mangled)");
|
|
2006
|
+
assert(c.documentation.includes("Connector target: slack:#releases?thread=42"), "opaque target doc is verbatim");
|
|
2007
|
+
assert(c.documentation.includes("Dedupe key: idem#deploy?v=1"), "opaque dedupeKey is verbatim");
|
|
2008
|
+
assert(c.documentation.includes("PR: owner/repo#42"), "an `owner/repo#42` PR reference is preserved verbatim");
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
test("#778 nodeDisplay redacts a credential-bearing connector payload.pr (canonical URL) but preserves a clean PR URL/`<node>.pr` ref", () => {
|
|
2012
|
+
const cred = nodeDisplay({
|
|
2013
|
+
id: "land",
|
|
2014
|
+
kind: "connector",
|
|
2015
|
+
connector: { target: "converge-merge", payload: { pr: "https://user:tok3n@github.com/o/r/pull/42?access=sekret" } },
|
|
2016
|
+
});
|
|
2017
|
+
assert(!cred.documentation.includes("tok3n") && !cred.documentation.includes("sekret"), "credential-bearing PR URL has userinfo + query stripped");
|
|
2018
|
+
const ref = nodeDisplay({
|
|
2019
|
+
id: "land2",
|
|
2020
|
+
kind: "connector",
|
|
2021
|
+
connector: { target: "converge-merge", payload: { pr: "impl.pr" } },
|
|
2022
|
+
});
|
|
2023
|
+
assert(ref.documentation.includes("PR: impl.pr"), "a `<node>.pr` fact reference is preserved verbatim");
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
test("#778 nodeDisplay redacts a PROTOCOL-RELATIVE (`//user:pass@…`) connector target/dedupeKey — a scheme is optional (#778 review)", () => {
|
|
2027
|
+
// `redactString` redacts a scheme-relative `//user:pass@host?token=…` form, but the URL detector
|
|
2028
|
+
// that gates it once required a `scheme://` prefix, so a protocol-relative target slipped through
|
|
2029
|
+
// VERBATIM into the compiled name/documentation, leaking its credential. The detector now accepts an
|
|
2030
|
+
// OPTIONAL scheme before the `//authority`.
|
|
2031
|
+
const c = nodeDisplay({
|
|
2032
|
+
id: "call",
|
|
2033
|
+
kind: "connector",
|
|
2034
|
+
connector: { target: "//user:p4ss@hooks.example.com/deploy?token=abc123", dedupeKey: "//s3cr3t@idem.example.com/key?sig=zzz" },
|
|
2035
|
+
});
|
|
2036
|
+
assert(!c.name.includes("p4ss") && !c.name.includes("abc123"), "protocol-relative connector name drops userinfo + query secret");
|
|
2037
|
+
assert(!c.documentation.includes("p4ss") && !c.documentation.includes("abc123"), "protocol-relative connector doc target drops secret");
|
|
2038
|
+
assert(!c.documentation.includes("s3cr3t") && !c.documentation.includes("zzz"), "protocol-relative dedupeKey drops secret");
|
|
2039
|
+
assert(c.documentation.includes("Connector target: //***@hooks.example.com/deploy?***"), "protocol-relative target rendered redacted");
|
|
2040
|
+
assert(c.documentation.includes("Dedupe key: //***@idem.example.com/key?***"), "protocol-relative dedupeKey rendered redacted");
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
test("#778 the connector timeout-escalation FEEL descriptor redacts a credential-bearing target (#778 review)", async () => {
|
|
2044
|
+
// The connector node's escalation-context descriptor (`connector → <target>`) is baked as a
|
|
2045
|
+
// compile-time FEEL string literal into the timeout escalation task. A raw credential-bearing target
|
|
2046
|
+
// there would persist the secret in the generated BPMN even though nodeDisplay redacts it — the
|
|
2047
|
+
// descriptor must use the same URL-only redaction.
|
|
2048
|
+
const graph = {
|
|
2049
|
+
name: "connector escalation redaction",
|
|
2050
|
+
nodes: [{ id: "call", kind: "connector", connector: { target: "https://user:p4ss@hooks.example.com/deploy?token=abc123" } }],
|
|
2051
|
+
edges: [],
|
|
2052
|
+
};
|
|
2053
|
+
const r = await compileOk(graph);
|
|
2054
|
+
assert(!r.bpmn.includes("p4ss") && !r.bpmn.includes("abc123"), "no connector credential leaks into the compiled BPMN escalation context");
|
|
2055
|
+
assert(r.bpmn.includes("connector → https://***@hooks.example.com/deploy?***"), "the escalation descriptor renders the redacted target");
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
test("#778 the compiled connector inner task carries the descriptive display name (distinct connector arg path)", async () => {
|
|
2059
|
+
const graph = {
|
|
2060
|
+
name: "connector inner name",
|
|
2061
|
+
nodes: [{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "o/r#42" }, dedupeKey: "land-1" } }],
|
|
2062
|
+
edges: [],
|
|
2063
|
+
};
|
|
2064
|
+
const r = await compileOk(graph);
|
|
2065
|
+
const el = elementForNode(r.bpmn, "land");
|
|
2066
|
+
assert(r.bpmn.includes(`<bpmn:serviceTask id="${el}_task" name="Converge & merge PR · land">`), "the connector inner task shares the descriptive display name");
|
|
2067
|
+
assert(!r.bpmn.includes('name="connector: land"'), "no bare kind:id connector task name remains");
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
test("#778 firstLine truncates by code point so an astral character never leaves an unpaired surrogate in the BPMN", async () => {
|
|
2071
|
+
// 70 ASCII + an emoji straddling the cap-1 (71) boundary + trailing text: a code-unit slice(0,71)
|
|
2072
|
+
// would cut the surrogate pair, leaving a lone high surrogate that stripXmlInvalidChars does NOT remove.
|
|
2073
|
+
const prompt = `${"a".repeat(70)}😀 trailing detail that pushes past the seventy-two character cap`;
|
|
2074
|
+
const graph = { name: "surrogate", nodes: [{ id: "n", kind: "agent", agent: { jobType: "j", prompt } }], edges: [] };
|
|
2075
|
+
const r = await compileOk(graph);
|
|
2076
|
+
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
2077
|
+
assert(!loneSurrogate.test(r.bpmn), "no unpaired surrogate survives into the compiled BPMN");
|
|
2078
|
+
// Positive check: `stripXmlInvalidChars` would silently drop a lone surrogate left by a buggy UTF-16
|
|
2079
|
+
// `slice`, so the no-lone-surrogate assertion alone can't catch a code-UNIT truncation. Assert the
|
|
2080
|
+
// label truncated by CODE POINT — the 70 ASCII run, the intact emoji pair, then the ellipsis.
|
|
2081
|
+
assert(r.bpmn.includes(`${"a".repeat(70)}😀…`), "truncation keeps whole code points: 70 ASCII + the intact emoji + ellipsis");
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
test("#778 the mermaid diagram strips XML-1.0-forbidden control characters from node labels", async () => {
|
|
2085
|
+
const graph = {
|
|
2086
|
+
name: "mermaid\u0001 controls",
|
|
2087
|
+
nodes: [{ id: "n", kind: "agent", agent: { jobType: "j", prompt: "preview\u0001 label\u0007 with controls" } }],
|
|
2088
|
+
edges: [],
|
|
2089
|
+
};
|
|
2090
|
+
const r = await compileOk(graph);
|
|
2091
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the mermaid sanitiser removed them.
|
|
2092
|
+
assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(r.diagram), "no forbidden control char survives into the mermaid diagram");
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
test("#778 the mermaid diagram encodes markup chars and folds bare CR line breaks in free-form labels", async () => {
|
|
2096
|
+
const graph = {
|
|
2097
|
+
name: "mermaid markup",
|
|
2098
|
+
// `<`/`>`/`&` would render as markup (label vanishes/mangles); a bare `\r` (valid, so kept by
|
|
2099
|
+
// stripXmlInvalidChars) injects a raw break into the line-oriented Mermaid source.
|
|
2100
|
+
nodes: [{ id: "n", kind: "agent", agent: { jobType: "j", prompt: "a<b> & first\rsecond" } }],
|
|
2101
|
+
edges: [],
|
|
2102
|
+
};
|
|
2103
|
+
const r = await compileOk(graph);
|
|
2104
|
+
const label = r.diagram.split("\n").find((l) => /^\s+\S+\["/.test(l) && l.includes("first"));
|
|
2105
|
+
assert(label !== undefined, "the node label line is present");
|
|
2106
|
+
assert(!/[<>]/.test(label ?? ""), "no raw < or > survives into the mermaid label");
|
|
2107
|
+
assert((label ?? "").includes("#lt;b#gt;") && (label ?? "").includes("#amp;"), "markup chars are Mermaid-entity encoded");
|
|
2108
|
+
assert(!/\r/.test(label ?? ""), "the bare CR is folded to a space, not a raw break");
|
|
2109
|
+
assert((label ?? "").includes("first second"), "the CR-separated words are joined by a space");
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
test("#778 stripXmlInvalidChars-guarded output drops U+FFFE/U+FFFF noncharacters and directly-injected unpaired surrogates while keeping valid astral pairs", async () => {
|
|
2113
|
+
// A prompt carrying a noncharacter, a lone high surrogate, a lone low surrogate, AND a valid emoji
|
|
2114
|
+
// pair: the renderer must drop the first three (XML-1.0 `Char` forbids them) but keep the pair.
|
|
2115
|
+
const prompt = "keep\uFFFE me\uFFFF here \uD83D lone-high \uDE00 lone-low but this pair 😀 stays";
|
|
2116
|
+
const graph = { name: "noncharacters", nodes: [{ id: "n", kind: "agent", agent: { jobType: "j", prompt } }], edges: [] };
|
|
2117
|
+
const r = await compileOk(graph);
|
|
2118
|
+
assert(!/[\uFFFE\uFFFF]/.test(r.bpmn), "no U+FFFE/U+FFFF noncharacter survives into the compiled BPMN");
|
|
2119
|
+
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
2120
|
+
assert(!loneSurrogate.test(r.bpmn), "no unpaired surrogate survives into the compiled BPMN");
|
|
2121
|
+
assert(r.bpmn.includes("😀"), "a valid astral pair is preserved");
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
test("#778 graphCarriesRedactedSecrets: false for a graph whose display fields are fully digest-represented", () => {
|
|
2125
|
+
const plain = {
|
|
2126
|
+
name: "plain",
|
|
2127
|
+
nodes: [
|
|
2128
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement nanobpm/nano-ide#42" } },
|
|
2129
|
+
{ id: "h", kind: "human", human: { prompt: "click done" } },
|
|
2130
|
+
{ id: "w", kind: "wait", wait: { kind: "epic", target: "nanobpm/nano-ide#488" } },
|
|
2131
|
+
{ id: "c", kind: "connector", connector: { target: "slack:#releases", payload: { pr: "o/r#1" } } },
|
|
2132
|
+
],
|
|
2133
|
+
edges: [],
|
|
2134
|
+
};
|
|
2135
|
+
assertEquals(graphCarriesRedactedSecrets(plain), false);
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
test("#778 graphCarriesRedactedSecrets: true when a node carries redacted-away secret material", () => {
|
|
2139
|
+
const urlInPrompt = { name: "n", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "push to https://u:p@host/x" } }], edges: [] };
|
|
2140
|
+
assertEquals(graphCarriesRedactedSecrets(urlInPrompt), true);
|
|
2141
|
+
|
|
2142
|
+
const commandWait = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "command", target: "run-the-secret.sh" } }], edges: [] };
|
|
2143
|
+
assertEquals(graphCarriesRedactedSecrets(commandWait), true);
|
|
2144
|
+
|
|
2145
|
+
const matchSecret = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "command", target: "x", match: { stdoutIncludes: "TOKEN=abc" } } }], edges: [] };
|
|
2146
|
+
assertEquals(graphCarriesRedactedSecrets(matchSecret), true);
|
|
2147
|
+
|
|
2148
|
+
const payloadKey = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: "o/r#1", secretHeader: "Bearer z" } } }], edges: [] };
|
|
2149
|
+
assertEquals(graphCarriesRedactedSecrets(payloadKey), true);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
test("#778 graphCarriesRedactedSecrets: true when a prompt differs from its display form only by trimmed whitespace", () => {
|
|
2153
|
+
// The display embeds `redactFreeText(trimmedOrEmpty(prompt))`, but the RAW untrimmed prompt reaches
|
|
2154
|
+
// the runtime — so `" run this"` and `"run this"` share one digest yet dispatch different prompts.
|
|
2155
|
+
const leadingWs = { name: "n", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: " run this" } }], edges: [] };
|
|
2156
|
+
assertEquals(graphCarriesRedactedSecrets(leadingWs), true);
|
|
2157
|
+
const humanWs = { name: "n", nodes: [{ id: "h", kind: "human", human: { prompt: "click done " } }], edges: [] };
|
|
2158
|
+
assertEquals(graphCarriesRedactedSecrets(humanWs), true);
|
|
2159
|
+
// The trimmed twin is faithfully represented → not lossy.
|
|
2160
|
+
const trimmed = { name: "n", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "run this" } }], edges: [] };
|
|
2161
|
+
assertEquals(graphCarriesRedactedSecrets(trimmed), false);
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
test("#778 graphCarriesRedactedSecrets: a wait.match value of the WRONG type forks the digest — numeric status:200 vs string status:\"200\" render identically yet parse differently (#778 review — thread :1495)", () => {
|
|
2165
|
+
// `parseMatch` reads `status` via `num()`, so a string "200" coerces to undefined (any-2xx) while the
|
|
2166
|
+
// number 200 means exactly-200 — same `status=200` display, different runtime. The string variant is a
|
|
2167
|
+
// digest-invisible fork and must be fingerprinted; the correctly-typed numeric form is a faithful
|
|
2168
|
+
// identity and must NOT be (keyless dispatch of a normal graph stays unaffected).
|
|
2169
|
+
const numeric = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://h/health", match: { status: 200 } } }], edges: [] };
|
|
2170
|
+
const stringy = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://h/health", match: { status: "200" } } }], edges: [] };
|
|
2171
|
+
assertEquals(graphCarriesRedactedSecrets(numeric), false);
|
|
2172
|
+
assertEquals(graphCarriesRedactedSecrets(stringy), true);
|
|
2173
|
+
// The raw type is the disambiguator, so the two never share a stable run-key fingerprint.
|
|
2174
|
+
assertEquals(digestInvisibleRawValues(numeric).length, 0);
|
|
2175
|
+
assertEquals(digestInvisibleRawValues(stringy).length, 1);
|
|
2176
|
+
// Same class for a numeric-typed string on `exitCode` (also `num()`-read).
|
|
2177
|
+
const exitStr = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "command", target: "x", match: { exitCode: "0" } } }], edges: [] };
|
|
2178
|
+
assertEquals(graphCarriesRedactedSecrets(exitStr), true);
|
|
2179
|
+
// ...but a NON-string on a string-typed field (`conclusion`) does NOT fork: `parseMatch` coerces it
|
|
2180
|
+
// through `str(v).trim()`, so `conclusion: 1` and `conclusion: "1"` are ONE runtime probe (and render
|
|
2181
|
+
// identically) — flagging it forked the stable run key of equivalent graphs (#778 review — thread
|
|
2182
|
+
// deliveryGraphCompiler.ts:1251).
|
|
2183
|
+
const concNum = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "acme/repo#1", match: { conclusion: 1 } } }], edges: [] };
|
|
2184
|
+
assertEquals(graphCarriesRedactedSecrets(concNum), false);
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
test("#778 graphCarriesRedactedSecrets: an UNKNOWN wait.match key is runtime-inert (parseMatch ignores it) so it is NOT a digest fork — push-back on fingerprinting it (#778 review — thread :1495)", () => {
|
|
2188
|
+
// `parseMatch` reads only the DECLARED predicate fields; an unknown key never reaches the worker's
|
|
2189
|
+
// predicate, so two graphs differing only by it run identically and correctly share one digest.
|
|
2190
|
+
// Fingerprinting it would wrongly force an idempotencyKey onto a behaviourally-identical graph.
|
|
2191
|
+
const withUnknown = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://h/health", match: { status: 200, bogus: "whatever" } } }], edges: [] };
|
|
2192
|
+
assertEquals(graphCarriesRedactedSecrets(withUnknown), false);
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
test("#778 wait display: a padded probe `kind` (\" http \") renders trimmed in name+doc, so it shares the trimmed twin's digest and is NOT digest-invisible (runtime `parseProbe` trims) (#778 review — thread :1495/:1296)", async () => {
|
|
2196
|
+
const mk = (k: string) => ({ name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: k, target: "https://h/health" } }], edges: [] });
|
|
2197
|
+
const padded = await compileOk(mk(" http "));
|
|
2198
|
+
const plain = await compileOk(mk("http"));
|
|
2199
|
+
assertEquals(padded.digest, plain.digest);
|
|
2200
|
+
assertEquals(graphCarriesRedactedSecrets(mk(" http ")), false);
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
test("#778 connector escalation descriptor: a padded connector `target` (\" converge-merge \") renders TRIMMED in the compiled BPMN, so it shares the trimmed twin's digest (runtime connector worker + `nodeDisplay` both trim) (#778 review — thread deliveryGraphCompiler.ts:1741)", async () => {
|
|
2204
|
+
const mk = (t: string) => ({
|
|
2205
|
+
name: "n",
|
|
2206
|
+
nodes: [
|
|
2207
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "Open a PR." } },
|
|
2208
|
+
{ id: "land", kind: "connector", connector: { target: t, payload: { pr: "acme/repo#1" } } },
|
|
2209
|
+
],
|
|
2210
|
+
edges: [{ from: "open", to: "land" }],
|
|
2211
|
+
});
|
|
2212
|
+
const padded = await compileOk(mk(" converge-merge "));
|
|
2213
|
+
const plain = await compileOk(mk("converge-merge"));
|
|
2214
|
+
// The escalation descriptor (`connector → …`) embedded in the compiled BPMN must trim the target, or a
|
|
2215
|
+
// whitespace-only variant forks the digest from the trimmed-equivalent graph the runtime dispatches
|
|
2216
|
+
// identically (issue #778 review — thread deliveryGraphCompiler.ts:1741).
|
|
2217
|
+
assertEquals(padded.digest, plain.digest);
|
|
2218
|
+
assert(padded.semanticBpmn.includes("connector → converge-merge"), `the descriptor must embed the trimmed target: ${padded.semanticBpmn.match(/connector →[^<"&]*/)?.[0]}`);
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
test("#778 graphCarriesRedactedSecrets: whitespace-only dedupeKey/formKey difference is NOT lossy (runtime trims both)", () => {
|
|
2222
|
+
// The connector worker (`connectorDedupeKey`) and human form resolution (`resolveHumanForm`) BOTH trim
|
|
2223
|
+
// these keys, so a leading/trailing-whitespace-only variant has identical runtime identity/behaviour —
|
|
2224
|
+
// the fingerprint keys on the NORMALISED value, so it must NOT be flagged digest-invisible (else a
|
|
2225
|
+
// whitespace twin needlessly requires an idempotencyKey and double-launches an identical graph) (#778).
|
|
2226
|
+
const wsKey = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", dedupeKey: " key " } }], edges: [] };
|
|
2227
|
+
assertEquals(graphCarriesRedactedSecrets(wsKey), false);
|
|
2228
|
+
const cleanKey = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", dedupeKey: "key" } }], edges: [] };
|
|
2229
|
+
assertEquals(graphCarriesRedactedSecrets(cleanKey), false);
|
|
2230
|
+
// The whitespace twin fingerprints IDENTICALLY to its trimmed form — same run-key, so a re-dispatch
|
|
2231
|
+
// short-circuits as `alreadyRunning` instead of relaunching.
|
|
2232
|
+
assertEquals(JSON.stringify(digestInvisibleRawValues(wsKey)), JSON.stringify(digestInvisibleRawValues(cleanKey)));
|
|
2233
|
+
const wsForm = { name: "n", nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: " deploy-approval " } }], edges: [] };
|
|
2234
|
+
assertEquals(graphCarriesRedactedSecrets(wsForm), false);
|
|
2235
|
+
const cleanForm = { name: "n", nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "deploy-approval" } }], edges: [] };
|
|
2236
|
+
assertEquals(JSON.stringify(digestInvisibleRawValues(wsForm)), JSON.stringify(digestInvisibleRawValues(cleanForm)));
|
|
2237
|
+
// A genuine credential in either key is still redacted-away → lossy (whitespace normalisation does not
|
|
2238
|
+
// hide a real secret).
|
|
2239
|
+
const credKey = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", dedupeKey: "//user:pass@host/x?token=abc" } }], edges: [] };
|
|
2240
|
+
assertEquals(graphCarriesRedactedSecrets(credKey), true);
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("#778 graphCarriesRedactedSecrets: true when payload.pr carries a redacted-away credential, or is a non-string value", () => {
|
|
2244
|
+
// `nodeDisplay` surfaces `payload.pr` redacted; a credential in the raw pr URL is dropped from the
|
|
2245
|
+
// digest but reaches the connector → lossy.
|
|
2246
|
+
const credPr = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: "https://u:p@host/x?token=abc" } } }], edges: [] };
|
|
2247
|
+
assertEquals(graphCarriesRedactedSecrets(credPr), true);
|
|
2248
|
+
// A non-string pr is never rendered as a string, yet the raw payload still reaches the connector.
|
|
2249
|
+
const nonStringPr = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: 42 } } }], edges: [] };
|
|
2250
|
+
assertEquals(graphCarriesRedactedSecrets(nonStringPr), true);
|
|
2251
|
+
// A faithful string pr (no credential) is fully digest-represented → not lossy.
|
|
2252
|
+
const plainPr = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: "o/r#1" } } }], edges: [] };
|
|
2253
|
+
assertEquals(graphCarriesRedactedSecrets(plainPr), false);
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
test("#778 graphCarriesRedactedSecrets: lossy for a present-but-unsurfaced payload — empty object, null/empty pr — without throwing on a non-plain payload", () => {
|
|
2257
|
+
// A present-empty payload `{}` renders NO `PR:` line yet the raw `{}` reaches the connector worker —
|
|
2258
|
+
// the digest cannot see it, so it must be lossy (else a `{}`-payload graph shares a run identity with
|
|
2259
|
+
// a no-payload one) (issue #778 review).
|
|
2260
|
+
const emptyPayload = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: {} } }], edges: [] };
|
|
2261
|
+
assertEquals(graphCarriesRedactedSecrets(emptyPayload), true);
|
|
2262
|
+
// `{ pr: null }` and `{ pr: "" }` likewise surface no `PR:` line but reach the connector raw.
|
|
2263
|
+
const nullPr = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: null } } }], edges: [] };
|
|
2264
|
+
assertEquals(graphCarriesRedactedSecrets(nullPr), true);
|
|
2265
|
+
const emptyStrPr = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: { pr: "" } } }], edges: [] };
|
|
2266
|
+
assertEquals(graphCarriesRedactedSecrets(emptyStrPr), true);
|
|
2267
|
+
// A non-plain-object payload (a bare primitive) must be treated as lossy WITHOUT throwing — `"pr" in 42`
|
|
2268
|
+
// would otherwise crash the predicate and 500 the dispatch (issue #778 review).
|
|
2269
|
+
const primitivePayload = { name: "n", nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#r", payload: 42 } }], edges: [] };
|
|
2270
|
+
assertEquals(graphCarriesRedactedSecrets(primitivePayload), true);
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
test("#778 nodeDisplay: emit `description` is embedded (redacted) in the label so a description-differing graph is not collapsed", () => {
|
|
2274
|
+
// `renderEmitContract` embeds each fact's `description` in the runtime `appendPrompt`, so two graphs
|
|
2275
|
+
// differing ONLY in an emit description dispatch DIFFERENT instructions — the display must carry a
|
|
2276
|
+
// (redacted) representation or the digest collapses them and keyless dispatch reuses the wrong prompt.
|
|
2277
|
+
const withDesc = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "do it" }, emits: [{ name: "pr", type: "pr", description: "the merged PR ref" }] });
|
|
2278
|
+
assert(withDesc.documentation.includes("Emits: pr (pr) — the merged PR ref"), "emit description appears in the label");
|
|
2279
|
+
const other = nodeDisplay({ id: "a", kind: "agent", agent: { jobType: "j", prompt: "do it" }, emits: [{ name: "pr", type: "pr", description: "a DIFFERENT description" }] });
|
|
2280
|
+
assert(withDesc.documentation !== other.documentation, "a different emit description yields a different label");
|
|
2281
|
+
// A credential in the description is redacted in the display AND flagged digest-invisible.
|
|
2282
|
+
const credDesc = { name: "n", nodes: [{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "do it" }, emits: [{ name: "pr", type: "pr", description: "post to //user:pass@host/x?token=abc" }] }], edges: [] };
|
|
2283
|
+
const disp = nodeDisplay(credDesc.nodes[0]);
|
|
2284
|
+
assert(!disp.documentation.includes("user:pass") && !disp.documentation.includes("token=abc"), "credential is stripped from the emit-description label");
|
|
2285
|
+
assertEquals(graphCarriesRedactedSecrets(credDesc), true);
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("#778 nodeDisplay: a credential-bearing human `formKey` is redacted in the label and flagged digest-invisible", () => {
|
|
2289
|
+
const credForm = { name: "n", nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "//user:pass@forms.example.com/approve?token=abc" } }], edges: [] };
|
|
2290
|
+
const disp = nodeDisplay(credForm.nodes[0]);
|
|
2291
|
+
assert(!disp.documentation.includes("user:pass") && !disp.documentation.includes("token=abc"), "credential stripped from the formKey label");
|
|
2292
|
+
assertEquals(graphCarriesRedactedSecrets(credForm), true);
|
|
2293
|
+
// An ordinary opaque form id is shown verbatim and is fully digest-represented.
|
|
2294
|
+
const plainForm = { name: "n", nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "deploy-approval" } }], edges: [] };
|
|
2295
|
+
assert(nodeDisplay(plainForm.nodes[0]).documentation.includes("Form: deploy-approval"), "opaque form id shown verbatim");
|
|
2296
|
+
assertEquals(graphCarriesRedactedSecrets(plainForm), false);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test("#778 graphCarriesRedactedSecrets: true when a non-redacted match value loses characters to XML sanitization", () => {
|
|
2300
|
+
// A non-secret match value is shown as `String(v)`, which XML-1.0 sanitization strips invalid chars
|
|
2301
|
+
// from at serialization — so `"1\u0001"` and `"1"` would share a digest while the raw probe configs
|
|
2302
|
+
// differ. That sanitization loss must be flagged digest-invisible (issue #778 review).
|
|
2303
|
+
const sanitized = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://h/x", match: { version: "1\u0001" } } }], edges: [] };
|
|
2304
|
+
assertEquals(graphCarriesRedactedSecrets(sanitized), true);
|
|
2305
|
+
// The clean twin (no invalid chars) is faithfully represented → not lossy.
|
|
2306
|
+
const clean = { name: "n", nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "https://h/x", match: { version: "1" } } }], edges: [] };
|
|
2307
|
+
assertEquals(graphCarriesRedactedSecrets(clean), false);
|
|
2308
|
+
// The two differ in their digest-invisible values, so their dispatch fingerprints diverge.
|
|
2309
|
+
assert(JSON.stringify(digestInvisibleRawValues(sanitized)) !== JSON.stringify(digestInvisibleRawValues(clean)), "sanitization-lossy value contributes a distinct fingerprint");
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
test("#778 digestInvisibleRawValues: emits a canonical (node-order-independent) list so the run-key fingerprint is reorder-invariant", () => {
|
|
2313
|
+
// The compiler sorts nodes before `semanticBpmn`, so a top-level node reorder shares one digest. This
|
|
2314
|
+
// list — which the run-key fingerprints — must therefore be reorder-invariant too, else a re-stage in a
|
|
2315
|
+
// different node encoding forks the run-key and double-launches (issue #778 review). Entries are
|
|
2316
|
+
// code-unit sorted at the source, so two node orderings yield an IDENTICAL list.
|
|
2317
|
+
const ab = { name: "n", nodes: [{ id: "a", kind: "connector", connector: { target: "//user:pass@host" } }, { id: "b", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] };
|
|
2318
|
+
const ba = { name: "n", nodes: [{ id: "b", kind: "connector", connector: { target: "//other:secret@host" } }, { id: "a", kind: "connector", connector: { target: "//user:pass@host" } }], edges: [] };
|
|
2319
|
+
assertEquals(JSON.stringify(digestInvisibleRawValues(ab)), JSON.stringify(digestInvisibleRawValues(ba)));
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
test("#778 redactFreeText: a userinfo with a literal SPACE then a NEWLINE before the `@` is still redacted (thread deliveryGraphCompiler.ts:1102)", () => {
|
|
2323
|
+
// The SPACE cuts the primary span at `//user:secret`, and the malformed-userinfo fallback must NOT
|
|
2324
|
+
// stop at the following CR/LF — it has to reach the `@` so the credential + `?token` tail collapse.
|
|
2325
|
+
const out = redactFreeText("push to //user:secret pass\n@host/repo?token=s3cr3t now");
|
|
2326
|
+
assert(!out.includes("token=s3cr3t"), `the query token must not survive: ${out}`);
|
|
2327
|
+
assert(!out.includes("secret pass"), `the space+newline userinfo must not survive: ${out}`);
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
test("#778 redactFreeText: redacts a URL query/fragment orphaned across a smuggled newline", () => {
|
|
2331
|
+
// The whitespace-delimited URL token stops at the CR/LF, so the `?token=…` tail on the far side must
|
|
2332
|
+
// still be stripped by the newline-spanning belt-and-braces (issue #778 review).
|
|
2333
|
+
const out = redactFreeText("see //user:pa\nss@host/path?token=secret for details");
|
|
2334
|
+
assert(!out.includes("user:pa") && !out.includes("token=secret"), `credential must not survive: ${out}`);
|
|
2335
|
+
// Ordinary prose after a real whitespace break is not over-redacted.
|
|
2336
|
+
const prose = redactFreeText("visit //example.com then\nis this ok? yes");
|
|
2337
|
+
assert(prose.includes("is this ok? yes"), `prose after a whitespace break is intact: ${prose}`);
|
|
2338
|
+
});
|