@nanobpm/nano-workforce 0.190.0 → 0.191.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +6 -6
  2. package/CHANGELOG.md +12 -0
  3. package/app/deliveryGraph.test.ts +447 -0
  4. package/app/deliveryGraph.ts +356 -1
  5. package/app/deliveryGraphCompiler.test.ts +1465 -3
  6. package/app/deliveryGraphCompiler.ts +833 -36
  7. package/app/deliveryGraphDispatch.test.ts +118 -2
  8. package/app/deliveryGraphDispatch.ts +138 -10
  9. package/app/deliveryGraphProposals.test.ts +243 -0
  10. package/app/deliveryGraphProposals.ts +130 -56
  11. package/app/deliveryGraphRun.test.ts +67 -4
  12. package/app/deliveryGraphRun.ts +62 -10
  13. package/app/deliveryHuman.test.ts +79 -2
  14. package/app/deliveryHuman.ts +92 -0
  15. package/app/deliveryRunner.test.ts +21 -0
  16. package/app/deliveryRunner.ts +9 -2
  17. package/app/mergeProtocol.ts +1 -1
  18. package/app/pollUserTasks.test.ts +39 -0
  19. package/app/readiness.test.ts +72 -0
  20. package/app/readiness.ts +234 -8
  21. package/app/service.ts +8 -3
  22. package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
  23. package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
  24. package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
  25. package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
  26. package/docs/adr/0003-epic-base-branch-admission.md +1 -1
  27. package/docs/adr/0004-shared-contract-coordination.md +1 -1
  28. package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
  29. package/docs/adr/0006-delivery-units-one-representation.md +1 -1
  30. package/operations/dispatchDeliveryGraph.test.ts +255 -0
  31. package/operations/dispatchDeliveryGraph.ts +156 -5
  32. package/package.json +1 -1
  33. package/workers/merge/worker.test.ts +2 -2
  34. package/workers/merge/worker.ts +1 -1
@@ -83,6 +83,98 @@ export function deliveryHumanContextQuestion(
83
83
  return label || "A scheduled delivery-graph step is waiting to be completed.";
84
84
  }
85
85
 
