@nanobpm/nano-workforce 0.190.0 → 0.191.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +355 -0
- package/app/deliveryGraphCompiler.test.ts +1465 -3
- package/app/deliveryGraphCompiler.ts +833 -36
- package/app/deliveryGraphDispatch.test.ts +118 -2
- package/app/deliveryGraphDispatch.ts +138 -10
- package/app/deliveryGraphProposals.test.ts +243 -0
- package/app/deliveryGraphProposals.ts +130 -56
- package/app/deliveryGraphRun.test.ts +42 -2
- package/app/deliveryGraphRun.ts +46 -6
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
- package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
package/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
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Delivery Graphs: persist a LOSSLESS graph-identity fingerprint per run in a dedicated SIDE TABLE
|
|
2
|
+
-- (issue #778 review — thread dispatchDeliveryGraph.ts:332).
|
|
3
|
+
--
|
|
4
|
+
-- A run is keyed by `run_key` (an explicit operator `idempotencyKey`, else the content `digest`). The
|
|
5
|
+
-- `digest` is content-addressed over the REDACTED `semanticBpmn`, so two graphs differing ONLY in a
|
|
6
|
+
-- redacted-away secret (a URL credential, a `command` target, a match secret, a free-form connector
|
|
7
|
+
-- `payload`, …) SHARE one digest. When an operator supplies an explicit `idempotencyKey`, a re-dispatch
|
|
8
|
+
-- short-circuits onto the existing running row — but the lossy `digest` on that row cannot prove the
|
|
9
|
+
-- short-circuited run is THIS exact secret-bearing graph vs. a credential-different graph re-staged under
|
|
10
|
+
-- the same key. The dispatch door therefore refused EVERY such short-circuit with 409, which also broke
|
|
11
|
+
-- the idempotency contract for a legitimate SAME-payload retry (a lost response / double-click of the
|
|
12
|
+
-- identical proposal): it received 409 and lingered `staged` instead of short-circuiting successfully.
|
|
13
|
+
--
|
|
14
|
+
-- `graph_fingerprint` stores the run's lossless content identity — `sha256(digest \0
|
|
15
|
+
-- canonicalJson(digestInvisibleRawValues(graph)))` (the SAME identity `stableProposalRunKey` folds into
|
|
16
|
+
-- the keyless run key) — captured at launch. On an explicit-key short-circuit the door compares the
|
|
17
|
+
-- incoming graph's fingerprint against the stored one: a MATCH is a same-payload retry (allow the
|
|
18
|
+
-- short-circuit + consume the proposal); a MISMATCH — or a MISSING row (a run that predates this table)
|
|
19
|
+
-- — is unprovable, so the door refuses (the safe pre-existing 409 behaviour) and it self-heals once the
|
|
20
|
+
-- run turns over.
|
|
21
|
+
--
|
|
22
|
+
-- WHY A SIDE TABLE, not a column on `delivery_graph_runs`: that base table is projected COLUMN-FOR-COLUMN
|
|
23
|
+
-- by several drift-guarded surfaces — the `delivery_graph_runs__units` compat VIEW served from the
|
|
24
|
+
-- `delivery_units` aggregate (091/098) and the `delivery_graph_read_model`/`__tracking` VIEWs (087/096),
|
|
25
|
+
-- each of which asserts full base-column pass-through parity. This identity fingerprint is an INTERNAL
|
|
26
|
+
-- dispatch concern surfaced to no read model, so it belongs off to the side rather than threaded through
|
|
27
|
+
-- (and re-drift-guarded across) every projection.
|
|
28
|
+
--
|
|
29
|
+
-- Forward-only, additive (expand). The runner wraps each file in its own transaction — no BEGIN/COMMIT.
|
|
30
|
+
-- Numbered after 112 (the current highest on origin/main; this branch forked before 103–112 and must not
|
|
31
|
+
-- reuse a prefix main already occupies).
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS delivery_graph_run_identity (
|
|
34
|
+
run_key TEXT PRIMARY KEY,
|
|
35
|
+
graph_fingerprint TEXT NOT NULL,
|
|
36
|
+
created_at TEXT NOT NULL
|
|
37
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- A MONOTONIC per-write stage revision for delivery-graph proposals (issue #778 review, thread
|
|
2
|
+
-- app/deliveryGraphProposals.ts:245). The supersede reconcile ("exactly one live proposal per logical
|
|
3
|
+
-- graph, the LATEST stage wins") breaks a same-millisecond `updated_at` tie between two staged siblings
|
|
4
|
+
-- with a deterministic secondary key. That key used to be SQLite's `rowid` (insertion order) — but a
|
|
5
|
+
-- RE-STAGE of an existing digest is an `UPDATE` that bumps `updated_at` while LEAVING the `rowid`
|
|
6
|
+
-- unchanged, so a re-stage that lands in the same millisecond as a different digest's fresh stage could
|
|
7
|
+
-- be selected as the OLDER row (its stale, lower rowid) and be immediately superseded, inverting the
|
|
8
|
+
-- "latest stage wins" invariant this tie-break exists to uphold.
|
|
9
|
+
--
|
|
10
|
+
-- `stage_seq` fixes that: it is reassigned to `MAX(stage_seq)+1` on EVERY stage write — a fresh insert
|
|
11
|
+
-- AND a re-stage update alike — so the most-recently-written row always holds the strictly-highest value
|
|
12
|
+
-- regardless of its rowid or a coincident `updated_at`. Additive/expand (nullable-equivalent `DEFAULT 0`,
|
|
13
|
+
-- no data rewrite): existing rows read as `0` and are re-sequenced the next time they are staged.
|
|
14
|
+
ALTER TABLE delivery_graph_proposals ADD COLUMN stage_seq INTEGER NOT NULL DEFAULT 0;
|
|
15
|
+
|
|
16
|
+
-- Index `stage_seq` so the atomic `MAX(stage_seq)+1` allocation in `stageProposal` (issue #778 review,
|
|
17
|
+
-- thread app/deliveryGraphProposals.ts:237) is an O(1) reverse-index seek of the single highest value
|
|
18
|
+
-- rather than a full scan of the retained proposal history (staged + terminal rows accumulate; a bare
|
|
19
|
+
-- aggregate would grow linearly with them).
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_graph_proposals_stage_seq
|
|
21
|
+
ON delivery_graph_proposals (stage_seq);
|
|
@@ -13,8 +13,10 @@ import { join, resolve } from "node:path";
|
|
|
13
13
|
import { after, describe, test } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
15
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
16
|
+
import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
|
|
16
17
|
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
17
18
|
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
19
|
+
import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
|
|
18
20
|
|
|
19
21
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
20
22
|
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
@@ -32,6 +34,74 @@ const SIDE_EFFECTING = {
|
|
|
32
34
|
edges: [{ from: "open-b", to: "publish" }],
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
test("stableProposalRunKey: digest identity + secret disambiguation (issue #778 review)", () => {
|
|
38
|
+
// The run key is derived from the proposal's semantic `digest` (which the compiler already normalises
|
|
39
|
+
// — collapsing omitted-vs-default fields and top-level node/edge REORDER) PLUS a fingerprint of the
|
|
40
|
+
// digest-invisible raw values. So a re-stage of the SAME logical graph in ANY encoding sharing the
|
|
41
|
+
// digest short-circuits, while a credential-differing graph (same digest, different secret) diverges.
|
|
42
|
+
const D1 = "sha-aaa";
|
|
43
|
+
const D2 = "sha-bbb";
|
|
44
|
+
const credA = {
|
|
45
|
+
name: "g",
|
|
46
|
+
nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }],
|
|
47
|
+
edges: [],
|
|
48
|
+
};
|
|
49
|
+
// Same digest + identical graph → same key (a double-click / re-dispatch short-circuits).
|
|
50
|
+
assert.equal(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credA));
|
|
51
|
+
// Same digest but a DIFFERENT redacted-away credential — both redact to the same `//***@host` display
|
|
52
|
+
// so they SHARE the digest — must yield a DIFFERENT key, else the second dispatch collapses onto the
|
|
53
|
+
// first's still-running instance.
|
|
54
|
+
const credB = { name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] };
|
|
55
|
+
assert.notEqual(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credB));
|
|
56
|
+
// A genuinely different semantic graph carries a DIFFERENT digest → a different key even with no secrets.
|
|
57
|
+
const plain = { name: "g", nodes: [{ id: "n", kind: "human", human: { prompt: "x" } }], edges: [] };
|
|
58
|
+
assert.notEqual(stableProposalRunKey(D1, plain), stableProposalRunKey(D2, plain));
|
|
59
|
+
// A graph with NO digest-invisible content has an EMPTY fingerprint, so its key is a pure function of
|
|
60
|
+
// the digest: a re-stage in a DIFFERENT object-key encoding (same digest) short-circuits.
|
|
61
|
+
const plainReordered = { edges: [], name: "g", nodes: [{ human: { prompt: "x" }, kind: "human", id: "n" }] };
|
|
62
|
+
assert.equal(stableProposalRunKey(D1, plain), stableProposalRunKey(D1, plainReordered));
|
|
63
|
+
assert.ok(stableProposalRunKey(D1, credA).startsWith("staged-"), "the key is self-describing");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stableProposalRunKey: node-ORDER of digest-invisible content does not fork the key (issue #778 review)", () => {
|
|
67
|
+
// The compiler SORTS nodes before emitting `semanticBpmn`, so two graphs differing ONLY in top-level
|
|
68
|
+
// node order share one digest. Their digest-invisible raw values (`digestInvisibleRawValues`) must
|
|
69
|
+
// therefore fingerprint identically too — else a re-stage of the same logical graph in a different
|
|
70
|
+
// node encoding forks a DISTINCT run-key and double-launches instead of short-circuiting. The
|
|
71
|
+
// fingerprint is code-unit sorted at its source, making it reorder-invariant like the digest.
|
|
72
|
+
const D = "sha-order";
|
|
73
|
+
const ab = {
|
|
74
|
+
name: "g",
|
|
75
|
+
nodes: [
|
|
76
|
+
{ id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
|
|
77
|
+
{ id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
|
|
78
|
+
],
|
|
79
|
+
edges: [],
|
|
80
|
+
};
|
|
81
|
+
const ba = {
|
|
82
|
+
name: "g",
|
|
83
|
+
nodes: [
|
|
84
|
+
{ id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
|
|
85
|
+
{ id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
|
|
86
|
+
],
|
|
87
|
+
edges: [],
|
|
88
|
+
};
|
|
89
|
+
assert.equal(stableProposalRunKey(D, ab), stableProposalRunKey(D, ba), "a top-level node reorder must not fork the run-key");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("isEquivalentReStage: only a live staged row whose RAW graph JSON is byte-identical to the launched graph is an equivalent re-stage — a credential-different re-stage (same digest) is NOT, and a retired/consumed row (null) is NOT (issue #778 review — thread dispatchDeliveryGraph.ts:320)", () => {
|
|
93
|
+
const launched = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }], edges: [] });
|
|
94
|
+
// A byte-identical re-stage IS equivalent — the door consumes the duplicate at its bumped revision.
|
|
95
|
+
assert.equal(isEquivalentReStage({ graph: launched }, launched), true);
|
|
96
|
+
// A credential-different re-stage shares the redacted digest but its RAW graph differs, so it is a
|
|
97
|
+
// newer, never-launched revision the door must leave `staged`.
|
|
98
|
+
const credDifferent = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] });
|
|
99
|
+
assert.equal(isEquivalentReStage({ graph: credDifferent }, launched), false);
|
|
100
|
+
// No live staged row (consumed/dismissed/superseded/expired) → not equivalent, nothing to consume.
|
|
101
|
+
assert.equal(isEquivalentReStage(null, launched), false);
|
|
102
|
+
assert.equal(isEquivalentReStage(undefined, launched), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
35
105
|
describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest", () => {
|
|
36
106
|
const dirs: string[] = [];
|
|
37
107
|
const apps: TestApp[] = [];
|
|
@@ -165,6 +235,138 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
165
235
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
166
236
|
});
|
|
167
237
|
|
|
238
|
+
test("a SAME-payload retry of a secret-bearing graph under the SAME idempotencyKey (a lost response / double-click) → 202 short-circuit, the proposal IS consumed and NO second run launches — the persisted identity fingerprint PROVES the running run is this exact graph, so the idempotency contract holds instead of a spurious 409 (issue #778 review — thread dispatchDeliveryGraph.ts:332)", async () => {
|
|
239
|
+
const app = await boot();
|
|
240
|
+
assert.ok(app.api);
|
|
241
|
+
const api = app.api;
|
|
242
|
+
let agentFired = 0;
|
|
243
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
244
|
+
agentFired++;
|
|
245
|
+
return {};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Stage + dispatch a SECRET-BEARING graph (SIDE_EFFECTING carries redacted-away secret material —
|
|
249
|
+
// the same graph the cross-graph 409 case above refuses) under an explicit idempotencyKey. It
|
|
250
|
+
// launches and parks on its human node, so its run stays `running`.
|
|
251
|
+
const first = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
252
|
+
const dispatched = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
253
|
+
body: { digest: first.body.digest, idempotencyKey: "retry-key", repoless: true },
|
|
254
|
+
});
|
|
255
|
+
assert.equal(dispatched.status, 202);
|
|
256
|
+
await app.settle();
|
|
257
|
+
assert.equal(agentFired, 1, "the side effect fired exactly once on the first dispatch");
|
|
258
|
+
assert.equal((await deliveryGraphProposals(app.db).get(first.body.digest))?.status, "dispatched");
|
|
259
|
+
|
|
260
|
+
// Re-stage the IDENTICAL graph (byte-for-byte the same → same digest) and re-dispatch under the
|
|
261
|
+
// SAME key — the caller retrying a lost/duplicated request. The running run's persisted fingerprint
|
|
262
|
+
// matches this graph, so the door short-circuits onto it (202, alreadyRunning) and CONSUMES the
|
|
263
|
+
// re-staged proposal — it does NOT 409 as a cross-graph collision would.
|
|
264
|
+
const restaged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
265
|
+
assert.equal(restaged.body.digest, first.body.digest);
|
|
266
|
+
assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "staged");
|
|
267
|
+
const retry = await api.call<{ ok: boolean; status: string; alreadyRunning?: boolean }>("dispatchDeliveryGraph", {
|
|
268
|
+
body: { digest: restaged.body.digest, idempotencyKey: "retry-key", repoless: true },
|
|
269
|
+
});
|
|
270
|
+
assert.equal(retry.status, 202);
|
|
271
|
+
assert.equal(retry.body.ok, true);
|
|
272
|
+
assert.equal(retry.body.alreadyRunning, true);
|
|
273
|
+
await app.settle();
|
|
274
|
+
// The retry short-circuited onto the existing run — the side effect did NOT fire a second time and
|
|
275
|
+
// no second run launched.
|
|
276
|
+
assert.equal(agentFired, 1, "a same-payload retry does not re-fire the side effect");
|
|
277
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
278
|
+
// The re-staged proposal was consumed (marked dispatched), not left lingering.
|
|
279
|
+
assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "dispatched");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("an explicit idempotencyKey short-circuit onto a running SECRET-BEARING run, re-dispatched from a graph authored AS the redacted form (graphCarriesRedactedSecrets === false) that SHARES the digest → 409; the proposal is NOT consumed (the refusal must not be gated on the INCOMING graph's redaction side — identity is unprovable whenever !identityConfirmed) (thread dispatchDeliveryGraph.ts:320)", async () => {
|
|
283
|
+
const app = await boot();
|
|
284
|
+
assert.ok(app.api);
|
|
285
|
+
const api = app.api;
|
|
286
|
+
|
|
287
|
+
// Graph 1 CARRIES a redacted-away credential in its human prompt — its content-addressed digest is
|
|
288
|
+
// over the REDACTED display, so it is NOT a faithful identity. It parks on its human node and stays
|
|
289
|
+
// running under an explicit idempotencyKey.
|
|
290
|
+
const secretPrompt = "click done: https://user:pass@host";
|
|
291
|
+
const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
|
|
292
|
+
// Graph 2 is authored LITERALLY as the redacted form of graph 1's prompt. Redaction is a no-op on it,
|
|
293
|
+
// so `graphCarriesRedactedSecrets` is FALSE, yet it SHARES graph 1's digest (both digest over the same
|
|
294
|
+
// redacted display). It is a DIFFERENT graph from the running secret-bearing run.
|
|
295
|
+
const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
|
|
296
|
+
|
|
297
|
+
// Stage + dispatch the SECRET-bearing graph FIRST (before the twin re-stages over its same-digest
|
|
298
|
+
// row) under an explicit key — it launches and parks (running), stamping its LOSSLESS fingerprint.
|
|
299
|
+
const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
300
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
301
|
+
body: { digest: g1.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
302
|
+
});
|
|
303
|
+
assert.equal(first.status, 202);
|
|
304
|
+
await app.settle();
|
|
305
|
+
assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
|
|
306
|
+
|
|
307
|
+
// Now re-stage the redacted-twin at the SAME digest (a DIFFERENT graph re-staged over the consumed
|
|
308
|
+
// row) and dispatch it under the SAME key. It short-circuits onto the still-running SECRET run — but
|
|
309
|
+
// that run is a different graph, so its persisted fingerprint does NOT match (identityConfirmed ===
|
|
310
|
+
// false). The digest COLLISION is the crux: the twin shares graph 1's digest yet carries no redacted
|
|
311
|
+
// secret. The refusal must fire even though the INCOMING graph carries no redacted secret; otherwise
|
|
312
|
+
// the twin's proposal is marked `dispatched` while it never launched.
|
|
313
|
+
const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
|
|
314
|
+
assert.equal(twin.body.digest, g1.body.digest);
|
|
315
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
316
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
317
|
+
body: { digest: twin.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
318
|
+
});
|
|
319
|
+
assert.equal(second.status, 409);
|
|
320
|
+
assert.equal(second.body.ok, false);
|
|
321
|
+
await app.settle();
|
|
322
|
+
// The twin proposal is still staged — it was never launched (the running run is the secret graph).
|
|
323
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
324
|
+
// Only the single secret run exists — the twin dispatch launched nothing.
|
|
325
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a KEYLESS short-circuit onto a running graph whose run key equals the shared digest (an earlier explicit `idempotencyKey === digest`), re-dispatched from a redacted-twin that SHARES the digest → 409; the proposal is NOT consumed. The ambiguous-identity refusal must gate EVERY `alreadyRunning && !identityConfirmed`, not only explicit-key requests — a keyless faithful-digest dispatch collides on the digest run key just the same (thread dispatchDeliveryGraph.ts:325)", async () => {
|
|
329
|
+
const app = await boot();
|
|
330
|
+
assert.ok(app.api);
|
|
331
|
+
const api = app.api;
|
|
332
|
+
|
|
333
|
+
// Graph 1 CARRIES a redacted-away credential — its digest is over the REDACTED display, NOT a faithful
|
|
334
|
+
// identity. Dispatch it under an explicit `idempotencyKey` set to its OWN digest, so the run is keyed
|
|
335
|
+
// by the digest itself and parks running, stamping graph 1's LOSSLESS fingerprint.
|
|
336
|
+
const secretPrompt = "click done: https://user:pass@host";
|
|
337
|
+
const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
|
|
338
|
+
// Graph 2 is authored LITERALLY as the redacted form — redaction is a no-op on it, so
|
|
339
|
+
// `graphCarriesRedactedSecrets` is FALSE. Because it is faithful, a KEYLESS dispatch leaves the run key
|
|
340
|
+
// undefined and the core falls back to the digest — which it SHARES with graph 1, colliding onto that
|
|
341
|
+
// still-running secret run even though NO explicit key was supplied.
|
|
342
|
+
const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
|
|
343
|
+
|
|
344
|
+
const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
345
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
346
|
+
body: { digest: g1.body.digest, idempotencyKey: g1.body.digest, repoless: true },
|
|
347
|
+
});
|
|
348
|
+
assert.equal(first.status, 202);
|
|
349
|
+
await app.settle();
|
|
350
|
+
assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
|
|
351
|
+
|
|
352
|
+
// Re-stage the twin at the SAME digest and dispatch it WITHOUT a key. It short-circuits onto the
|
|
353
|
+
// running secret run (same digest run key), but that run is a different graph → identityConfirmed is
|
|
354
|
+
// false. The refusal must fire despite the request being keyless; otherwise the twin is marked
|
|
355
|
+
// `dispatched` while it never launched.
|
|
356
|
+
const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
|
|
357
|
+
assert.equal(twin.body.digest, g1.body.digest);
|
|
358
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
359
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
360
|
+
body: { digest: twin.body.digest, repoless: true },
|
|
361
|
+
});
|
|
362
|
+
assert.equal(second.status, 409);
|
|
363
|
+
assert.equal(second.body.ok, false);
|
|
364
|
+
await app.settle();
|
|
365
|
+
// The twin proposal is still staged — nothing new launched.
|
|
366
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
367
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
368
|
+
});
|
|
369
|
+
|
|
168
370
|
test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
|
|
169
371
|
const app = await boot();
|
|
170
372
|
assert.ok(app.api);
|
|
@@ -251,6 +453,59 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
251
453
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
252
454
|
});
|
|
253
455
|
|
|
456
|
+
test("a SECRET-BEARING staged graph dispatches through the cockpit UI WITHOUT an idempotencyKey — the door supplies a stable server-side key (#778)", async () => {
|
|
457
|
+
// Regression: Option C's dispatch gate refuses a keyless dispatch of a graph whose digest is lossy,
|
|
458
|
+
// but the cockpit staged-proposals UI posts no idempotency-key field. The door must therefore derive
|
|
459
|
+
// a stable server-side key from the stored proposal so these proposals remain launchable.
|
|
460
|
+
const app = await boot();
|
|
461
|
+
assert.ok(app.api);
|
|
462
|
+
const api = app.api;
|
|
463
|
+
const SECRET = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:pass@host.example/repo" } }] };
|
|
464
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
465
|
+
// No idempotencyKey in the body — exactly what the staged UI posts.
|
|
466
|
+
const res = await api.call<{ ok: boolean; status: string; runKey: string }>("dispatchDeliveryGraph", {
|
|
467
|
+
body: { digest: staged.body.digest, repoless: true },
|
|
468
|
+
});
|
|
469
|
+
assert.equal(res.status, 202);
|
|
470
|
+
assert.equal(res.body.ok, true);
|
|
471
|
+
assert.equal(res.body.status, "running");
|
|
472
|
+
assert.ok(res.body.runKey.startsWith("staged-"), `expected a server-side staged- key, got ${res.body.runKey}`);
|
|
473
|
+
await app.settle();
|
|
474
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
475
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("an explicit idempotencyKey re-used across two SECRET-differing graphs that SHARE a digest is refused at short-circuit → 409; the re-staged proposal is NOT consumed and nothing new launches (#778 review — thread dispatchDeliveryGraph.ts:282)", async () => {
|
|
479
|
+
const app = await boot();
|
|
480
|
+
assert.ok(app.api);
|
|
481
|
+
const api = app.api;
|
|
482
|
+
// Two graphs differing ONLY in a redacted-away credential redact to the SAME `semanticBpmn`, so they
|
|
483
|
+
// share ONE content digest and therefore ONE digest-keyed proposal row.
|
|
484
|
+
const SECRET_A = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAAsecret@host.example/repo" } }] };
|
|
485
|
+
const SECRET_B = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBBsecret@host.example/repo" } }] };
|
|
486
|
+
const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_A });
|
|
487
|
+
// Dispatch A under an explicit shared key — it launches and parks (running).
|
|
488
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
489
|
+
body: { digest: a.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
490
|
+
});
|
|
491
|
+
assert.equal(first.status, 202);
|
|
492
|
+
await app.settle();
|
|
493
|
+
// B re-stages the SAME digest-keyed proposal row (its credential differs, but the digest is identical).
|
|
494
|
+
const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_B });
|
|
495
|
+
assert.equal(b.body.digest, a.body.digest, "the two secret-differing graphs must share one redacted digest");
|
|
496
|
+
// Dispatch B under the SAME shared key — it short-circuits onto A's still-running run. Because the
|
|
497
|
+
// digest cannot prove the running run is THIS (secret-bearing) graph, B must NOT be consumed: 409.
|
|
498
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
499
|
+
body: { digest: b.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
500
|
+
});
|
|
501
|
+
assert.equal(second.status, 409);
|
|
502
|
+
assert.equal(second.body.ok, false);
|
|
503
|
+
await app.settle();
|
|
504
|
+
// The proposal is still staged (never falsely consumed) and only A's single run exists.
|
|
505
|
+
assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
|
|
506
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
507
|
+
});
|
|
508
|
+
|
|
254
509
|
test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
255
510
|
const app = await boot();
|
|
256
511
|
assert.ok(app.api);
|