@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
|
@@ -62,6 +62,15 @@ export interface DeliveryGraphProposal {
|
|
|
62
62
|
created_at: string;
|
|
63
63
|
updated_at: string;
|
|
64
64
|
expires_at: string;
|
|
65
|
+
/** A strictly-monotonic per-write stage revision (migration 114), reassigned to `MAX(stage_seq)+1`
|
|
66
|
+
* on every stage write (insert AND re-stage). It is the supersede reconcile's same-millisecond
|
|
67
|
+
* tie-break, AND the version token the dispatch door threads through so `markProposalDispatched`
|
|
68
|
+
* can guard its terminal flip against a concurrent re-stage that overwrote this same-digest row with
|
|
69
|
+
* a newer (e.g. credential-differing) graph after this dispatch snapshotted it (issue #778 review —
|
|
70
|
+
* thread deliveryGraphProposals.ts:243). Persisted rows carry a value ≥ 1; a pre-migration row reads
|
|
71
|
+
* `0` until its next stage re-sequences it. Assigned atomically in the `stageProposal` SQL, never
|
|
72
|
+
* from a row object — a freshly-`buildProposalRow`'d row's `0` here is a never-persisted placeholder. */
|
|
73
|
+
stage_seq: number;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
|
|
@@ -150,6 +159,9 @@ export function buildProposalRow(input: {
|
|
|
150
159
|
created_at: createdAt,
|
|
151
160
|
updated_at: at,
|
|
152
161
|
expires_at: proposalExpiry(createdAt),
|
|
162
|
+
// Placeholder only: the persisted `stage_seq` is assigned atomically as `MAX(stage_seq)+1` inside
|
|
163
|
+
// the `stageProposal` INSERT/UPSERT (never bound from this object), so this `0` is never written.
|
|
164
|
+
stage_seq: 0,
|
|
153
165
|
};
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -190,55 +202,77 @@ export interface StageOutcome {
|
|
|
190
202
|
* retire. */
|
|
191
203
|
export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
|
|
192
204
|
const table = deliveryGraphProposals(data);
|
|
193
|
-
const existing = await table.get(row.digest);
|
|
194
205
|
// Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
|
|
195
206
|
// digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
|
|
196
207
|
// after to report exactly which digests this stage retired.
|
|
197
208
|
const priorSameKey = (await table.find({ status: "staged" })).filter(
|
|
198
209
|
(r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
|
|
199
210
|
);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.
|
|
239
|
-
|
|
211
|
+
// Persist the write, its strictly-monotonic stage sequence, AND the supersede reconcile as ONE
|
|
212
|
+
// transaction (issue #778 review — threads deliveryGraphProposals.ts:237/:239). Two properties matter:
|
|
213
|
+
// • `stage_seq` is assigned ATOMICALLY inside the write itself — `MAX(stage_seq)+1` in the
|
|
214
|
+
// INSERT/UPDATE, NOT a separate stamp statement — so there is never a "written but unstamped
|
|
215
|
+
// (seq 0)" window in which a rival same-millisecond stage's reconcile could read the half-written
|
|
216
|
+
// sibling at its DEFAULT 0 and supersede it out of order (the inversion the old two-statement
|
|
217
|
+
// stamp+reconcile allowed). Assigning inside the serialized transaction keeps `MAX(...)` atomic, so
|
|
218
|
+
// `+1` is strictly greater than every committed seq and reflects true write order.
|
|
219
|
+
// • wrapping the reconcile in the SAME transaction means no concurrent stage observes an intermediate
|
|
220
|
+
// state between the write and the reconcile.
|
|
221
|
+
// `MAX(stage_seq)` reads the pre-write table via the `stage_seq` index (migration 114), an O(1)
|
|
222
|
+
// reverse-index seek rather than the full-history scan a bare aggregate over the retained terminal rows
|
|
223
|
+
// would cost.
|
|
224
|
+
const at = now();
|
|
225
|
+
const atExpiry = proposalExpiry(at);
|
|
226
|
+
const nextSeq = `(SELECT COALESCE(MAX("stage_seq"), 0) + 1 FROM "delivery_graph_proposals")`;
|
|
227
|
+
await data.open().tx(async (t) => {
|
|
228
|
+
// Single idempotent UPSERT rather than a read-`existing`-then-branch INSERT/UPDATE: reading the row
|
|
229
|
+
// BEFORE the transaction meant two concurrent FIRST stages of the same digest both saw `null` and
|
|
230
|
+
// would both take an INSERT — the second hitting a primary-key conflict that fails the stage and drops
|
|
231
|
+
// its reconciliation. `ON CONFLICT("digest") DO UPDATE` collapses the raced insert into an in-place
|
|
232
|
+
// update instead, so a same-digest stage is idempotent regardless of arrival order (issue #778 review —
|
|
233
|
+
// thread deliveryGraphProposals.ts:245). `stage_seq` is still assigned ATOMICALLY inside the write
|
|
234
|
+
// (`MAX(stage_seq)+1`, materialised once as `excluded."stage_seq"` so the insert and the on-conflict
|
|
235
|
+
// update use the exact same value), preserving the strictly-monotonic ordering the supersede reconcile
|
|
236
|
+
// below tie-breaks on.
|
|
237
|
+
//
|
|
238
|
+
// The TTL anchor (`created_at`) and horizon (`expires_at`) are ALSO derived inside this atomic write,
|
|
239
|
+
// from the row as it exists AT WRITE TIME — NOT from a pre-transaction `table.get` snapshot (issue #778
|
|
240
|
+
// review — thread deliveryGraphProposals.ts:247). A pre-read snapshot is a TOCTOU: a concurrent re-stage
|
|
241
|
+
// can commit a fresh TTL between the read and this write, and the stale caller — committing LAST, so it
|
|
242
|
+
// wins the newest `stage_seq` — would overwrite that fresh horizon with the one it computed from the OLD
|
|
243
|
+
// `created_at`, restoring an already-expired `expires_at` on the newest row and leaving the proposal
|
|
244
|
+
// non-dispatchable. On a fresh INSERT the caller's `created_at`/`expires_at` stand (first stage anchors
|
|
245
|
+
// the TTL). On a CONFLICT the `CASE` re-reads the CURRENT committed row: if it is still LIVE
|
|
246
|
+
// (`expires_at` in the future) the original anchor and horizon are PRESERVED (an idempotent re-stage
|
|
247
|
+
// keeps the TTL pinned to the first stage); once it has aged out (past, blank, or NULL `expires_at`,
|
|
248
|
+
// matching `isProposalExpired`'s fail-closed rule) the TTL is RE-ANCHORED to now so a re-proposed digest
|
|
249
|
+
// is genuinely live again. All timestamps are ISO-8601 UTC, so the lexical `>` comparison is a valid
|
|
250
|
+
// chronological test.
|
|
251
|
+
await t.exec(
|
|
252
|
+
`INSERT INTO "delivery_graph_proposals" ("digest", "logical_key", "title", "graph", "preview", "node_count", "human_node_count", "side_effect_count", "side_effecting", "status", "created_at", "updated_at", "expires_at", "stage_seq") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${nextSeq}) ON CONFLICT("digest") DO UPDATE SET "logical_key" = excluded."logical_key", "title" = excluded."title", "graph" = excluded."graph", "preview" = excluded."preview", "node_count" = excluded."node_count", "human_node_count" = excluded."human_node_count", "side_effect_count" = excluded."side_effect_count", "side_effecting" = excluded."side_effecting", "status" = excluded."status", "created_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."created_at" ELSE ? END, "updated_at" = ?, "expires_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."expires_at" ELSE ? END, "stage_seq" = excluded."stage_seq"`,
|
|
253
|
+
[row.digest, row.logical_key, row.title, row.graph, row.preview, row.node_count, row.human_node_count, row.side_effect_count, row.side_effecting, row.status, row.created_at, at, row.expires_at, at, at, at, at, atExpiry],
|
|
254
|
+
);
|
|
255
|
+
// Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
|
|
256
|
+
// `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
|
|
257
|
+
// `stage_seq` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT
|
|
258
|
+
// — it never references the just-written digest, so it converges to a single live row regardless of the
|
|
259
|
+
// interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
|
|
260
|
+
// just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
|
|
261
|
+
// committed (the older pass won't flip the newer row, and the newer pass ran before the older row
|
|
262
|
+
// existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
|
|
263
|
+
// both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
|
|
264
|
+
// statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
|
|
265
|
+
// The tie-break is the monotonic `stage_seq` (atomically assigned in the write above), NOT the content
|
|
266
|
+
// `digest` NOR the frozen `rowid`: two stages landing in the SAME millisecond tie on `updated_at`, and a
|
|
267
|
+
// `digest`-string tie-break is arbitrary w.r.t. submission order while `rowid` does not advance on a
|
|
268
|
+
// re-stage UPDATE — so either could pick the LATER-submitted graph as the one superseded, inverting
|
|
269
|
+
// "the latest stage wins". `stage_seq` advances on every stage (insert AND re-stage alike), so the
|
|
270
|
+
// most-recently-written row deterministically wins the tie.
|
|
271
|
+
await t.exec(
|
|
272
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."stage_seq" > "delivery_graph_proposals"."stage_seq")))`,
|
|
240
273
|
[now(), row.logical_key],
|
|
241
274
|
);
|
|
275
|
+
});
|
|
242
276
|
|
|
243
277
|
// Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
|
|
244
278
|
// this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
|
|
@@ -259,12 +293,13 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
|
|
|
259
293
|
}
|
|
260
294
|
const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
|
|
261
295
|
|
|
262
|
-
// Re-read the just-written row AFTER the reconcile so
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// that doesn't match the DB
|
|
266
|
-
//
|
|
267
|
-
|
|
296
|
+
// Re-read the just-written row AFTER the reconcile so the returned row reports the ACTUALLY-persisted
|
|
297
|
+
// state: a concurrent newer stage of a different digest can flip THIS digest to `superseded` in the
|
|
298
|
+
// reconcile above, so returning the pre-reconcile input `row` (still `staged`) would report a status
|
|
299
|
+
// that doesn't match the DB — and the persisted `created_at`/`expires_at`/`stage_seq` are derived inside
|
|
300
|
+
// the write, not on `row`. Fall back to the input `row` only if the persisted row somehow vanished (it
|
|
301
|
+
// shouldn't — we just wrote it), keeping the return non-null.
|
|
302
|
+
const persisted = (await table.get(row.digest)) ?? row;
|
|
268
303
|
|
|
269
304
|
return { row: persisted, superseded, siblingsStaged };
|
|
270
305
|
}
|
|
@@ -321,9 +356,36 @@ export async function listStagedProposals(
|
|
|
321
356
|
}
|
|
322
357
|
|
|
323
358
|
/** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
|
|
324
|
-
* staged list (the run then shows in the in-flight grid).
|
|
325
|
-
|
|
326
|
-
|
|
359
|
+
* staged list (the run then shows in the in-flight grid).
|
|
360
|
+
*
|
|
361
|
+
* Like `markProposalExpired`/`markProposalDismissed`/`sweepExpiredProposals`, this is a GUARDED UPDATE
|
|
362
|
+
* (`... WHERE digest=? AND status='staged'`), not a blind update-by-key. The dispatch door's
|
|
363
|
+
* `getStagedProposal` liveness read, the launch, and this terminal flip are separate statements, so the
|
|
364
|
+
* row can leave `staged` in the window between them (a concurrent dispatch consumed it, or a
|
|
365
|
+
* dismiss/supersede/expiry sweep retired it); a blind `table.update(digest, …)` would clobber that
|
|
366
|
+
* newer terminal status back to `dispatched`, breaking monotonic lifecycle transitions.
|
|
367
|
+
*
|
|
368
|
+
* When `expectedStageSeq` is supplied the guard ALSO pins `stage_seq` to the value the dispatch door
|
|
369
|
+
* snapshotted when it loaded the proposal (issue #778 review — thread deliveryGraphProposals.ts:243). A
|
|
370
|
+
* concurrent RE-STAGE of the SAME digest overwrites the row in place with a newer graph (two
|
|
371
|
+
* credential-differing graphs share a semantic digest) and bumps `stage_seq`; without the seq pin, this
|
|
372
|
+
* flip would retire that newer, never-launched graph — the dispatch launched the STALE snapshot it read
|
|
373
|
+
* yet marks the fresh row consumed. Pinning `stage_seq` makes the flip a no-op in that race, leaving the
|
|
374
|
+
* re-staged graph `staged` for its own dispatch. Omit `expectedStageSeq` (e.g. a pre-migration row whose
|
|
375
|
+
* seq is `0`) to fall back to the `status='staged'`-only guard.
|
|
376
|
+
*
|
|
377
|
+
* Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
|
|
378
|
+
* `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer the
|
|
379
|
+
* `staged` revision this dispatch snapshotted (a concurrent dispatch/re-stage/retire won) — the launched
|
|
380
|
+
* run still stands, but the mark was intentionally skipped so the newer row is not clobbered. */
|
|
381
|
+
export async function markProposalDispatched(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
|
|
382
|
+
const db = data.open();
|
|
383
|
+
const guardSeq = typeof expectedStageSeq === "number";
|
|
384
|
+
const res = await db.exec(
|
|
385
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'dispatched', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
|
|
386
|
+
guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
|
|
387
|
+
);
|
|
388
|
+
return res.changed > 0;
|
|
327
389
|
}
|
|
328
390
|
|
|
329
391
|
/** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
|
|
@@ -342,12 +404,18 @@ export async function markProposalDispatched(data: DataLayer, digest: string): P
|
|
|
342
404
|
* Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
|
|
343
405
|
* `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer `staged`
|
|
344
406
|
* at write time (a dismiss/supersede/dispatch race won), so the caller must not treat the retirement as
|
|
345
|
-
* having happened.
|
|
346
|
-
|
|
407
|
+
* having happened.
|
|
408
|
+
*
|
|
409
|
+
* When an `expectedStageSeq` is supplied (the dispatch door's corrupt-graph retirement), the flip is
|
|
410
|
+
* ADDITIONALLY guarded on `stage_seq = ?`, exactly like `markProposalDispatched`: a concurrent re-stage
|
|
411
|
+
* of the same digest bumps `stage_seq`, so guarding on the revision we inspected keeps the expiry from
|
|
412
|
+
* retiring a newer graph that landed in the read→write window (issue #778 review). */
|
|
413
|
+
export async function markProposalExpired(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
|
|
347
414
|
const db = data.open();
|
|
415
|
+
const guardSeq = typeof expectedStageSeq === "number";
|
|
348
416
|
const res = await db.exec(
|
|
349
|
-
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
|
|
350
|
-
[now(), digest],
|
|
417
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
|
|
418
|
+
guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
|
|
351
419
|
);
|
|
352
420
|
return res.changed > 0;
|
|
353
421
|
}
|
|
@@ -404,9 +472,15 @@ export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date
|
|
|
404
472
|
let swept = 0;
|
|
405
473
|
for (const row of staged) {
|
|
406
474
|
if (isProposalExpired(row.expires_at, at)) {
|
|
475
|
+
// Pin `stage_seq` (like `markProposalDispatched`/`markProposalExpired`): the `find` liveness read and
|
|
476
|
+
// this flip are separate statements, so a concurrent RE-STAGE can overwrite the row in place with a
|
|
477
|
+
// newer graph — bumping `stage_seq` and REFRESHING `expires_at` — in the window between them. A blind
|
|
478
|
+
// `digest + status='staged'` update would then expire that fresh, un-expired revision. Guarding on the
|
|
479
|
+
// `stage_seq` we inspected makes the flip a no-op in that race, leaving the re-staged graph `staged`
|
|
480
|
+
// (issue #778 review — thread deliveryGraphProposals.ts:417).
|
|
407
481
|
const res = await db.exec(
|
|
408
|
-
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'
|
|
409
|
-
[ts, row.digest],
|
|
482
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged' AND "stage_seq" = ?`,
|
|
483
|
+
[ts, row.digest, row.stage_seq],
|
|
410
484
|
);
|
|
411
485
|
swept += res.changed;
|
|
412
486
|
}
|
|
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { test } from "node:test";
|
|
11
|
-
import { assertEquals } from "#test-assert";
|
|
11
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
12
12
|
import type { DataLayer } from "@nanobpm/urban";
|
|
13
13
|
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
14
14
|
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
computeRunKey,
|
|
20
20
|
deriveDeliveryPhase,
|
|
21
21
|
DELIVERY_PHASE,
|
|
22
|
+
deliveryGraphRunIdentities,
|
|
22
23
|
deliveryGraphRuns,
|
|
23
24
|
humanTaskElementId,
|
|
24
25
|
parseHumanLabels,
|
|
@@ -107,6 +108,25 @@ test("claimRunForLaunch: re-running a terminal row clears the PRIOR instance key
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
test("claimRunForLaunch: winning a re-run claim ATOMICALLY invalidates the prior run's identity side-row — a concurrent loser's short-circuit read sees a MISSING (unprovable) fingerprint, not the stale prior one (issue #778 review — thread deliveryGraphDispatch.ts:180)", async () => {
|
|
112
|
+
await withData(async (data) => {
|
|
113
|
+
const runs = deliveryGraphRuns(data);
|
|
114
|
+
const identities = deliveryGraphRunIdentities(data);
|
|
115
|
+
// A terminal prior run still carrying its stamped lossless identity fingerprint.
|
|
116
|
+
await runs.insert({ ...claimRow("running"), status: "failed" });
|
|
117
|
+
await identities.insert({ run_key: "rk", graph_fingerprint: "PRIOR-FP", created_at: "2020-01-01T00:00:00.000Z" });
|
|
118
|
+
// The winner re-runs (conceptually a DIFFERENT graph that shares the digest-derived run_key but not
|
|
119
|
+
// the credential-sensitive fingerprint). The CAS flips the row to running…
|
|
120
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true);
|
|
121
|
+
// …and the stale identity row must be GONE the instant the row is `running`. Otherwise a racing loser
|
|
122
|
+
// reading between this flip and the winner's post-launch `upsertRunIdentity` re-stamp would read
|
|
123
|
+
// "PRIOR-FP" and, if that matched ITS graph, wrongly confirm identity for a credential-different run.
|
|
124
|
+
// Missing → dispatch's identityConfirmed is false (the safe 409) throughout the window.
|
|
125
|
+
const after = await identities.get("rk");
|
|
126
|
+
assert(after == null, `the stale identity row must be gone, got: ${JSON.stringify(after)}`);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
110
130
|
// ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
|
|
111
131
|
test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
|
|
112
132
|
await withData(async (data) => {
|
|
@@ -200,12 +220,32 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
|
|
|
200
220
|
assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
|
|
201
221
|
});
|
|
202
222
|
|
|
223
|
+
test("buildHumanLabels: redacts a credential-bearing human prompt before it lands in human_labels (issue #778 review)", async () => {
|
|
224
|
+
const graph = {
|
|
225
|
+
nodes: [
|
|
226
|
+
{ id: "publish", kind: "human", human: { prompt: "deploy via https://user:s3cr3t@registry.example.com then confirm" } },
|
|
227
|
+
],
|
|
228
|
+
edges: [],
|
|
229
|
+
};
|
|
230
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
231
|
+
assertEquals(compiled.ok, true);
|
|
232
|
+
if (!compiled.ok) return;
|
|
233
|
+
const labels = buildHumanLabels(compiled);
|
|
234
|
+
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
235
|
+
const label = labels[humanTaskElementId(publishEl)];
|
|
236
|
+
assert(!label.includes("s3cr3t") && !label.includes("user:s3cr3t"), "the credential must not survive into the denormalised inbox label");
|
|
237
|
+
assertStringIncludes(label, "//***@");
|
|
238
|
+
});
|
|
239
|
+
|
|
203
240
|
test("parseHumanLabels: round-trips a stored map and tolerates null/blank/corrupt", () => {
|
|
204
|
-
assertEquals(parseHumanLabels(JSON.stringify({ a: "x" })), { a: "x" });
|
|
205
241
|
assertEquals(parseHumanLabels(null), {});
|
|
206
242
|
assertEquals(parseHumanLabels(""), {});
|
|
207
243
|
assertEquals(parseHumanLabels(" "), {});
|
|
208
244
|
assertEquals(parseHumanLabels("{not json"), {});
|
|
245
|
+
assertEquals(parseHumanLabels(JSON.stringify({ n1: "manual OTP publish", n2: "confirm deploy" })), {
|
|
246
|
+
n1: "manual OTP publish",
|
|
247
|
+
n2: "confirm deploy",
|
|
248
|
+
}); // round-trips a valid string→string map
|
|
209
249
|
assertEquals(parseHumanLabels(JSON.stringify(["a"])), {}); // non-object
|
|
210
250
|
assertEquals(parseHumanLabels(JSON.stringify({ a: 1, b: "y" })), { b: "y" }); // drops non-string values
|
|
211
251
|
});
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
|
|
27
27
|
import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
28
28
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
29
|
+
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
29
30
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
30
31
|
|
|
31
32
|
const now = () => new Date().toISOString();
|
|
@@ -97,6 +98,24 @@ export const DELIVERY_PHASE = {
|
|
|
97
98
|
export const deliveryGraphRuns = (data: DataLayer) =>
|
|
98
99
|
data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
|
|
99
100
|
|
|
101
|
+
/** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
|
|
102
|
+
* content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
|
|
103
|
+
* drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
|
|
104
|
+
export interface DeliveryGraphRunIdentity {
|
|
105
|
+
run_key: string;
|
|
106
|
+
/** `sha256(digest \0 canonicalJson(digestInvisibleRawValues(graph)))`, captured at launch — the same
|
|
107
|
+
* lossless identity `stableProposalRunKey` folds into the keyless run key. An explicit-`idempotencyKey`
|
|
108
|
+
* short-circuit compares this to prove the running run is THIS exact graph (a same-payload retry) vs. a
|
|
109
|
+
* credential-different graph re-staged under the same key. */
|
|
110
|
+
graph_fingerprint: string;
|
|
111
|
+
created_at: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `delivery_graph_run_identity` side-table accessor — the run's lossless identity fingerprint,
|
|
115
|
+
* keyed by `run_key` (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
|
|
116
|
+
export const deliveryGraphRunIdentities = (data: DataLayer) =>
|
|
117
|
+
data.table<DeliveryGraphRunIdentity>("delivery_graph_run_identity", "run_key");
|
|
118
|
+
|
|
100
119
|
/** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
|
|
101
120
|
* won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
|
|
102
121
|
* it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
|
|
@@ -119,7 +138,9 @@ export const deliveryGraphRuns = (data: DataLayer) =>
|
|
|
119
138
|
* the row briefly visible as `running` while still pointing at the OLD instance key, so the
|
|
120
139
|
* `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
|
|
121
140
|
* the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
|
|
122
|
-
* the flip means a claimed `running` row can never be observed with a stale instance key.
|
|
141
|
+
* the flip means a claimed `running` row can never be observed with a stale instance key. A winning
|
|
142
|
+
* relaunch also DELETES the run's prior `delivery_graph_run_identity` side-row in the same transaction,
|
|
143
|
+
* for the same reason — see the inline note (issue #778 review). */
|
|
123
144
|
export async function claimRunForLaunch(
|
|
124
145
|
data: DataLayer,
|
|
125
146
|
existing: boolean,
|
|
@@ -134,9 +155,8 @@ export async function claimRunForLaunch(
|
|
|
134
155
|
return false;
|
|
135
156
|
}
|
|
136
157
|
}
|
|
137
|
-
const res = await data
|
|
138
|
-
.
|
|
139
|
-
.exec(
|
|
158
|
+
const res = await data.open().tx(async (t) => {
|
|
159
|
+
const flip = await t.exec(
|
|
140
160
|
`UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
|
|
141
161
|
[
|
|
142
162
|
claim.status,
|
|
@@ -148,7 +168,21 @@ export async function claimRunForLaunch(
|
|
|
148
168
|
claim.run_key,
|
|
149
169
|
],
|
|
150
170
|
);
|
|
151
|
-
|
|
171
|
+
if (flip.changed !== 1) return false;
|
|
172
|
+
// Invalidate the PRIOR run's lossless identity fingerprint IN THE SAME transaction as the flip, so a
|
|
173
|
+
// claimed `running` row can never be observed alongside the previous run's stale `graph_fingerprint`.
|
|
174
|
+
// dispatch's `identityConfirmed` short-circuit reads this side-row to prove an already-running run is
|
|
175
|
+
// THIS exact graph (a same-payload retry) vs. a credential-different graph re-staged under the same
|
|
176
|
+
// key; between this flip and the winner's post-launch re-stamp (`upsertRunIdentity`) the prior row
|
|
177
|
+
// would otherwise still match a relaunch that shares only the digest, WRONGLY confirming identity for
|
|
178
|
+
// a different-credential run. Deleting it here makes the fingerprint UNPROVABLE (missing →
|
|
179
|
+
// identityConfirmed:false, the safe pre-existing 409) throughout that window, atomically with the
|
|
180
|
+
// status flip — the same "no stale side-state observable on a claimed row" invariant the instance-key
|
|
181
|
+
// clear above enforces (issue #778 review — thread deliveryGraphDispatch.ts:180).
|
|
182
|
+
await t.exec(`DELETE FROM "delivery_graph_run_identity" WHERE "run_key" = ?`, [claim.run_key]);
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
return res;
|
|
152
186
|
}
|
|
153
187
|
|
|
154
188
|
/** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
|
|
@@ -181,7 +215,13 @@ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<s
|
|
|
181
215
|
for (const stop of compiled.humanNodes) {
|
|
182
216
|
const element = elementByNodeId.get(stop.nodeId);
|
|
183
217
|
if (element === undefined) continue;
|
|
184
|
-
|
|
218
|
+
// Redact the prompt with the SAME display-safe helper `nodeDisplay` uses BEFORE stamping it: this
|
|
219
|
+
// label is denormalised into the run row's `human_labels` and surfaced to the operator as the parked
|
|
220
|
+
// task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
|
|
221
|
+
// must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
|
|
222
|
+
// path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
|
|
223
|
+
const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt) : "";
|
|
224
|
+
labels[humanTaskElementId(element)] = firstLine(safePrompt) || stop.nodeId;
|
|
185
225
|
}
|
|
186
226
|
return labels;
|
|
187
227
|
}
|
|
@@ -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/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>");
|