86
+ /** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
87
+ * first `http(s)` URL embedded in the node's stored instruction, or `null` when it names none. The
88
+ * Tasks page renders the "Decision context" as PLAIN, non-clickable text (a `detail.fields` entry),
89
+ * and links only through the http(s)-gated `subject_url` `linkField` — so an author who wants the
90
+ * operator to have a clickable link (e.g. the PR to review) writes the URL into the node prompt and
91
+ * this lifts it onto `subject_url`. Uses the SAME exact-then-`__esc`-stripped lookup as the context
92
+ * question so the twin escalation task resolves to its real node's instruction. Returns `null` (not a
93
+ * fabricated link) when no URL is present, preserving today's linkless behaviour. */
94
+ export function deliveryHumanContextUrl(
95
+ humanLabels: Record<string, string> | undefined,
96
+ elementId: string,
97
+ ): string | null {
98
+ const labels = humanLabels ?? {};
99
+ const base = elementId.replace(/__esc$/, "");
100
+ const instruction = labels[elementId] ?? labels[base] ?? "";
101
+ const url = firstHttpUrl(instruction);
102
+ // The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
103
+ // carried a credential — `//user:pass@` userinfo, or a `?query`/`#fragment` (which can hide a signed/
104
+ // authenticating token) — has that component collapsed to the `***` sentinel (`//***@`, `?***`, `#***`).
105
+ // Lifting such an altered URL onto `subject_url` would publish a DIFFERENT, broken link (a truncated
106
+ // query changes the referenced resource; a signed URL becomes unusable). We cannot recover the real URL
107
+ // here — the raw prompt is deliberately NOT stored (issue #778) — so decline to link rather than silently
108
+ // publishing a redaction-mangled one. A URL with no credential component is untouched by redaction and
109
+ // links faithfully (issue #813 review). Callers wanting a clickable link should embed a credential-free
110
+ // URL in the prompt.
111
+ return url !== null && urlBearsRedactionMarker(url) ? null : url;
112
+ }
113
+
114
+ /** The `***` sentinel {@link redactFreeText}/`redactString` leaves where it collapsed a URL's credential
115
+ * span — `//***@` (userinfo), `?***` (query), `#***` (fragment). Its presence in an extracted URL means
116
+ * redaction ALTERED the link, so it must not be published as a `subject_url` (issue #813 review). */
117
+ const URL_REDACTION_MARKER = /\/\/\*\*\*@|[?#]\*\*\*/;
118
+
119
+ function urlBearsRedactionMarker(url: string): boolean {
120
+ return URL_REDACTION_MARKER.test(url);
121
+ }
122
+
123
+ const URL_TRAILING_CLOSERS: Readonly<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
124
+
125
+ const URL_TRAILING_PUNCTUATION = ".,;:!?'\"";
126
+
127
+ /** Trim trailing prose punctuation a URL commonly picks up when embedded in a sentence (`.,;:!?'"` and
128
+ * a wrapping `)`/`]`/`}`), WITHOUT corrupting a URL that legitimately ends in a closing bracket. A
129
+ * closing bracket is only stripped when it is UNBALANCED within the URL (a prose wrapper, e.g. the `)`
130
+ * in "see (https://x/y)"); a balanced pair — e.g. Wikipedia `…/Foo_(disambiguation)` — is part of the
131
+ * URL and preserved, so the persisted link isn't truncated into a broken one. Bracket balance is
132
+ * tracked in running per-pair counts (seeded by one linear scan, then decremented as trailing closers
133
+ * are stripped) so the whole trim is a single O(n) pass — NOT the prior re-`split()`-the-prefix-each-
134
+ * step quadratic (issue #813 review). */
135
+ function trimUrlTrailingPunctuation(url: string): string {
136
+ const opens: Record<string, number> = { "(": 0, "[": 0, "{": 0 };
137
+ const closes: Record<string, number> = { ")": 0, "]": 0, "}": 0 };
138
+ for (const ch of url) {
139
+ if (ch in opens) opens[ch] += 1;
140
+ else if (ch in closes) closes[ch] += 1;
141
+ }
142
+ let end = url.length;
143
+ while (end > 0) {
144
+ const ch = url[end - 1];
145
+ const opener = URL_TRAILING_CLOSERS[ch];
146
+ if (opener !== undefined) {
147
+ // `opens`/`closes` reflect url[0:end]; only trailing closers are ever removed below, so they stay
148
+ // accurate. Balanced (opener count ≥ closer count) → the bracket belongs to the URL; stop.
149
+ if (opens[opener] >= closes[ch]) break;
150
+ closes[ch] -= 1;
151
+ end -= 1;
152
+ continue;
153
+ }
154
+ if (URL_TRAILING_PUNCTUATION.includes(ch)) {
155
+ end -= 1;
156
+ continue;
157
+ }
158
+ break;
159
+ }
160
+ return url.slice(0, end);
161
+ }
162
+
163
+ /** Extract the first `http(s)` URL from free text, trimming trailing sentence punctuation/brackets a
164
+ * prose author commonly appends (`).,;` etc.) so the link resolves — but preserving a bracket that is
165
+ * BALANCED within the URL (e.g. Wikipedia `…_(technology)`), so a valid URL is never truncated into a
166
+ * broken task link. An apostrophe is a valid URL sub-delimiter (RFC 3986 §2.2), so it is allowed WITHIN
167
+ * the URL and only stripped when trailing (a prose quote closer) by `trimUrlTrailingPunctuation` — the
168
+ * same way a wrapping `)` is handled — so a link such as `…/It's_a_Wonderful_Life` is not truncated at
169
+ * the apostrophe (issue #813 review). The scheme match is case-insensitive (RFC 3986 schemes are
170
+ * case-insensitive). Returns `null` when none. */
171
+ export function firstHttpUrl(text: string | undefined | null): string | null {
172
+ if (typeof text !== "string") return null;
173
+ const match = text.match(/https?:\/\/[^\s<>"`]+/i);
174
+ if (!match) return null;
175
+ return trimUrlTrailingPunctuation(match[0]) || null;
176
+ }
177
+
86
178
  /** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
87
179
  * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
88
180
  export const GENERIC_HUMAN_FORM = "delivery-human-generic";
@@ -128,6 +128,27 @@ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt
128
128
  assertEquals(ack?.nodeId, "ack");
129
129
  });
130
130
 
131
+ test("the human node's seeded prompt REDACTS an embedded credential (issue #778 review): the read-only user-task form field never renders a `//user:pass@host` secret", async () => {
132
+ // The compiler already redacts the human prompt for the operator-facing PREVIEW (buildHumanNodes),
133
+ // but the runtime user-task seeds `nodeInputs.<el>.prompt` — rendered read-only in the delivery-human
134
+ // form (`delivery-human-{ack,publish}.form`, key "prompt", `readonly:true`). That display path must be
135
+ // redacted too, or a credential-bearing instruction leaks its secret to whoever opens the task. The
136
+ // prompt is DISPLAY-ONLY (the functional outputs are the emit/resolvedArtifact fields), so redacting it
137
+ // is safe. `redactFreeText` is a no-op for a credential-free prompt, so the plain-prompt tests above
138
+ // stay green.
139
+ const graph: DeliveryGraph = {
140
+ nodes: [
141
+ { id: "publish", kind: "human", human: { prompt: "clone https://user:s3cr3t@github.com/o/r then run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
142
+ ],
143
+ edges: [],
144
+ };
145
+ const p = await prepareOk(graph);
146
+ const human = Object.values(p.nodeInputs).find((v) => "escalationSlaTimeout" in (v as Record<string, unknown>)) as Record<string, unknown>;
147
+ const prompt = String(human.prompt);
148
+ assert(!prompt.includes("s3cr3t"), `the human-task form prompt must not expose the URL credential, got: ${prompt}`);
149
+ assert(prompt.includes("***@github.com"), `the credential userinfo is redacted in place, got: ${prompt}`);
150
+ });
151
+
131
152
  test("agent node classifier-emit contract (#506): a declared `emits` threads the emit instruction into appendPrompt; a no-emit node leaves it untouched", async () => {
132
153
  // #506: a guarded split (S7) routes on a producer's emitted scalar, published from the engine
133
154
  // variable named exactly after the fact. A real `senior:*` agent completes with the Output-contract
@@ -19,7 +19,7 @@ import type { EngineClient } from "@nanobpm/urban";
19
19
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
21
  import { isPlausibleBranchName } from "./baseBranch.ts";
22
- import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
+ import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, redactFreeText } from "./deliveryGraphCompiler.ts";
23
23
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
24
24
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
25
25
  import { isoDuration } from "./reviewWait.ts";
@@ -585,7 +585,14 @@ function buildNodeInput(
585
585
  // form can render its "now do X" prompt, name the parked node, and label/hide its emit field
586
586
  // (issue #499 — the generic form otherwise renders contextless). `emits` stays the single
587
587
  // source of truth: the compiled ioMapping derives the emit label/mode from it in FEEL.
588
- prompt: node.human?.prompt ?? "",
588
+ // REDACT the prompt: this seeds `nodeInputs.<el>.prompt`, rendered in the delivery-human form's
589
+ // read-only `prompt` textarea (`delivery-human-{ack,publish}.form`) to whoever opens the task —
590
+ // a DISPLAY surface, not a functional value (the functional outputs are the emit/resolvedArtifact
591
+ // fields). So it gets the SAME embedded-credential redaction the operator-facing compile PREVIEW
592
+ // already applies (buildHumanNodes), closing the runtime leak where a credential-bearing
593
+ // instruction would expose its secret in the parked task's form (issue #778 review — thread
594
+ // deliveryGraphCompiler.ts:826). `redactFreeText` is a no-op for a credential-free prompt.
595
+ prompt: redactFreeText(node.human?.prompt ?? ""),
589
596
  nodeId: node.id ?? ctx.element,
590
597
  emits: Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [],
591
598
  };
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Merlin's merge stage was repo-blind: it ran a plain `gh pr merge` and, on refusal, escalated to
4
4
  // a human. That breaks on any repo with a frugal-CI + on-demand-queue posture — e.g.
5
- // `Magikcraft/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
5
+ // `nanobpm/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
6
6
  // NOT re-run it), and the documented way to land is: produce a fresh head run (`gh pr ready` /
7
7
  // close+reopen) → wait for it to go green → `@mergifyio queue`. See that repo's
8
8
  // `AGENTS.md → ## Merging PRs`.
@@ -695,6 +695,45 @@ test("pollUserTasks: a parked delivery-human node carries its instruction as `qu
695
695
  assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
696
696
  });
697
697
 
698
+ test("pollUserTasks: a delivery-human node lifts an embedded prompt URL onto subject_url (issue #813)", async () => {
699
+ // A delivery run carries no other subject URL, so the ONLY clickable link a delivery human task can
700
+ // offer is a URL the author embedded in the node instruction — `deliveryHumanContextUrl` lifts it
701
+ // onto `subject_url`. Assert the wiring end-to-end (pure-helper tests alone would miss a projection
702
+ // regression): the base node AND its bounded-timeout `…__esc` twin both resolve the same stored
703
+ // label to the same link, while a URL-less instruction leaves `subject_url` null.
704
+ const { data, stores } = memData({
705
+ delivery_graph_runs: [
706
+ {
707
+ run_key: "delivery-graph-403eb22e",
708
+ process_key: "dg-1",
709
+ status: "running",
710
+ title: "release runbook",
711
+ human_labels: JSON.stringify({
712
+ "delivery-human-task__n7": "Review the release PR at https://github.com/nanobpm/nano-workforce/pull/814 before publishing.",
713
+ "delivery-human-task__n8": "Run the manual OTP publish for @nanobpm/urban",
714
+ }),
715
+ },
716
+ ],
717
+ });
718
+ const restore = stubUserTaskSearch([
719
+ { userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
720
+ // the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base link
721
+ { userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
722
+ // a URL-less instruction leaves the link null (no fabricated link)
723
+ { userTaskKey: "20413", elementId: "delivery-human-task__n8", processInstanceKey: "dg-1", state: "CREATED" },
724
+ ]);
725
+ try {
726
+ await pollUserTasks(data, fakeEngine({}), REST);
727
+ } finally {
728
+ restore();
729
+ }
730
+
731
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
732
+ assertEquals(byKey["20411"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
733
+ assertEquals(byKey["20412"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
734
+ assertEquals(byKey["20413"].subject_url, null);
735
+ });
736
+
698
737
  test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
699
738
  // An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
700
739
  // node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
@@ -48,6 +48,7 @@ import {
48
48
  readinessTimeoutMs,
49
49
  redactString,
50
50
  redactTarget,
51
+ isUrlShaped,
51
52
  } from "./readiness.ts";
52
53
 
53
54
  // A ProbeExec stub: canned http/command responses, capturing the last command it was asked to run.
@@ -862,6 +863,20 @@ test("probeBudgetMs: prefers the seeded probeTimeout (the gate timer's bound), f
862
863
  });
863
864
 
864
865
  // ── repo/ref parse + redaction ──────────────────────────────────────────────────────────────
866
+ test("isUrlShaped: tolerates XML-attribute whitespace (TAB/LF/CR/space) between the scheme and `//` — `https:\\t//host` still classifies as a URL so its credential is redacted, not passed through as a non-URL (#778 review — thread readiness.ts:1191)", () => {
867
+ assert(isUrlShaped("https://host"), "a plain URL");
868
+ assert(isUrlShaped("//host"), "scheme-relative");
869
+ assert(isUrlShaped(" https://host"), "leading whitespace (trimmed first)");
870
+ // The XML-valid whitespace `stripXmlInvalidChars` keeps must not break the scheme→authority match:
871
+ assert(isUrlShaped("https:\t//user:pass@host"), "TAB between scheme and //");
872
+ assert(isUrlShaped("https:\n//host"), "LF between scheme and //");
873
+ assert(isUrlShaped("https:\r//host"), "CR between scheme and //");
874
+ assert(isUrlShaped("https: //host"), "spaces between scheme and //");
875
+ // A plain routing token / non-URL is still NOT url-shaped.
876
+ assert(!isUrlShaped("senior:feature"), "a plain job-type token is not url-shaped");
877
+ assert(!isUrlShaped("owner/repo#1"), "a PR ref is not url-shaped");
878
+ });
879
+
865
880
  test("parseRepoRef: splits owner/repo@ref and defaults the ref to HEAD", () => {
866
881
  assertEquals(parseRepoRef("o/r@abc123"), { repo: "o/r", ref: "abc123" });
867
882
  assertEquals(parseRepoRef("o/r"), { repo: "o/r", ref: "HEAD" });
@@ -874,6 +889,63 @@ test("redactString/redactTarget: strip userinfo and query (a token often rides e
874
889
  assert(!t.includes("s3cr3t"), "the secret must not survive redaction");
875
890
  });
876
891
 
892
+ test("redactString: a `//user:pass@` userinfo that embeds a raw line break is still stripped (newline-safe)", () => {
893
+ // The userinfo class is `[^/@]` (NOT `[^/@\s]`): a CR/LF smuggled inside the userinfo must not
894
+ // break the `//…@` match and leave the credential tail visible.
895
+ const r = redactString("https://user:pa\nss@host/path");
896
+ assert(!r.includes("ss@host") && !r.includes("user:pa"), "the newline-split userinfo is redacted");
897
+ assertStringIncludes(r, "//***@");
898
+ });
899
+
900
+ test("redactString: a `//user:pass@` userinfo that embeds a raw TAB is still stripped (tab-safe)", () => {
901
+ // A TAB is a VALID XML character `stripXmlInvalidChars` does not remove, so an earlier `[^/@ \t]`
902
+ // userinfo class (excluding TAB) let `https://user:pa\tss@host` split before the `@` — leaving
903
+ // `ss@host` visible in the display artifact. The class is now `[^/@]` (issue #778 review).
904
+ const r = redactString("https://user:pa\tss@host/path?token=s3cr3t");
905
+ assert(!r.includes("ss@host") && !r.includes("user:pa"), "the tab-split userinfo is redacted");
906
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
907
+ assertStringIncludes(r, "//***@");
908
+ });
909
+
910
+ test("redactString: a `//user:secret pass@` userinfo that embeds a literal SPACE is still stripped (space-safe)", () => {
911
+ // An earlier `[^/@ ]` class bounded the userinfo at a SPACE to avoid over-matching prose, but that let
912
+ // a malformed but operator-authored `https://user:secret pass@host` leave `user:secret` visible in the
913
+ // display artifact. The class is now `[^/@]` (space-tolerant), so the whole userinfo through the `@`
914
+ // collapses; the RAW value still reaches runtime unmodified (issue #778 review — thread readiness.ts:1177).
915
+ const r = redactString("https://user:secret pass@host/path?token=s3cr3t");
916
+ assert(!r.includes("secret") && !r.includes("user:"), `the space-split userinfo is redacted: ${r}`);
917
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
918
+ assertStringIncludes(r, "//***@");
919
+ });
920
+
921
+ test("redactString: userinfo carrying MULTIPLE raw `@` collapses the WHOLE authority credential, not just the prefix (multi-@ suffix-leak, #778 review — thread readiness.ts:1175)", () => {
922
+ // Per RFC 3986 the userinfo runs to the LAST `@` before the authority's path. An earlier `[^/@]*@`
923
+ // span stopped at the FIRST `@`, so `https://user:pass@ss@host` rendered as `https://***@ss@host` —
924
+ // leaking `ss@host`, the userinfo suffix, into the display artifact. The class is now `[^/]` so the
925
+ // span reaches the final `@` and the entire credential authority collapses to `//***@` (leaving the
926
+ // real host `host`).
927
+ const r = redactString("https://user:pass@ss@host/path?token=s3cr3t");
928
+ assert(!r.includes("ss@host"), `the multi-@ userinfo suffix must not leak: ${r}`);
929
+ assert(!r.includes("user:pass"), "the userinfo prefix is redacted");
930
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
931
+ assertStringIncludes(r, "//***@host");
932
+ });
933
+
934
+ test("redactString: a `?query`/`#fragment` value containing a literal `@` does NOT let the userinfo span cross the delimiter and leak the tail (#778 review — thread readiness.ts:1199)", () => {
935
+ // `//host?token=secret@tail` has NO userinfo — the `@` sits INSIDE the query. An unbounded `[^/]*@`
936
+ // userinfo class consumed the `?` marker (`//host?token=secret@` → `//***@`), so the follow-on
937
+ // query strip found no `?` and left `tail` visible. Bounding the userinfo class before `?`/`#`
938
+ // (`[^/?#]*@`) keeps the `?` in place so the whole `?query` (incl. the `@tail`) is redacted.
939
+ const r = redactString("https://host?token=secret@tail");
940
+ assert(!r.includes("tail"), `the query tail past the @ must not leak: ${r}`);
941
+ assert(!r.includes("secret"), "the query token is redacted");
942
+ assertEquals(r, "https://host?***");
943
+ // A GENUINE userinfo credential still collapses AND its query is still stripped.
944
+ const c = redactString("https://user:pass@host?token=s3cr3t@x");
945
+ assert(!c.includes("user:pass") && !c.includes("s3cr3t"), `userinfo + query both redacted: ${c}`);
946
+ assertEquals(c, "https://***@host?***");
947
+ });
948
+
877
949
  test("redactTarget: a command target is never logged — only the kind + a fixed placeholder", () => {
878
950
  const ct = redactTarget(parseProbe({ kind: "command", target: "curl -H 'Authorization: Bearer s3cr3t' https://h/p" }));
879
951
  assertEquals(ct, "command:<redacted>");
package/app/readiness.ts CHANGED
@@ -63,6 +63,11 @@ export type Backoff = "fixed" | "exponential";
63
63
  // is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces).
64
64
  export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
65
65
  export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
66
+ // The DEFAULT `onTimeout` an omitted value takes — the single source of truth `parseProbe` fills in and
67
+ // the compiler's display drops as an effective default (writing it explicitly is behaviourally identical
68
+ // to omitting it, so surfacing it would fork `semanticBpmn`/the digest from the omitted-equivalent
69
+ // graph; issue #778 review — thread deliveryGraphCompiler.ts:1398).
70
+ export const DEFAULT_ON_TIMEOUT: OnTimeout = "escalate";
66
71
  export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
67
72
  export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
68
73
  export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
@@ -237,7 +242,7 @@ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean
237
242
  if (onTimeoutRaw !== "" && !isOnTimeout(onTimeoutRaw)) {
238
243
  throw new Error(`readiness probe: invalid onTimeout '${onTimeoutRaw}' (expected ${ON_TIMEOUTS.join(", ")})`);
239
244
  }
240
- const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw;
245
+ const onTimeout: OnTimeout = onTimeoutRaw === "" ? DEFAULT_ON_TIMEOUT : onTimeoutRaw;
241
246
 
242
247
  const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined;
243
248
  // A capability edge whose ref or package is blank can never resolve — fail loudly here rather than
@@ -307,7 +312,7 @@ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean
307
312
 
308
313
  // Membership guards that narrow a validated string to its union without a type assertion (the
309
314
  // `no-unsafe-type-assertion` gate bans `as`).
310
- function isProbeKind(v: string): v is ProbeKind {
315
+ export function isProbeKind(v: string): v is ProbeKind {
311
316
  for (const k of PROBE_KINDS) if (k === v) return true;
312
317
  return false;
313
318
  }
@@ -318,7 +323,7 @@ function isOnTimeout(v: string): v is OnTimeout {
318
323
 
319
324
  // `isBackoff` narrows a validated string to its union without a type assertion (the
320
325
  // `no-unsafe-type-assertion` gate bans `as`).
321
- function isBackoff(v: string): v is Backoff {
326
+ export function isBackoff(v: string): v is Backoff {
322
327
  for (const b of BACKOFFS) if (b === v) return true;
323
328
  return false;
324
329
  }
@@ -414,7 +419,17 @@ export function normalizePoll(poll: ProbePoll | undefined): Required<ProbePoll>
414
419
  const everyMs = typeof everyRaw === "number" && everyRaw >= 1 ? Math.trunc(everyRaw) : DEFAULT_EVERY_MS;
415
420
  const timeoutMs =
416
421
  typeof timeoutRaw === "number" && timeoutRaw >= 1 ? Math.trunc(timeoutRaw) : DEFAULT_TIMEOUT_MS;
417
- return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff: poll?.backoff ?? DEFAULT_BACKOFF };
422
+ // TRIM `backoff` to match `parseProbe` (`parsePoll` does `str(raw.backoff).trim()`): a padded
423
+ // `" exponential "` runs as the SAME `exponential` policy, so leaving the whitespace here would make
424
+ // the compiler's display path (`normalizePoll(p.poll)`) emit a spurious non-default `Poll:` line for a
425
+ // runtime-default poll — forking `semanticBpmn`/the digest from the omitted-equivalent graph and
426
+ // letting a re-stage bypass the idempotency fence to duplicate the run (issue #778 review — thread
427
+ // deliveryGraphCompiler.ts:1414). A whitespace-only / unrecognised value falls back to the default
428
+ // (the compiler display path takes raw JSON; a genuinely invalid backoff fails loudly later in
429
+ // `parseProbe`, so defaulting the DISPLAY is safe and keeps the effective-policy render honest).
430
+ const backoffTrimmed = typeof poll?.backoff === "string" ? poll.backoff.trim() : undefined;
431
+ const backoff = backoffTrimmed && isBackoff(backoffTrimmed) ? backoffTrimmed : DEFAULT_BACKOFF;
432
+ return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff };
418
433
  }
419
434
 
420
435
  /** The delay (ms) before the `attempt`-th retry (1-based). Fixed backoff returns `everyMs`;
@@ -1157,10 +1172,221 @@ export function redactTarget(probe: ReadinessProbe): string {
1157
1172
  return `${probe.kind}:${redactString(probe.target)}`;
1158
1173
  }
1159
1174
 
1175
+ /** The ONE canonical embedded-`//<userinfo>@` credential-userinfo span, shared by the redactor
1176
+ * ({@link redactString}, which REWRITES the span to `//***@`) and the semantic reject path
1177
+ * ({@link hasEmbeddedCredential}, used by `validateDeliveryGraph` to REJECT a credential-bearing
1178
+ * `agent.jobType`). Keeping ONE source string means the "what counts as an embedded credential" rule
1179
+ * can never drift between the two — the redactor must strip exactly what the validator rejects, or a
1180
+ * credential the validator misses would be echoed un-redacted. The userinfo class is `[^/?#]` (NOT
1181
+ * `[^/@\s]`, and NOT the earlier `[^/@]`/`[^/]`): it deliberately spans WHITESPACE up to the `@`, so a
1182
+ * malformed-but-operator-authored `//user:secret pass@host` (a literal space in the userinfo) is
1183
+ * caught, not left to leak verbatim into the executable `<zeebe:taskDefinition type=…>` / compiled BPMN
1184
+ * (issue #778 review — thread deliveryGraph.ts:570). The userinfo colon is DELIBERATELY OPTIONAL
1185
+ * (`[^/?#]*@`, not `[^/?#]*:[^/?#]*@`):
1186
+ * a passwordless, username-only `//token@host` is a bearer/OAuth token riding the userinfo and MUST be
1187
+ * caught too — a plain worker-routing job type never contains a `//…@` span at all (with or without a
1188
+ * colon), so requiring a colon would only re-open a real leak for no legitimate gain (issue #778 review
1189
+ * — thread readiness.ts:1170). `[^/?#]*@` is a single-quantifier match — linear, no catastrophic
1190
+ * backtracking.
1191
+ *
1192
+ * The userinfo class is `[^/?#]` (NOT `[^/@]`): per RFC 3986 the userinfo runs to the LAST `@` before the
1193
+ * authority's path, so a malformed-but-accepted multi-`@` value (`//user:pass@ss@host`) must collapse
1194
+ * through EVERY `@` up to the path — an earlier `[^/@]*@` stopped at the FIRST `@`, rewriting only
1195
+ * `//user:pass@` and leaking the `ss@host` suffix into the display artifact (issue #778 review — thread
1196
+ * readiness.ts:1175).
1197
+ *
1198
+ * The class also EXCLUDES `?` and `#` (NOT the earlier `[^/]`): per RFC 3986 a raw `?`/`#` starts the
1199
+ * query/fragment and so ENDS the authority — a `//host?token=secret@tail` carries its `@` INSIDE the
1200
+ * query, with no userinfo at all. An unbounded `[^/]*@` greedily consumed the `?` marker
1201
+ * (`//host?token=secret@` → `//***@`), so the follow-on `?query`/`#fragment` strip in {@link redactString}
1202
+ * found no marker and left `tail` visible. Bounding the userinfo before `?`/`#` keeps the marker in
1203
+ * place so the whole query/fragment (incl. any `@tail`) is redacted, while a genuine `//user:pass@host?…`
1204
+ * still collapses its userinfo AND has its query stripped (issue #778 review — thread readiness.ts:1199).
1205
+ * `[^/?#]` still excludes `/` (so it never crosses into the path), and the trailing `@` is anchored
1206
+ * greedily to the last one before a `/`/`?`/`#`; `[^/?#]*@` remains a single-quantifier match, so it is
1207
+ * linear with no catastrophic backtracking. */
1208
+ export const EMBEDDED_CREDENTIAL_SRC = "\\/\\/[^/?#]*@";
1209
+
1160
1210
  /** Strip credential-bearing pieces from a free-form target string for logging: any `user:pass@`
1161
- * userinfo and any `?query`/`#fragment` (a token often rides the query). */
1211
+ * userinfo and any `?query`/`#fragment` (a token often rides the query). The query/fragment strip uses
1212
+ * `[\s\S]*` (NOT `.*$`, which cannot cross a line break) so an embedded CR/LF after the `?`/`#` — e.g.
1213
+ * `https://host/?token=secret\nnext` — cannot leave the token un-redacted; everything from the first
1214
+ * `?`/`#` to end-of-string is consumed regardless of intervening newlines. The userinfo class is
1215
+ * `[^/?#]` (NOT `[^/@\s]`, NOT the earlier `[^/@ ]`/`[^/@ \t]`/`[^/@]`, and NOT the later `[^/]`): ANY character smuggled
1216
+ * INSIDE the
1217
+ * userinfo up to the `@` — a raw CR/LF (`https://user:pa\nss@host`), an embedded TAB
1218
+ * (`https://user:pa\tss@host`), a literal SPACE (`https://user:secret pass@host`, a malformed but
1219
+ * operator-authored value), OR an extra raw `@` (`https://user:pass@ss@host`, whose userinfo per RFC
1220
+ * 3986 runs to the LAST `@`) — must not break the `//…@` match and leave the credential tail visible.
1221
+ * An earlier `[^/@ ]` bounded the userinfo at a SPACE to avoid over-matching prose, and `[^/@]` stopped
1222
+ * at the FIRST `@` (leaking a multi-`@` suffix), but both let a credential escape into an
1223
+ * operator-visible display artifact; since the RAW value
1224
+ * still reaches runtime unmodified and only the DISPLAY doc is affected, redacting more (through the
1225
+ * last `@` before the path) is the safe direction (issue #778 review). The `?`/`#` exclusion is the
1226
+ * INVERSE correction: a `[^/]` userinfo crossed a raw query/fragment delimiter — a `?token=…@tail` query
1227
+ * (no userinfo; the `@` rides the query) collapsed to `//***@tail`, consuming the `?` marker so the
1228
+ * follow-on `?query`/`#fragment` strip below found nothing and LEAKED `tail`. Bounding the userinfo at
1229
+ * `?`/`#` keeps the marker so the query strip redacts the whole tail (issue #778 review — thread
1230
+ * readiness.ts:1199). `[^/?#]*@` remains a
1231
+ * single-quantifier match, so it is
1232
+ * linear with no catastrophic backtracking. Callers that surface the result in a display artifact must
1233
+ * first pass it through `stripXmlInvalidChars` so an XML-forbidden control (e.g. U+000B) inside the
1234
+ * userinfo cannot split the `//…@` match and be re-joined at render. */
1162
1235
  export function redactString(s: string): string {
1163
- return s
1164
- .replace(/\/\/[^/@\s]*@/g, "//***@")
1165
- .replace(/[?#].*$/, (m) => `${m[0]}***`);
1236
+ return redactEmbeddedCredential(s).replace(/[?#][\s\S]*$/, (m) => `${m[0]}***`);
1237
+ }
1238
+
1239
+ /** Strip ONLY the embedded `//<userinfo>@` credential span(s) from a string, collapsing each to
1240
+ * `//***@` and leaving everything else (including any `?query`/`#fragment`, which may be a MEANINGFUL
1241
+ * opaque-token character rather than URL syntax) untouched. Shares the ONE canonical
1242
+ * {@link EMBEDDED_CREDENTIAL_SRC} span so "what counts as an embedded credential" can never drift from
1243
+ * the redactor ({@link redactString}) or the validator ({@link hasEmbeddedCredential}). A `//<userinfo>@`
1244
+ * span is UNAMBIGUOUSLY a credential wherever it sits — a plain opaque identifier
1245
+ * (`slack:#releases`, `owner/repo#42`, `pkg@version`) never contains one — so it is safe to strip
1246
+ * regardless of the value's URL-shape or the span's position, which is why {@link redactConnectorValue}
1247
+ * runs it on a NON-URL-shaped free-form value (a probe target / PR ref) whose credential rides AFTER a
1248
+ * prefix (`prefix //user:pass@host#42`) that the anchored {@link isUrlShaped} check would miss (issue
1249
+ * #778 review — thread deliveryGraph.ts:162/566). `[^/]*@` is a single-quantifier match — linear, no
1250
+ * catastrophic backtracking. */
1251
+ export function redactEmbeddedCredential(s: string): string {
1252
+ return s.replace(new RegExp(EMBEDDED_CREDENTIAL_SRC, "g"), "//***@");
1253
+ }
1254
+
1255
+ /** The ONE canonical embedded SCHEME-RELATIVE credential-URL token: a `//<userinfo>@authority…` run
1256
+ * (bounded by the next literal space) whose `//` carries userinfo. The `@` UNAMBIGUOUSLY marks the run a
1257
+ * URL — an opaque ref (`//host#42`, `slack:#releases`, `owner/repo#42`) never carries `//user@` — so its
1258
+ * `?query`/`#fragment` IS URL syntax and may hide a token, exactly like an explicit-scheme
1259
+ * {@link EMBEDDED_SCHEME_URL_SRC}. Distinct from the userinfo-ONLY {@link EMBEDDED_CREDENTIAL_SRC}, which
1260
+ * strips just the `//user:pass@` span and (correctly, for an opaque `//host#42`) LEAVES the tail: a
1261
+ * userinfo-bearing `//user:pass@host?token=secret` embedded after a non-URL prefix is scheme-relative,
1262
+ * so the anchored whole-value {@link isUrlShaped} check misses it (non-URL prefix) and the userinfo-only
1263
+ * strip leaks the `?token=secret` tail (issue #778 review — thread deliveryGraph.ts:188). Requiring the
1264
+ * `@` keeps genuine opaque `//host#42` refs (no userinfo) untouched. The token runs to the next literal
1265
+ * SPACE (`[^ ]*`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR and still
1266
+ * redacts a `?query` secret sitting past it. The userinfo class is `[^/?#]` (NOT the earlier `[^/]`):
1267
+ * bounding it before a raw `?`/`#` stops the userinfo span crossing the query/fragment delimiter — a
1268
+ * `//host?token=secret@tail` (no userinfo; the `@` rides the query) no longer collapses to `//***@tail`
1269
+ * (leaking `tail`); it instead falls through to {@link redactEmbeddedSchemeRelativeUrl}, which strips the
1270
+ * whole `?query` (issue #778 review — thread readiness.ts:1199, mirroring the {@link EMBEDDED_CREDENTIAL_SRC}
1271
+ * fix). `[^/?#]*@` and `[^ ]*` are separated by the literal `@`
1272
+ * anchor, so the match stays linear with no catastrophic backtracking. */
1273
+ const EMBEDDED_CREDENTIAL_URL_SRC = "\\/\\/[^/?#]*@[^ ]*";
1274
+
1275
+ /** Redact each embedded SCHEME-RELATIVE credential-URL (`//<userinfo>@authority…`) token IN PLACE via
1276
+ * {@link redactString} — stripping its `//user:pass@` userinfo AND any `?query`/`#fragment` — while
1277
+ * leaving opaque `//host#42` refs (no userinfo) untouched. Lets {@link redactConnectorValue} catch a
1278
+ * query/fragment secret riding a credential-bearing scheme-relative URL after a non-URL prefix
1279
+ * (`prefix //user:pass@host?token=secret`) that the anchored {@link isUrlShaped} whole-value check AND
1280
+ * the userinfo-only {@link redactEmbeddedCredential} both miss (issue #778 review — thread
1281
+ * deliveryGraph.ts:188). Deterministic and total. */
1282
+ export function redactEmbeddedCredentialUrl(s: string): string {
1283
+ return s.replace(new RegExp(EMBEDDED_CREDENTIAL_URL_SRC, "g"), (m) => redactString(m));
1284
+ }
1285
+
1286
+ /** The ONE canonical embedded ABSOLUTE-URL token: an EXPLICIT-scheme `scheme://authority…` run bounded
1287
+ * by whitespace. Deliberately requires an explicit `scheme:` before the `//` (NOT the scheme-relative
1288
+ * `//authority` {@link isUrlShaped} also accepts): an absolute URL's `?query`/`#fragment` are
1289
+ * UNAMBIGUOUSLY URL syntax and may hide a token, so they are safe to strip; a scheme-relative
1290
+ * `//host#42` (or an opaque `slack:#releases`, `owner/repo#42`) instead carries a MEANINGFUL opaque
1291
+ * `#`/`?` (a `parsePrTarget` PR ref) that must survive, so it is left to the userinfo-only
1292
+ * {@link redactEmbeddedCredential}. The scheme may be followed by XML-attribute whitespace
1293
+ * (`\s*` — TAB/LF/CR/space) BEFORE the `//` authority, exactly like {@link isUrlShaped}: those are valid
1294
+ * XML `Char`s that `stripXmlInvalidChars` does NOT remove, so an embedded `scheme<whitespace>//authority`
1295
+ * after a non-URL prefix (`prefix https:\t//user:pass@host?token=secret`) is not whole-value URL-shaped,
1296
+ * escapes the anchored {@link isUrlShaped} check, and — without this `\s*` — was missed here too, leaving
1297
+ * the fallback {@link redactEmbeddedCredential} to strip only the `//…@` userinfo and LEAK the
1298
+ * `?token=secret` tail. Keeping this token's `scheme:` + `\s*` + `//` shape aligned with `isUrlShaped`
1299
+ * closes that gap (issue #778 review — thread readiness.ts:1239). The token then runs to the next literal
1300
+ * (`[^ ]+`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR (0x09/0x0A/0x0D —
1301
+ * the only whitespace `stripXmlInvalidChars` keeps) and still redacts a `?query`/`#fragment` secret
1302
+ * sitting past it (`https://host/pa\tth?token=secret`); a `\s`-bounded token stopped at that internal
1303
+ * whitespace and leaked the tail (#778 review — thread deliveryGraph.ts:188). Over-redacting across an
1304
+ * internal newline is the safe direction. `\s*` and `[^ ]+` are separated by the literal `:`/`//`
1305
+ * anchors, so the match stays linear with no catastrophic backtracking. */
1306
+ const EMBEDDED_SCHEME_URL_SRC = "[a-z][a-z0-9+.-]*:\\s*\\/\\/[^ ]+";
1307
+
1308
+ /** Redact each embedded ABSOLUTE-URL (explicit `scheme://…`) token in a free-form value IN PLACE via
1309
+ * {@link redactString} — stripping that token's `user:pass@` userinfo AND `?query`/`#fragment` — while
1310
+ * leaving surrounding prose and any scheme-relative/opaque `//…`/`#`/`?` run untouched (those are
1311
+ * handled, credential-only, by {@link redactEmbeddedCredential}). Lets {@link redactConnectorValue}
1312
+ * catch a query/fragment secret riding an embedded absolute URL after a non-URL prefix
1313
+ * (`prefix https://host/path?token=secret`) that the anchored {@link isUrlShaped} whole-value check
1314
+ * misses (issue #778 review — thread deliveryGraph.ts:181). Deterministic and total. */
1315
+ export function redactEmbeddedUrl(s: string): string {
1316
+ return s.replace(new RegExp(EMBEDDED_SCHEME_URL_SRC, "gi"), (m) => redactString(m));
1317
+ }
1318
+
1319
+ /** The ONE canonical embedded SCHEME-RELATIVE URL token that carries a `?query`: a userinfo-LESS
1320
+ * `//authority…?query…` run (bounded by the next literal space). Distinct from the userinfo-bearing
1321
+ * {@link EMBEDDED_CREDENTIAL_URL_SRC} (which the `@` marks a URL) and the explicit-scheme
1322
+ * {@link EMBEDDED_SCHEME_URL_SRC}: a `senior:feature //host?token=secret` embedded after a non-URL prefix
1323
+ * has NO userinfo `@` and NO explicit `scheme:`, so the anchored {@link isUrlShaped} whole-value check,
1324
+ * {@link redactEmbeddedCredentialUrl}, and {@link redactEmbeddedUrl} ALL miss it — leaking the
1325
+ * `?token=secret` tail (issue #778 review — thread deliveryGraph.ts:633). A scheme-relative `//authority`
1326
+ * whose `?` is present is UNAMBIGUOUSLY URL syntax — an opaque ref (`owner/repo#42`, `//host#42`) uses a
1327
+ * `#<digits>` fragment (`parsePrTarget`), never a `//…?…` query — so its query is safe to strip while a
1328
+ * userinfo-less opaque `//host#42` (no `?`) is left to keep its meaningful `#42`. The token runs to the
1329
+ * next literal SPACE (`[^ ]*`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR
1330
+ * and still redacts a `?query` secret sitting past it. `[^ ]*` before/after the literal `?` anchor keeps
1331
+ * the match linear with no catastrophic backtracking. */
1332
+ const EMBEDDED_SCHEME_RELATIVE_URL_SRC = "\\/\\/[^ ]*\\?[^ ]*";
1333
+
1334
+ /** Redact each embedded SCHEME-RELATIVE URL (`//authority…?query…`, no userinfo, no explicit scheme)
1335
+ * token IN PLACE via {@link redactString} — stripping its `?query`/`#fragment` — while leaving a
1336
+ * userinfo-less opaque `//host#42` PR ref (no `?`) untouched. Lets {@link redactConnectorValue} catch a
1337
+ * query secret riding a scheme-relative URL after a non-URL prefix (`prefix //host?token=secret`) that
1338
+ * the anchored {@link isUrlShaped} check, {@link redactEmbeddedCredentialUrl}, and {@link redactEmbeddedUrl}
1339
+ * all miss (issue #778 review — thread deliveryGraph.ts:633). Deterministic and total. */
1340
+ export function redactEmbeddedSchemeRelativeUrl(s: string): string {
1341
+ return s.replace(new RegExp(EMBEDDED_SCHEME_RELATIVE_URL_SRC, "g"), (m) => redactString(m));
1342
+ }
1343
+
1344
+ /** The canonical scheme-relative `//` authority marker — the `//` that opens an authority in ANY URL
1345
+ * form (absolute `scheme://`, scheme-relative `//`, credential `//user@`). A worker-routing job type
1346
+ * never legitimately contains it. */
1347
+ const SCHEME_RELATIVE_AUTHORITY_SRC = "\\/\\/";
1348
+
1349
+ /** True when `value` embeds a scheme-relative `//authority` token (see {@link SCHEME_RELATIVE_AUTHORITY_SRC}).
1350
+ * A plain worker-routing job type / opaque id NEVER contains `//` — so a match (whether it carries
1351
+ * userinfo, an explicit scheme, a `?query`, a `#fragment`, or nothing) is illegitimate in an
1352
+ * `agent.jobType` and must be rejected, closing the userinfo-less scheme-relative gap
1353
+ * (`senior:feature //host?token=secret`) that {@link hasEmbeddedCredential} (needs `@`),
1354
+ * {@link hasEmbeddedUrl} (needs an explicit scheme), and the anchored {@link isUrlShaped} (whole-value
1355
+ * only) all miss (issue #778 review — thread deliveryGraph.ts:633). */
1356
+ export function hasSchemeRelativeAuthority(value: string): boolean {
1357
+ return new RegExp(SCHEME_RELATIVE_AUTHORITY_SRC).test(value);
1358
+ }
1359
+
1360
+ /** True when `value` embeds a `//<userinfo>@host` credential token (see {@link EMBEDDED_CREDENTIAL_SRC};
1361
+ * the userinfo colon is optional, so a passwordless `//token@host` bearer token also matches).
1362
+ * A plain worker-routing job type / opaque id never contains one, so a match is a credential leak to reject. */
1363
+ export function hasEmbeddedCredential(value: string): boolean {
1364
+ return new RegExp(EMBEDDED_CREDENTIAL_SRC).test(value);
1365
+ }
1366
+
1367
+ /** True when `value` embeds an absolute-URL token (`scheme://authority…`; see
1368
+ * {@link EMBEDDED_SCHEME_URL_SRC}). A plain worker-routing job type / opaque id never contains one, so a
1369
+ * match is a URL whose `?query`/`#fragment` can hide a token and which — since the executable
1370
+ * `<zeebe:taskDefinition type=…>` carries the job type VERBATIM — would leak into the compiled BPMN.
1371
+ * Distinct from {@link hasEmbeddedCredential}: that catches only a `//<userinfo>@` credential span and
1372
+ * misses a userinfo-less `senior:feature https://host?token=secret` (issue #778 review — thread
1373
+ * deliveryGraph.ts:602). */
1374
+ export function hasEmbeddedUrl(value: string): boolean {
1375
+ return new RegExp(EMBEDDED_SCHEME_URL_SRC, "i").test(value);
1376
+ }
1377
+
1378
+ /** The ONE canonical "is this value a URL?" classifier — a `scheme://authority` OR scheme-relative
1379
+ * `//authority` form, either of which can hide a credential in userinfo/query/fragment. It is the single
1380
+ * source of truth for URL-shape detection shared by the connector/jobType display redactor
1381
+ * ({@link redactString} callers in the compiler) AND the semantic validation boundary
1382
+ * (`validateDeliveryGraph`), so the "what counts as credential-bearing/URL-shaped" rule can never drift
1383
+ * between the redact path and the reject path (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1384
+ * Classify on the TRIMMED value so leading whitespace (` //user:pass@host` — the OpenAPI edge caps
1385
+ * length but does not trim) cannot bypass the anchored check. The scheme may be followed by
1386
+ * XML-attribute whitespace (TAB/LF/CR/space) before the `//` authority: those characters are valid XML
1387
+ * `Char`s that `stripXmlInvalidChars` does NOT remove, so `https:\t//user:pass@host` must still classify
1388
+ * as URL-shaped or its credential escapes redaction as "not a URL" (issue #778 review — thread
1389
+ * readiness.ts:1191). Deterministic and total. */
1390
+ export function isUrlShaped(value: string): boolean {
1391
+ return /^([a-z][a-z0-9+.-]*:\s*)?\/\//i.test(value.trim());
1166
1392
  }
package/app/service.ts CHANGED
@@ -32,7 +32,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
32
32
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
33
33
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
34
34
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
35
- import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
35
+ import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
36
36
  import { fleetSupportsDurableResume } from "./durableResume.ts";
37
37
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
38
38
  import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
@@ -3041,11 +3041,16 @@ export async function pollUserTasks(
3041
3041
  // A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
3042
3042
  // and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
3043
3043
  // context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
3044
- // surface seeds no form variables, the operator has no idea what the run is waiting on.
3044
+ // surface seeds no form variables, the operator has no idea what the run is waiting on. A URL the
3045
+ // author embedded in the node instruction is lifted onto `subject_url` so the row's "Link" column /
3046
+ // detail link is clickable (issue #813); a delivery run carries no other subject URL (`subj.url` is
3047
+ // null), so this is the only link a delivery human task can offer.
3048
+ let deliverySubjectUrl: string | null = null;
3045
3049
  if (question === null && isDeliveryHumanElement(elementId)) {
3046
3050
  question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
3051
+ deliverySubjectUrl = deliveryHumanContextUrl(subj?.humanLabels, elementId);
3047
3052
  }
3048
- return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
3053
+ return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? deliverySubjectUrl, question, processKey: processInstanceKey, formKey: resolvedFormKey };
3049
3054
  };
3050
3055
 
3051
3056
  // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a