@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
package/app/deliveryHuman.ts
CHANGED
|
@@ -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
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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:
|
|
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
|
};
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -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
|
-
// `
|
|
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
|
package/app/readiness.test.ts
CHANGED
|
@@ -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 === "" ?
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
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 ??
|
|
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
|