@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/deliveryGraph.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
// caller can point the author straight at the offending input.
|
|
21
21
|
|
|
22
22
|
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
23
|
+
import { isEnvKey } from "./contracts.ts";
|
|
23
24
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
24
25
|
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
26
|
+
import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped, parsePrTarget, redactEmbeddedCredentialUrl, redactEmbeddedSchemeRelativeUrl, redactEmbeddedUrl, redactString } from "./readiness.ts";
|
|
25
27
|
import { isResolvableRepo } from "./repoEnvelope.ts";
|
|
26
28
|
|
|
27
29
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
@@ -88,6 +90,7 @@ export type DeliveryGraphErrorCode =
|
|
|
88
90
|
| "guard-default-conflict"
|
|
89
91
|
| "bad-when"
|
|
90
92
|
| "guard-type-mismatch"
|
|
93
|
+
| "guard-invalid-equals"
|
|
91
94
|
| "mixed-fan-out"
|
|
92
95
|
| "multiple-defaults"
|
|
93
96
|
| "non-exhaustive-split"
|
|
@@ -98,6 +101,13 @@ export type DeliveryGraphErrorCode =
|
|
|
98
101
|
| "converge-merge-type"
|
|
99
102
|
| "invalid-node-repository"
|
|
100
103
|
| "invalid-node-base-branch"
|
|
104
|
+
| "invalid-job-type"
|
|
105
|
+
| "url-shaped-job-type"
|
|
106
|
+
| "credential-in-job-type"
|
|
107
|
+
| "embedded-url-in-job-type"
|
|
108
|
+
| "scheme-relative-url-in-job-type"
|
|
109
|
+
| "invalid-credential-env"
|
|
110
|
+
| "invalid-backoff"
|
|
101
111
|
| "unbound-pr";
|
|
102
112
|
|
|
103
113
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
@@ -115,6 +125,111 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
115
125
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
/** Strip the characters XML 1.0's `Char` production forbids anywhere in a document: the C0 control
|
|
129
|
+
* characters (except tab `#x9`, LF `#xA`, CR `#xD`), the noncharacters U+FFFE/U+FFFF, and unpaired
|
|
130
|
+
* UTF-16 surrogates — none can be represented by an entity, so any of them in an element
|
|
131
|
+
* `name`/`documentation` makes `layoutBpmn`/deployment reject the whole semantic BPMN. VALID astral
|
|
132
|
+
* pairs are preserved; dropping an unrepresentable character is the only well-formed rendering.
|
|
133
|
+
* Canonical here (the low-level graph module) so both the validator (which must REJECT such a
|
|
134
|
+
* character in executable FEEL — see {@link hasXmlInvalidChars}) and the compiler (which strips it
|
|
135
|
+
* from DISPLAY text) share ONE character-class definition — no drift surface. Deterministic and
|
|
136
|
+
* total. */
|
|
137
|
+
export function stripXmlInvalidChars(value: string): string {
|
|
138
|
+
return (
|
|
139
|
+
value
|
|
140
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this IS the XML-1.0 control filter.
|
|
141
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
|
|
142
|
+
// Unpaired surrogates (a high not followed by a low, or a low not preceded by a high); valid pairs stay.
|
|
143
|
+
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
|
|
144
|
+
// XML-1.0 noncharacters just past the BMP `Char` range end (#xFFFD).
|
|
145
|
+
.replace(/[\uFFFE\uFFFF]/g, "")
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True when `value` contains any XML-1.0-forbidden character (see {@link stripXmlInvalidChars}).
|
|
150
|
+
* Defined in terms of the strip so detection and stripping can never disagree — a single source of
|
|
151
|
+
* truth for the character class. Used to REJECT such a character in an executable FEEL guard literal
|
|
152
|
+
* at validation time (rather than let the compiler's display-text sanitiser silently rewrite the
|
|
153
|
+
* guard and route the process down the wrong edge). Deterministic and total. */
|
|
154
|
+
export function hasXmlInvalidChars(value: string): boolean {
|
|
155
|
+
return stripXmlInvalidChars(value) !== value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Credential redaction for a free-form connector value (`target`, `dedupeKey`, a bound
|
|
159
|
+
* `payload.pr`) or an `agent.jobType` descriptor. Such a value is frequently an OPAQUE identifier —
|
|
160
|
+
* `slack:#releases`, `owner/repo#42`, a `<node>.pr` ref, `pkg@version` — in which `#`/`?`/`@` are
|
|
161
|
+
* MEANINGFUL, so blind {@link redactString} would mangle it (e.g. `slack:#releases` → `slack:#***`,
|
|
162
|
+
* `owner/repo#42` → `owner/repo#***`). Only a value that is actually a URL — a `scheme://authority` OR a
|
|
163
|
+
* scheme-relative `//authority` form ({@link isUrlShaped}), either of which can hide a credential in
|
|
164
|
+
* userinfo/query/fragment (`redactString` redacts both) — is redacted in FULL (userinfo AND
|
|
165
|
+
* `?query`/`#fragment`). But an embedded `//<userinfo>@` credential can also ride AFTER a non-URL
|
|
166
|
+
* prefix in a free-form value that later parsers accept (`parsePrTarget` takes any prefix before
|
|
167
|
+
* `#<digits>`, so `prefix //user:pass@host#42`), which the anchored {@link isUrlShaped} check misses.
|
|
168
|
+
* Because a `//<userinfo>@` span is UNAMBIGUOUSLY a credential wherever it sits (an opaque id never
|
|
169
|
+
* contains one), a scheme-relative `//<userinfo>@authority…` run is treated as an embedded URL and
|
|
170
|
+
* redacted in FULL — userinfo AND its `?query`/`#fragment` — via {@link redactEmbeddedCredentialUrl},
|
|
171
|
+
* since userinfo marks the run a URL whose `?`/`#` ARE URL syntax rather than a meaningful opaque token
|
|
172
|
+
* character; an opaque `//host#42` (no userinfo) keeps its `#42` (issue #778 review — threads
|
|
173
|
+
* deliveryGraph.ts:162/566, :188). An embedded ABSOLUTE URL
|
|
174
|
+
* (an explicit `scheme://…` token) after a non-URL prefix (`prefix https://host/path?token=secret`)
|
|
175
|
+
* likewise hides a `?query`/`#fragment` token the anchored whole-value {@link isUrlShaped} check
|
|
176
|
+
* misses; its `?`/`#` ARE URL syntax (explicit scheme), so redact that token in full via
|
|
177
|
+
* {@link redactEmbeddedUrl}, while a scheme-relative `//host#42` keeps its meaningful opaque `#42`
|
|
178
|
+
* (issue #778 review — thread deliveryGraph.ts:181). Canonical HERE (the low-level
|
|
179
|
+
* graph module, alongside {@link stripXmlInvalidChars} and the URL classifier it shares) so the
|
|
180
|
+
* compiler's DISPLAY path AND `validateDeliveryGraph`'s reject/error path use ONE redactor — no drift
|
|
181
|
+
* surface (issue #778 review — thread deliveryGraphCompiler.ts:1606). Deterministic and total. */
|
|
182
|
+
export function redactConnectorValue(value: string): string {
|
|
183
|
+
// Strip XML-invalid display characters BEFORE classifying/redacting: the anchored `^(scheme:)?//`
|
|
184
|
+
// check and the redaction both run on the exact string the renderer will emit. Otherwise a target
|
|
185
|
+
// prefixed by an unrepresentable control char (e.g. `\x01//user:pass@host/?token=…`) fails the
|
|
186
|
+
// anchored check, escapes redaction, then loses that prefix during `escapeXml`/`stripXmlInvalidChars`
|
|
187
|
+
// — surfacing the credential verbatim in the BPMN name/documentation and connector escalation FEEL.
|
|
188
|
+
const cleaned = stripXmlInvalidChars(value);
|
|
189
|
+
// A whole-value URL gets the full redact (userinfo + query/fragment) — EXCEPT a whole-value opaque
|
|
190
|
+
// scheme-relative PR ref (`//host#42`: no explicit scheme, no `//…@` credential, no `?query`), whose
|
|
191
|
+
// only URL-ish payload is a MEANINGFUL `#<digits>` fragment (a `parsePrTarget` PR ref). `isUrlShaped`
|
|
192
|
+
// matches such a ref (scheme-relative `//authority`), so the whole-value branch would `redactString`
|
|
193
|
+
// its `#42` → `#***` — yet the EMBEDDED contract deliberately PRESERVES the same `//host#42` (the
|
|
194
|
+
// embedded redactors leave a userinfo-/query-less `//host#42` untouched). Route it through the embedded
|
|
195
|
+
// path so a whole-value `//host#42` keeps its `#42` exactly like `prefix //host#42`, closing that
|
|
196
|
+
// whole-value/embedded inconsistency (issue #778 review — thread deliveryGraph.ts:198). Only a
|
|
197
|
+
// NUMERIC `#<digits>` fragment is a valid `parsePrTarget` PR handle — `//host#access-token` is an
|
|
198
|
+
// ordinary URL fragment that can hide a secret, so the exception REQUIRES `parsePrTarget` to accept
|
|
199
|
+
// the whole (trimmed) value; a non-numeric fragment falls to the full whole-value redact like any
|
|
200
|
+
// other URL fragment (issue #778 review — thread deliveryGraph.ts:206). An explicit
|
|
201
|
+
// `scheme://host#42`, a `//user:pass@host#…` credential, or a userinfo-less `//host?token=…` query all
|
|
202
|
+
// still fall to the full whole-value redact.
|
|
203
|
+
// Any non-URL value keeps its meaningful `#`/`?` opaque-token characters, but still has (a) each
|
|
204
|
+
// embedded ABSOLUTE-URL (`scheme://…`) token redacted in full — its `?query`/`#fragment` IS URL syntax
|
|
205
|
+
// — and (b) each embedded scheme-relative `//<userinfo>@authority…` credential-URL redacted in full
|
|
206
|
+
// (userinfo AND `?query`/`#fragment`), and (c) each embedded userinfo-LESS scheme-relative URL that
|
|
207
|
+
// carries a `?query` (`//host?token=secret`) redacted (its `?` is unambiguously URL syntax — an opaque
|
|
208
|
+
// PR ref uses a `#<digits>` fragment, never a `//…?…` query), while a userinfo-less opaque `//host#42`
|
|
209
|
+
// (no `?`) keeps its `#42` (issue #778 review — thread deliveryGraph.ts:633).
|
|
210
|
+
const isOpaqueSchemeRelativePrRef =
|
|
211
|
+
cleaned.trim().startsWith("//") &&
|
|
212
|
+
!hasEmbeddedCredential(cleaned) &&
|
|
213
|
+
!cleaned.includes("?") &&
|
|
214
|
+
parsePrTarget(cleaned) !== null;
|
|
215
|
+
return isUrlShaped(cleaned) && !isOpaqueSchemeRelativePrRef
|
|
216
|
+
? redactString(cleaned)
|
|
217
|
+
: redactEmbeddedSchemeRelativeUrl(redactEmbeddedCredentialUrl(redactEmbeddedUrl(cleaned)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** True when `value` contains a whitespace character that XML **attribute-value normalization**
|
|
221
|
+
* rewrites to a space (literal TAB `#x9`, LF `#xA`, or CR `#xD`). Such characters are perfectly
|
|
222
|
+
* valid XML `Char`s — so {@link hasXmlInvalidChars} does NOT flag them — yet when a value is emitted
|
|
223
|
+
* verbatim into a raw XML attribute (`<zeebe:taskDefinition type="…">`), a conforming parser folds
|
|
224
|
+
* each of them to a single space at deploy time. An executable value carrying one (e.g.
|
|
225
|
+
* `senior:\nfeature`) is therefore silently deployed as a DIFFERENT worker type (`senior: feature`),
|
|
226
|
+
* routing the cell to the wrong worker. Rejected — not normalized — for the same reason as
|
|
227
|
+
* {@link hasXmlInvalidChars}: an executable value must never be silently mutated. Deterministic and
|
|
228
|
+
* total. */
|
|
229
|
+
export function hasAttrNormalizedWhitespace(value: string): boolean {
|
|
230
|
+
return /[\t\n\r]/.test(value);
|
|
231
|
+
}
|
|
232
|
+
|
|
118
233
|
/** True when `kind` is a member of the closed allowlist. */
|
|
119
234
|
function isDeliveryNodeKind(kind: unknown): kind is DeliveryNodeKind {
|
|
120
235
|
if (typeof kind !== "string") return false;
|
|
@@ -372,7 +487,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
372
487
|
}
|
|
373
488
|
// A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
|
|
374
489
|
// end on the not-ready-at-boundary path, but the engine treats terminate-end events as
|
|
375
|
-
// parsed-not-executed (
|
|
490
|
+
// parsed-not-executed (nanobpm/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
|
|
376
491
|
// end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
|
|
377
492
|
// until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
|
|
378
493
|
// (default) and `continue` ARE honored.
|
|
@@ -385,6 +500,88 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
385
500
|
code: "unsupported-on-timeout",
|
|
386
501
|
});
|
|
387
502
|
}
|
|
503
|
+
// A `wait` node's `poll.backoff` names a policy `parseProbe`→`parsePoll` validates against the
|
|
504
|
+
// closed `BACKOFFS` enum at DISPATCH: an unrecognised value (`"linear"`) THROWS there. But the
|
|
505
|
+
// compiler's display/digest path calls `normalizePoll` on the RAW graph, which silently maps every
|
|
506
|
+
// unrecognised backoff to `exponential` (the default). With the doc suppressing a default backoff
|
|
507
|
+
// and `digestInvisibleRawValues` only fingerprinting XML-strip differences (`"linear"` is XML-
|
|
508
|
+
// clean), a `backoff:"linear"` graph shares the omitted/default-poll graph's semantic digest+run
|
|
509
|
+
// key — so keyless dispatch can short-circuit the malformed proposal onto a valid running instance
|
|
510
|
+
// and mark it DISPATCHED instead of rejecting it. Enforce the SAME `isBackoff` contract here at the
|
|
511
|
+
// semantic boundary — trimming first, exactly like `parsePoll` (`str(raw.backoff).trim()`), so a
|
|
512
|
+
// padded-but-valid `" fixed "` the runtime accepts is not false-rejected — so a malformed backoff
|
|
513
|
+
// fails loudly at the preview/stage door BEFORE defaulting can mask it (issue #778 review — thread
|
|
514
|
+
// readiness.ts:432, same class as the `credentialEnv` semantic-boundary checks below).
|
|
515
|
+
if (kind === "wait" && isRecord(config.poll) && config.poll.backoff !== undefined && config.poll.backoff !== null) {
|
|
516
|
+
const backoff = typeof config.poll.backoff === "string" ? config.poll.backoff.trim() : String(config.poll.backoff).trim();
|
|
517
|
+
if (backoff !== "" && !isBackoff(backoff)) {
|
|
518
|
+
errors.push({
|
|
519
|
+
path: `${path}.${configKey}.poll.backoff`,
|
|
520
|
+
message:
|
|
521
|
+
`\`wait.poll.backoff\` must be one of ${BACKOFFS.join(", ")} (got ${JSON.stringify(config.poll.backoff)}); ` +
|
|
522
|
+
"`parsePoll` rejects an unrecognised backoff at dispatch while the compiler would silently default " +
|
|
523
|
+
"it — colliding the malformed graph's digest with a valid default-poll graph — so reject it here " +
|
|
524
|
+
"at the semantic boundary rather than letting a malformed proposal stage and be marked dispatched",
|
|
525
|
+
code: "invalid-backoff",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// A wait probe's `credentialEnv` names a DECLARED env-contract KEY (the secret is read from the
|
|
530
|
+
// ambient env at execution time, never carried here) — but `parseProbe` only enforces that
|
|
531
|
+
// `isEnvKey` contract LATER, at dispatch. The compiler surfaces the `credentialEnv` value verbatim
|
|
532
|
+
// into the probe's `<bpmn:documentation>`, so a text-ingress graph could stage
|
|
533
|
+
// `credentialEnv: "an-actual-secret"` and expose it in the compiled BPMN the preview door returns
|
|
534
|
+
// BEFORE dispatch rejects it. Enforce the SAME `isEnvKey` contract here at the semantic boundary so
|
|
535
|
+
// a non-key value can never reach the compiler (issue #778 review — thread
|
|
536
|
+
// deliveryGraphCompiler.ts:1249). `parseProbe` ALSO rejects a `credentialEnv` on any non-`http`
|
|
537
|
+
// probe kind (the credential selects an HTTP Authorization header — no other kind reads it), but
|
|
538
|
+
// only LATER, at dispatch: a shape-valid `command`/`npm`/`github-check`/`capability` graph
|
|
539
|
+
// carrying a well-formed `credentialEnv` key stages successfully and then THROWS during dispatch
|
|
540
|
+
// instead of returning a compile-time validation error. Enforce the SAME `http`-only contract here
|
|
541
|
+
// at the semantic boundary so the mismatch surfaces as a 400 from the preview/stage door rather
|
|
542
|
+
// than a dispatch-time incident (issue #778 review — thread deliveryGraph.ts:474).
|
|
543
|
+
// A PRESENT `credentialEnv` that is neither null/undefined nor a string can never name an
|
|
544
|
+
// env-contract key: `parseProbe` coerces + rejects it at DISPATCH, so a shape-valid graph carrying
|
|
545
|
+
// e.g. `credentialEnv: 123` stages successfully and only fails later with an unlaunchable proposal.
|
|
546
|
+
// Reject a non-string here at the semantic boundary so the mismatch surfaces as a 400 from the
|
|
547
|
+
// preview/stage door (issue #778 review — suppressed advisory deliveryGraph.ts:473).
|
|
548
|
+
if (kind === "wait" && config.credentialEnv !== undefined && config.credentialEnv !== null && typeof config.credentialEnv !== "string") {
|
|
549
|
+
errors.push({
|
|
550
|
+
path: `${path}.${configKey}.credentialEnv`,
|
|
551
|
+
message:
|
|
552
|
+
"`wait.credentialEnv` must be a STRING naming a declared env-contract key; a non-string value " +
|
|
553
|
+
"can never resolve to a key and `parseProbe` rejects it at dispatch, so reject it here rather " +
|
|
554
|
+
"than let a shape-invalid graph stage and then throw during dispatch",
|
|
555
|
+
code: "invalid-credential-env",
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (kind === "wait" && typeof config.credentialEnv === "string" && config.credentialEnv.trim().length > 0) {
|
|
559
|
+
// Compare the TRIMMED value: `parseProbe` normalises `credentialEnv` with `.trim()` before its
|
|
560
|
+
// own `isEnvKey`/kind checks (readiness.ts:287), so validating the raw value here would
|
|
561
|
+
// false-reject a padded-but-valid `" GITHUB_TOKEN "` the runtime accepts — keep validation and
|
|
562
|
+
// execution in agreement (issue #778 review — suppressed advisory deliveryGraph.ts:466).
|
|
563
|
+
const credentialEnv = config.credentialEnv.trim();
|
|
564
|
+
if (!isEnvKey(credentialEnv)) {
|
|
565
|
+
errors.push({
|
|
566
|
+
path: `${path}.${configKey}.credentialEnv`,
|
|
567
|
+
message:
|
|
568
|
+
"`wait.credentialEnv` must name a DECLARED env-contract key (never a secret value — the " +
|
|
569
|
+
"credential is read from the ambient env at execution time); an undeclared key is rejected " +
|
|
570
|
+
"so a secret can never be smuggled into the compiled BPMN the preview door returns",
|
|
571
|
+
code: "invalid-credential-env",
|
|
572
|
+
});
|
|
573
|
+
} else if (typeof config.kind === "string" && config.kind.trim() !== "http") {
|
|
574
|
+
errors.push({
|
|
575
|
+
path: `${path}.${configKey}.credentialEnv`,
|
|
576
|
+
message:
|
|
577
|
+
`\`credentialEnv\` is only supported for the \`http\` wait kind (probe kind is ` +
|
|
578
|
+
`${JSON.stringify(config.kind)}); \`parseProbe\` rejects this combination at dispatch, so ` +
|
|
579
|
+
"reject it here at the semantic boundary rather than letting a shape-valid graph stage and " +
|
|
580
|
+
"then throw during dispatch",
|
|
581
|
+
code: "invalid-credential-env",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
388
585
|
// S5 (ADR 0006 §3): converge/merge are first-class, edge-gated CELL POLICY, not raw nodes.
|
|
389
586
|
// A raw converge/merge agent job (`senior:converge`, `senior:merge`, or a bare `converge`/
|
|
390
587
|
// `merge` verb) is retired as user-facing vocabulary — reject it at compile so "a raw converge
|
|
@@ -401,6 +598,133 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
401
598
|
code: "raw-converge-node",
|
|
402
599
|
});
|
|
403
600
|
}
|
|
601
|
+
// `agent.jobType` is baked VERBATIM into the executable `<zeebe:taskDefinition type=…>` attribute
|
|
602
|
+
// (and mirrored into `resolved.calledElement`), so — unlike a display string — the compiler must
|
|
603
|
+
// NOT let its attribute sanitiser silently strip an XML-1.0-invalid character out of it, NOR may
|
|
604
|
+
// it carry a whitespace character that XML attribute-value normalization folds to a space at
|
|
605
|
+
// deploy time: either would deploy a worker type differing from the authored job type (e.g.
|
|
606
|
+
// `senior:\u0001feature` → `senior:feature`, or `senior:\nfeature` → `senior: feature`), silently
|
|
607
|
+
// routing the cell to the wrong worker. Reject it here rather than rewrite/normalize an executable
|
|
608
|
+
// value (issue #778 review — same rationale as `guard-invalid-equals`).
|
|
609
|
+
if (
|
|
610
|
+
kind === "agent" &&
|
|
611
|
+
typeof config.jobType === "string" &&
|
|
612
|
+
(hasXmlInvalidChars(config.jobType) || hasAttrNormalizedWhitespace(config.jobType))
|
|
613
|
+
) {
|
|
614
|
+
errors.push({
|
|
615
|
+
path: `${path}.${configKey}.jobType`,
|
|
616
|
+
message:
|
|
617
|
+
`\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} contains a character that would be silently ` +
|
|
618
|
+
"rewritten when emitted as the executable `<zeebe:taskDefinition type=…>` attribute — an " +
|
|
619
|
+
"XML-1.0-invalid character (control characters, U+FFFE/U+FFFF, or an unpaired surrogate) that " +
|
|
620
|
+
"the sanitiser strips, or attribute whitespace (tab, LF, CR) that XML attribute-value " +
|
|
621
|
+
"normalization folds to a space — so it must be rejected rather than silently rewritten into " +
|
|
622
|
+
"a different (wrong) worker type",
|
|
623
|
+
code: "invalid-job-type",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
// A URL-shaped `agent.jobType` (`//user:pass@host`, `https://…`) is baked VERBATIM into the
|
|
627
|
+
// executable `<zeebe:taskDefinition type=…>` for worker routing, so — unlike a display string — it
|
|
628
|
+
// CANNOT be redacted in place (that would change the routing target). The compiler's display path
|
|
629
|
+
// redacts a URL-shaped jobType wherever it surfaces to an operator, but the raw executable value
|
|
630
|
+
// still lands in the compiled BPMN returned by the preview door, defeating that guarantee, and a
|
|
631
|
+
// URL is never a legitimate Zeebe routing key anyway. Reject it at the semantic boundary (the same
|
|
632
|
+
// "reject rather than rewrite an executable value" rule as `invalid-job-type` above) so a
|
|
633
|
+
// credential-bearing job type can never reach the compiler (issue #778 review — thread
|
|
634
|
+
// deliveryGraphCompiler.ts:1606). The message shows the REDACTED form so the 400 never echoes a
|
|
635
|
+
// credential.
|
|
636
|
+
if (kind === "agent" && typeof config.jobType === "string" && isUrlShaped(config.jobType)) {
|
|
637
|
+
errors.push({
|
|
638
|
+
path: `${path}.${configKey}.jobType`,
|
|
639
|
+
message:
|
|
640
|
+
`\`agent.jobType\` ${JSON.stringify(redactConnectorValue(config.jobType))} is URL-shaped — a URL is ` +
|
|
641
|
+
"never a valid worker-routing job type and, since the executable `<zeebe:taskDefinition type=…>` " +
|
|
642
|
+
"must carry it verbatim, a credential embedded in it would leak into the compiled BPMN. Use a " +
|
|
643
|
+
"plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a runtime " +
|
|
644
|
+
"job variable instead",
|
|
645
|
+
code: "url-shaped-job-type",
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
// The url-shaped check above is ANCHORED (a job type that STARTS with a URL). A credential-bearing
|
|
649
|
+
// URL can also be EMBEDDED after an otherwise plausible token (`senior:feature //user:pass@host`),
|
|
650
|
+
// separated by a plain space — which `hasAttrNormalizedWhitespace` (TAB/LF/CR only) and the anchored
|
|
651
|
+
// `isUrlShaped` both miss — so the `//user:pass@host` substring still lands verbatim in the
|
|
652
|
+
// executable `<zeebe:taskDefinition type=…>`. Reject ANY embedded `//…@` credential token (a
|
|
653
|
+
// routing key never contains one) at the same trust boundary via {@link hasEmbeddedCredential} — the
|
|
654
|
+
// SAME whitespace-tolerant `//…@` span the display redactor strips, so a userinfo carrying a literal
|
|
655
|
+
// space (`//user:secret pass@host`) can neither slip past this reject nor escape the display redact
|
|
656
|
+
// (issue #778 review — thread deliveryGraph.ts:550/570). Message redacted.
|
|
657
|
+
if (kind === "agent" && typeof config.jobType === "string" && hasEmbeddedCredential(config.jobType)) {
|
|
658
|
+
errors.push({
|
|
659
|
+
path: `${path}.${configKey}.jobType`,
|
|
660
|
+
message:
|
|
661
|
+
`\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} embeds a ` +
|
|
662
|
+
"credential-bearing URL userinfo token (`//<userinfo>@host` — the userinfo need not contain a " +
|
|
663
|
+
"colon; a passwordless `//token@host` bearer token counts) that a plain worker-routing job type " +
|
|
664
|
+
"never contains and that would land verbatim in the executable `<zeebe:taskDefinition type=…>`, " +
|
|
665
|
+
"leaking the credential into the compiled BPMN. Use a plain job-type token and carry any " +
|
|
666
|
+
"endpoint/credential as a runtime job variable instead",
|
|
667
|
+
code: "credential-in-job-type",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
// The two checks above catch a WHOLE-value URL (anchored `isUrlShaped`) and a `//<userinfo>@`
|
|
671
|
+
// credential token, but a userinfo-LESS embedded absolute URL slips both: `senior:feature
|
|
672
|
+
// https://host/path?token=secret` starts with a plausible token (so the anchored check misses it)
|
|
673
|
+
// and has no `//…@` (so `hasEmbeddedCredential` misses it), yet its `?token=secret` query rides an
|
|
674
|
+
// explicit-scheme URL and, since the executable `<zeebe:taskDefinition type=…>` carries `jobType`
|
|
675
|
+
// VERBATIM, persists in the compiled BPMN even though `nodeDisplay` redacts the operator-visible
|
|
676
|
+
// descriptor. Reject ANY embedded absolute-URL token (a routing key never contains one) at this
|
|
677
|
+
// same trust boundary via {@link hasEmbeddedUrl} — the SAME `scheme://…` span the display redactor
|
|
678
|
+
// ({@link redactEmbeddedUrl}) strips (issue #778 review — thread deliveryGraph.ts:602). Message
|
|
679
|
+
// redacted.
|
|
680
|
+
if (kind === "agent" && typeof config.jobType === "string" && hasEmbeddedUrl(config.jobType)) {
|
|
681
|
+
errors.push({
|
|
682
|
+
path: `${path}.${configKey}.jobType`,
|
|
683
|
+
message:
|
|
684
|
+
`\`agent.jobType\` ${JSON.stringify(redactConnectorValue(config.jobType))} embeds an absolute URL ` +
|
|
685
|
+
"(`scheme://…`) whose `?query`/`#fragment` can hide a credential token that a plain " +
|
|
686
|
+
"worker-routing job type never contains and that, since the executable " +
|
|
687
|
+
"`<zeebe:taskDefinition type=…>` carries the job type verbatim, would land in the compiled BPMN. " +
|
|
688
|
+
"Use a plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a " +
|
|
689
|
+
"runtime job variable instead",
|
|
690
|
+
code: "embedded-url-in-job-type",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
// The three checks above catch a whole-value URL (anchored `isUrlShaped`), a `//<userinfo>@`
|
|
694
|
+
// credential token, and an explicit-scheme `scheme://…` token — but a userinfo-LESS,
|
|
695
|
+
// schemeless embedded scheme-relative URL slips all three: `senior:feature //host?token=secret`
|
|
696
|
+
// starts with a plausible token (anchored `isUrlShaped` misses it), has no `//…@`
|
|
697
|
+
// (`hasEmbeddedCredential` misses it), and no explicit `scheme:` before the `//`
|
|
698
|
+
// (`hasEmbeddedUrl` misses it), yet its `?token=secret` query rides a `//host` authority and,
|
|
699
|
+
// since the executable `<zeebe:taskDefinition type=…>` carries the job type VERBATIM, persists in
|
|
700
|
+
// the compiled BPMN. A worker-routing job type has no legitimate need for `//` at all, so reject
|
|
701
|
+
// ANY embedded scheme-relative `//` authority token via {@link hasSchemeRelativeAuthority} — the
|
|
702
|
+
// residual gap the three checks above leave (issue #778 review — thread deliveryGraph.ts:633).
|
|
703
|
+
// Message redacted via `redactString` (strips `//…@` userinfo AND `?query`/`#fragment`) so the
|
|
704
|
+
// 400 never echoes a credential — `redactConnectorValue` also now redacts this token, but a
|
|
705
|
+
// schemeless `//host` bare authority would still surface here, so use the total `redactString`.
|
|
706
|
+
// Gated behind the three checks above (each of which already REJECTS its shape with a tailored
|
|
707
|
+
// code) so a value they catch is not double-reported — this fires ONLY for the residual
|
|
708
|
+
// userinfo-less, schemeless embedded scheme-relative URL they all miss.
|
|
709
|
+
if (
|
|
710
|
+
kind === "agent" &&
|
|
711
|
+
typeof config.jobType === "string" &&
|
|
712
|
+
!isUrlShaped(config.jobType) &&
|
|
713
|
+
!hasEmbeddedCredential(config.jobType) &&
|
|
714
|
+
!hasEmbeddedUrl(config.jobType) &&
|
|
715
|
+
hasSchemeRelativeAuthority(config.jobType)
|
|
716
|
+
) {
|
|
717
|
+
errors.push({
|
|
718
|
+
path: `${path}.${configKey}.jobType`,
|
|
719
|
+
message:
|
|
720
|
+
`\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} embeds a scheme-relative ` +
|
|
721
|
+
"URL authority (`//…`) — a routing key never contains `//`, and its `?query`/`#fragment` can hide a credential " +
|
|
722
|
+
"token that, since the executable `<zeebe:taskDefinition type=…>` carries the job type verbatim, would land in the " +
|
|
723
|
+
"compiled BPMN. Use a plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a runtime " +
|
|
724
|
+
"job variable instead",
|
|
725
|
+
code: "scheme-relative-url-in-job-type",
|
|
726
|
+
});
|
|
727
|
+
}
|
|
404
728
|
// S5 trust boundary: `validateDeliveryGraph` is the gate before `dispatchDeliveryGraphRun`
|
|
405
729
|
// narrows `graph: unknown` with `as DeliveryGraph`, so a graph that bypassed OpenAPI validation
|
|
406
730
|
// must not smuggle a non-boolean `converge`/`merge` past the policy checks below (which compare
|
|
@@ -925,6 +1249,22 @@ function validateGuardedEdges(
|
|
|
925
1249
|
});
|
|
926
1250
|
continue;
|
|
927
1251
|
}
|
|
1252
|
+
// A string `equals` is baked VERBATIM into the compiled `<bpmn:conditionExpression>` FEEL literal,
|
|
1253
|
+
// so an XML-1.0-invalid character in it (a C0 control, U+FFFE/U+FFFF, a lone surrogate) cannot be
|
|
1254
|
+
// entity-escaped and the compiler's display-text sanitiser would silently STRIP it — mutating
|
|
1255
|
+
// executable FEEL (e.g. `"a\uFFFEb"` → `"ab"`) and potentially routing the split down the wrong
|
|
1256
|
+
// edge. Reject it here rather than silently rewrite the guard (issue #778 review).
|
|
1257
|
+
if (typeof e.equals === "string" && hasXmlInvalidChars(e.equals)) {
|
|
1258
|
+
errors.push({
|
|
1259
|
+
path: `${path}.equals`,
|
|
1260
|
+
message:
|
|
1261
|
+
`guard \`equals\` for "${whenStr}" contains XML-1.0-invalid characters (control characters, ` +
|
|
1262
|
+
"U+FFFE/U+FFFF, or an unpaired surrogate) that cannot be represented in the compiled FEEL " +
|
|
1263
|
+
"condition — remove them rather than let the guard be silently rewritten",
|
|
1264
|
+
code: "guard-invalid-equals",
|
|
1265
|
+
});
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
928
1268
|
guardFactTypeByIndex.set(e.index, factType);
|
|
929
1269
|
}
|
|
930
1270
|
|
|
@@ -1215,3 +1555,18 @@ export function resolveDeliveryFrom(
|
|
|
1215
1555
|
const { nodeId, fact } = resolveFrom(from, nodeFacts);
|
|
1216
1556
|
return fact !== undefined ? { nodeId, fact } : { nodeId };
|
|
1217
1557
|
}
|
|
1558
|
+
|
|
1559
|
+
/** A DETERMINISTIC canonical JSON serialization: object keys sorted recursively while ARRAY order is
|
|
1560
|
+
* preserved (object key order and insignificant whitespace are not semantic). Two byte-different-but-
|
|
1561
|
+
* equivalent encodings (reordered keys, reflowed whitespace) serialize identically, while any genuine
|
|
1562
|
+
* value difference still diverges. Shared single source of truth (issue #778 review): the dispatch
|
|
1563
|
+
* run-key hashes it and the compiler's `payload` disambiguator canonicalises with it, so neither can
|
|
1564
|
+
* drift from the other. */
|
|
1565
|
+
export function canonicalJson(value: unknown): string {
|
|
1566
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
1567
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
1568
|
+
return `{${Object.entries(value)
|
|
1569
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1570
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)
|
|
1571
|
+
.join(",")}}`;
|
|
1572
|
+
}
|