clearotron 0.4.0-beta.2 → 0.4.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/THIRD-PARTY-NOTICES.md +5 -5
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +90 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/coverage-form.mjs +6 -0
- package/driver/coverage-ledger.mjs +14 -2
- package/driver/driver.config.mjs +24 -7
- package/driver/engine/mcp/band-server.mjs +3 -3
- package/driver/engine/mcp/stdio-server.mjs +12 -1
- package/driver/gateway.mjs +27 -3
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +41 -10
- package/driver/pipeline.mjs +104 -12
- package/driver/progress.mjs +6 -1
- package/driver/provider-usage.mjs +16 -0
- package/driver/publish/index.mjs +11 -5
- package/driver/publish/knockout.mjs +16 -3
- package/driver/publish/render-knockout.mjs +38 -11
- package/driver/record-carry.mjs +17 -1
- package/driver/reference-strip-signatures.mjs +11 -1
- package/driver/register-plan.mjs +7 -3
- package/driver/register-served.mjs +91 -0
- package/driver/remedy-accounting.mjs +38 -9
- package/driver/reviewer-open-points.mjs +20 -23
- package/driver/score-redaction.mjs +416 -20
- package/driver/screen-gate.mjs +3 -3
- package/driver/skills/clearance-register/SKILL.md +0 -8
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/providers/corsearch.md +1 -1
- package/driver/skills/clearance-register/register-recipes.md +3 -9
- package/driver/skills/clearance-search/SKILL.md +2 -2
- package/driver/skills/clearance-search/phase2-execution.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +2 -0
- package/driver/stages-knockout.mjs +22 -0
- package/driver/suite-census.json +152 -26
- package/driver/unit-inventory.mjs +38 -43
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +2 -2
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/jx/src/core.js +0 -1
- package/providers/jx/src/judge.js +0 -1
- package/providers/jx/src/nativeread.js +0 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/core.js +125 -17
- package/scripts/e2e-first-time.mjs +12 -4
- package/scripts/e2e-scenario-ops.mjs +25 -2
- package/scripts/e2e.mjs +246 -24
- package/scripts/mint-reference-strip-backlog.mjs +36 -2
- package/scripts/score.mjs +102 -29
- package/shared/identifier-scan.mjs +31 -1
|
@@ -159,9 +159,9 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
159
159
|
// stamped and says where its verdict lives, and driver/updater-identity.mjs makes that verdict from
|
|
160
160
|
// the digests the updater records about itself.
|
|
161
161
|
attribution: "stamp",
|
|
162
|
-
measured: "2026-09-08, the test deployment: the timer and its service are installed
|
|
162
|
+
measured: "2026-09-08, read on the test deployment: the timer and its service are installed, the timer is enabled and ACTIVE, and it last fired within the hour — `systemctl --user list-timers`. The service's own state says nothing about this: it is `inactive` between runs and `inactive` when the timer has been stopped. The timer's ActiveState says the timer itself is still up. It is NOT a next elapse and does not promise one: `active` means UP, never SCHEDULED. Proving something will still fire takes a real next elapse, which `systemctl list-timers --all` supplies and `show -p ActiveState` cannot. Measured in testing 2026-09-09: a timer reading `ActiveState=active` whose `OnCalendar` names a date already past, so `list-timers` reports its next fire as null and it will never run again.",
|
|
163
163
|
tracked: ["clearotron-deploy.service", "clearotron-deploy.timer"],
|
|
164
|
-
note: "the install's own updater.
|
|
164
|
+
note: "the install's own updater.",
|
|
165
165
|
// IT WAS AN ORPHAN, AND THE CONDITION IT NAMED HAS BEEN MET. This entry carried an `orphanReason`
|
|
166
166
|
// saying it had been shipped and installed nowhere, and that `runsOn` would gain "test" the day a
|
|
167
167
|
// deploy placed it. It has: the timer is installed, enabled and firing, and `runsOn` says so. The prose
|
|
@@ -212,10 +212,8 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
212
212
|
// prose references in driver/systemd/render-units.mjs also describe this file as though it were in
|
|
213
213
|
// the tree; they are stale today either way.
|
|
214
214
|
unit: "profile-service", runsOn: [],
|
|
215
|
-
measured: "2026-09-08
|
|
216
|
-
+ "unit
|
|
217
|
-
+ "other product plus unloadable residue, and no unit of THIS name is under either. Names and enable "
|
|
218
|
-
+ "state only; no unit contents were read.",
|
|
215
|
+
measured: "2026-09-08, read on production: no unit of this name is present. Names and enable state only; "
|
|
216
|
+
+ "no unit contents were read.",
|
|
219
217
|
tracked: null,
|
|
220
218
|
untrackedReason: "the repository has never carried this file — `git log --all` on the path is empty. "
|
|
221
219
|
+ "It was described as deploying from a copy held on the deployment itself, in the same "
|
|
@@ -226,9 +224,9 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
226
224
|
// it loaded no EnvironmentFile and so had no `${VAR}` systemd could expand. The owner's one-config-
|
|
227
225
|
// per-server-box ruling gives it `EnvironmentFile=%h/.env` like every other service, which makes the
|
|
228
226
|
// checkout path an ordinary systemd expansion and leaves no placeholder to render.
|
|
229
|
-
note: "
|
|
230
|
-
+ "
|
|
231
|
-
+ "
|
|
227
|
+
note: "Was a TEMPLATE unit carrying identity-provider values inline; generic since the product "
|
|
228
|
+
+ "installed its own units. It claimed a box for as long as it existed and no reading ever "
|
|
229
|
+
+ "supported it — which is what the dated measurement beside it exists to prevent.",
|
|
232
230
|
orphanReason: "CLAIMED A DEPLOYMENT IT WAS NEVER MEASURED ON, which is a FOURTH kind of orphan and "
|
|
233
231
|
+ "the only one that was ever a wrong claim rather than a waiting decision: the other three run "
|
|
234
232
|
+ "under another name, were deliberately switched off, or have simply never been installed. This "
|
|
@@ -245,7 +243,7 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
245
243
|
// REPORTED, never a fault, for exactly this case. It becomes `["prod"]` in the change that DEPLOYS
|
|
246
244
|
// it, against a fresh enumeration, which is the ratchet working rather than being edited around.
|
|
247
245
|
unit: "clearotron-portal", runsOn: ["test"], tracked: ["clearotron-portal.service"],
|
|
248
|
-
measured: "2026-09-07,
|
|
246
|
+
measured: "2026-09-07, read on the test deployment with a working user bus: ActiveState=active, UnitFileState=enabled, its fragment under the account's own unit directory.",
|
|
249
247
|
note: "NEW, SHIPPED BUT NOT YET DEPLOYED. The portal had NO unit at all — it "
|
|
250
248
|
+ "and the MCP face are the two children bin/start.mjs supervises, and neither ran under systemd, "
|
|
251
249
|
+ "so a hosted deployment kept them alive with a hand launcher and `clearotron start` had nothing "
|
|
@@ -262,7 +260,7 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
262
260
|
// Same as clearotron-portal above: shipped, running nowhere, declared as an orphan rather than
|
|
263
261
|
// asserting a production state no enumeration has ever shown.
|
|
264
262
|
unit: "clearotron-mcp-face", runsOn: ["test"], tracked: ["clearotron-mcp-face.service"],
|
|
265
|
-
measured: "2026-09-07,
|
|
263
|
+
measured: "2026-09-07, read on the test deployment with a working user bus: ActiveState=active, UnitFileState=enabled, its fragment under the account's own unit directory.",
|
|
266
264
|
note: "NEW, SHIPPED BUT NOT YET DEPLOYED. The engine door the portal calls over "
|
|
267
265
|
+ "MCP; the other of the two processes that had no unit.",
|
|
268
266
|
orphanReason: "THE ENGINE DOOR THE PORTAL CALLS, shipped with no box carrying it yet. Same kind as "
|
|
@@ -291,7 +289,7 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
291
289
|
// door beside it: it is in SERVER_INSTALL_SET, and it leaves this list on the day a measured
|
|
292
290
|
// enumeration of a box shows it running.
|
|
293
291
|
unit: "clearotron-client-mcp", runsOn: ["test"], tracked: ["clearotron-client-mcp.service"],
|
|
294
|
-
measured: "2026-09-07,
|
|
292
|
+
measured: "2026-09-07, read on the test deployment with a working user bus: ActiveState=active, UnitFileState=enabled, its fragment under the account's own unit directory.",
|
|
295
293
|
note: "INSTALLED WITH THE PRODUCT since 2026-09-03. The client door "
|
|
296
294
|
+ "— the one surface that accepts an account-scoped key, and a separate process from the engine "
|
|
297
295
|
+ "door, which refuses one outright.",
|
|
@@ -309,10 +307,9 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
309
307
|
// Written as part of retiring the path-watcher posture, because retiring `prelim-driver.*` without
|
|
310
308
|
// it leaves a hosted box healthy-looking and draining nothing.
|
|
311
309
|
unit: "clearotron-worker", runsOn: ["test"], tracked: ["clearotron-worker.service"],
|
|
312
|
-
measured: "2026-09-07,
|
|
310
|
+
measured: "2026-09-07, read on the test deployment with a working user bus: ActiveState=active, UnitFileState=enabled, its fragment under the account's own unit directory.",
|
|
313
311
|
note: "NEW. The drain, as one plain service unit invoking the entrypoint "
|
|
314
|
-
+ "directly (owner ruling 2026-08-31). Ships tracked
|
|
315
|
-
+ "its rebuild, which is the same event that lets the retired units' files leave the tree.",
|
|
312
|
+
+ "directly (owner ruling 2026-08-31). Ships tracked and runs nowhere yet.",
|
|
316
313
|
orphanReason: "AWAITING A BOX, not an orphan by design. It is the hosted posture's drainer, and the "
|
|
317
314
|
+ "hosted posture is an opt-in step no install performs. It must NOT be "
|
|
318
315
|
+ "started beside `clearotron start`, which supervises its own worker — the two postures are "
|
|
@@ -320,10 +317,10 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
320
317
|
},
|
|
321
318
|
{
|
|
322
319
|
unit: "trademark-portal", runsOn: [],
|
|
323
|
-
orphanReason: "THE PRE-RENAME PORTAL.
|
|
320
|
+
orphanReason: "THE PRE-RENAME PORTAL. Production runs `clearotron-portal`; this name is "
|
|
324
321
|
+ "what that service used to be called, and the 2026-09-08 reading finds no unit of this name under "
|
|
325
322
|
+ "any account. It ran on production until the rename and the entry was never re-measured after it.",
|
|
326
|
-
measured: "2026-09-08
|
|
323
|
+
measured: "2026-09-08, read on production: no unit of this name is present. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: null,
|
|
327
324
|
untrackedReason: "the deployed copy carries real Cloudflare Access team/AUD/domain values inline. "
|
|
328
325
|
+ "A tracked file would be the placeholder TEMPLATE, merged by hand after a "
|
|
329
326
|
+ "diff — writing one carelessly replaces working auth with placeholders that look configured.",
|
|
@@ -335,10 +332,10 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
335
332
|
},
|
|
336
333
|
{
|
|
337
334
|
unit: "trademark-ops-mcp", runsOn: [],
|
|
338
|
-
orphanReason: "THE PRE-RENAME OPERATOR DOOR.
|
|
335
|
+
orphanReason: "THE PRE-RENAME OPERATOR DOOR. Production runs `clearotron-mcp-face`; the "
|
|
339
336
|
+ "2026-09-08 reading finds no unit of this name under any account. Same rename as the portal it "
|
|
340
337
|
+ "serves, and the same claim carried across it unmeasured.",
|
|
341
|
-
measured: "2026-09-08
|
|
338
|
+
measured: "2026-09-08, read on production: no unit of this name is present. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: null,
|
|
342
339
|
untrackedReason: "same CF Access inline-values shape as the portal it serves.",
|
|
343
340
|
},
|
|
344
341
|
{
|
|
@@ -346,22 +343,20 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
346
343
|
orphanReason: "THE PRE-RENAME CLIENT DOOR. `clearotron-client-mcp` runs on both the production and "
|
|
347
344
|
+ "the test deployments; the 2026-09-08 reading finds no unit of THIS name under any account. The "
|
|
348
345
|
+ "tracked template stays because the repository still ships it — see the note.",
|
|
349
|
-
measured: "2026-09-08
|
|
346
|
+
measured: "2026-09-08, read on production: no unit of this name is present. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: ["client-mcp.service"],
|
|
350
347
|
note: "TRACKED, in mcp-server/remote/ — this entry said it had no file, and the file was there. It is "
|
|
351
348
|
+ "a banner-marked TEMPLATE, so the live copy differing from it is the arrangement, not drift. "
|
|
352
|
-
+ "
|
|
353
|
-
+ "
|
|
354
|
-
+ "
|
|
355
|
-
+ "
|
|
356
|
-
+ "unit runs the same binary in dev mode on its own port; see the template for why that port is "
|
|
357
|
-
+ "not the default.",
|
|
349
|
+
+ "It was reported as shipping no unit; a unit did ship, in mcp-server/remote/ rather than "
|
|
350
|
+
+ "driver/systemd/ — the same one-directory blind spot this file's own header records. A "
|
|
351
|
+
+ "deployment unit runs the same binary in dev mode on its own port; see the template for why "
|
|
352
|
+
+ "that port is not the default.",
|
|
358
353
|
},
|
|
359
354
|
{
|
|
360
355
|
unit: "client-mcp-apikey", runsOn: ["test"],
|
|
361
|
-
measured: "2026-09-08, read
|
|
362
|
-
+ "
|
|
363
|
-
+ "production
|
|
364
|
-
+ "
|
|
356
|
+
measured: "2026-09-08, read on the test deployment: enabled and present, beside the renamed "
|
|
357
|
+
+ "`clearotron-client-mcp`, and INACTIVE — enabled with no running process. Absent from "
|
|
358
|
+
+ "production. The entry declared production and the reading puts it on the other deployment "
|
|
359
|
+
+ "— the claim was not stale, it named the wrong machine.",
|
|
365
360
|
tracked: ["client-mcp-apikey.service"],
|
|
366
361
|
// THE SEPARATE QUESTION, ANSWERED. The line above used to end by saying that why it runs beside the
|
|
367
362
|
// renamed door was a question for somebody else. It is a survivor of the rename, not a posture
|
|
@@ -401,7 +396,7 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
401
396
|
orphanReason: "A PRE-RENAME NAME OF THE RETIRED DEPLOYMENT, and the one of them whose tracked file "
|
|
402
397
|
+ "is GENERIC rather than a template — so the file is ready to be compared against a live copy the "
|
|
403
398
|
+ "day something runs it, and nothing does. Read as absent under every account on 2026-09-08.",
|
|
404
|
-
measured: "2026-09-08
|
|
399
|
+
measured: "2026-09-08, read on production: no unit of this name is present. Names and enable state only; no unit contents were read.", tracked: ["trademark-artifacts-http.service"],
|
|
405
400
|
note: "TRACKED, in mcp-server/remote/. GENERIC, not a template: it carries no banner and no "
|
|
406
401
|
+ "placeholder, and defers its CF Access values to the EnvironmentFile — so its live copy is "
|
|
407
402
|
+ "expected to MATCH the tracked file, and a difference is real drift.",
|
|
@@ -455,11 +450,11 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
455
450
|
},
|
|
456
451
|
{
|
|
457
452
|
unit: "client-access", runsOn: [],
|
|
458
|
-
orphanReason: "NOT THIS REPOSITORY'S SERVICE, and no longer running under this name:
|
|
459
|
-
+ "
|
|
453
|
+
orphanReason: "NOT THIS REPOSITORY'S SERVICE, and no longer running under this name: production holds it "
|
|
454
|
+
+ "only under a retired-suffixed filename, a suffix systemd never reads. "
|
|
460
455
|
+ "Declared here so a unit sharing a machine with ours is not invisible, and now declared as gone.",
|
|
461
|
-
measured: "2026-09-08
|
|
462
|
-
+ "
|
|
456
|
+
measured: "2026-09-08, read on production: present ONLY as a retired-suffixed filename, which systemd "
|
|
457
|
+
+ "does not load. No loadable unit of this name.", tracked: null,
|
|
463
458
|
untrackedReason: "it is not this repo's service. Its ExecStart runs a script from a different "
|
|
464
459
|
+ "product's checkout on the same box, not under this clone — so there is no code here to "
|
|
465
460
|
+ "template, and the old reason (CF Access values inline) would send the next reader hunting for a "
|
|
@@ -524,12 +519,12 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
524
519
|
+ "flag was ever captured, so stopping it dropped nothing. The decision owed, if any, is whether the "
|
|
525
520
|
+ "feedback machinery is removed rather than parked; driver/run-activity.mjs still carries a note "
|
|
526
521
|
+ "addressed to whoever picks that up, and it is still unclaimed.",
|
|
527
|
-
note: "STOPPED
|
|
528
|
-
+ "
|
|
522
|
+
note: "STOPPED against an input nothing writes to any more; the owner's word came 2026-08-20 "
|
|
523
|
+
+ "and it was disabled the same evening.",
|
|
529
524
|
},
|
|
530
525
|
// ── ANOTHER PRODUCT'S UNITS ARE NOT DECLARED HERE, AND THAT IS THE RULE (ruling 594, 2026-09-27) ──
|
|
531
526
|
//
|
|
532
|
-
// Five entries stood here describing a different product's services on
|
|
527
|
+
// Five entries stood here describing a different product's services on production — a gateway, a
|
|
533
528
|
// notify adapter and three bridges — each with prose about how that box actually runs them. The owner's
|
|
534
529
|
// rule: deployment-specific entries have no place in public open-source code, and the health check needs
|
|
535
530
|
// its own services only.
|
|
@@ -549,15 +544,15 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
549
544
|
// deployments. Not here. This file ships.
|
|
550
545
|
{
|
|
551
546
|
unit: "trademark-test-deploy", runsOn: [],
|
|
552
|
-
measured: "2026-09-07, test
|
|
547
|
+
measured: "2026-09-07, read on the test deployment: LoadState=not-found. The hourly deploy runs as clearotron-deploy.timer, which has its own entry; this name is what that one used to be called.", tracked: null,
|
|
553
548
|
// AN EMPTY runsOn OWES A REASON, and this one is a supersession rather than an orphan: the hourly
|
|
554
|
-
// deploy still runs on the test
|
|
549
|
+
// deploy still runs on the test deployment, under the name `clearotron-deploy`, which has its own entry
|
|
555
550
|
// its own measurement. This name is what that unit used to be called. It is kept rather than deleted
|
|
556
551
|
// because the unit FILE is still tracked and an inventory of what ships should account for every
|
|
557
552
|
// tracked file — deleting the entry would make the file unaccounted for, which is the ratchet this
|
|
558
553
|
// file exists to keep shut.
|
|
559
554
|
orphanReason: "SUPERSEDED, NOT RETIRED — the hourly deploy of the test instance runs as `clearotron-deploy`, measured active there on 2026-09-07. This entry is the pre-rename name for the same job, so it runs on no box under THIS name and the boxes moved to the entry that replaced it. The tracked file stays until production is rebuilt, which is the same disposition the other retired units carry.",
|
|
560
|
-
untrackedReason: "the hourly --ff-only deploy of the TEST instance. It exists on
|
|
555
|
+
untrackedReason: "the hourly --ff-only deploy of the TEST instance. It exists on that deployment only "
|
|
561
556
|
+ "and by design: production never auto-deploys, so a tracked file shipped to both would be a unit "
|
|
562
557
|
+ "production must be trusted never to enable.",
|
|
563
558
|
},
|
|
@@ -565,7 +560,7 @@ export const UNIT_INVENTORY = Object.freeze([
|
|
|
565
560
|
// row was right until the moment it was wrong. That nightly rotation was retired in code by:
|
|
566
561
|
// bin/register-ledger-prune.mjs is deleted, record bodies now live in each run's own directory and
|
|
567
562
|
// are purged with the run, so there is no global file left to rotate. The row stayed here on
|
|
568
|
-
// purpose while the TIMER was still installed on the test
|
|
563
|
+
// purpose while the TIMER was still installed on the test deployment — dropping it first would have
|
|
569
564
|
// an installed unit invisible to the very check that exists to find installed units nothing
|
|
570
565
|
// describes. The timer was disabled and stopped on 2026-08-13, four hours before it would have
|
|
571
566
|
// fired against the deleted script, and this row went with it. Re-listing it would re-declare a
|
|
@@ -728,7 +723,7 @@ export function unitInventoryVerdict({
|
|
|
728
723
|
// units shipped WITH entries, those entries kept `runsOn: []`, and the pre-rename names kept the
|
|
729
724
|
// boxes. Every arm scoped to CHECKED_UNITS — which filters on a NON-EMPTY runsOn — therefore asked
|
|
730
725
|
// systemd about sixteen names that mostly named nothing and reported "0 active; 16 at rest", while
|
|
731
|
-
// four services ran under names no entry claimed. Measured on the test
|
|
726
|
+
// four services ran under names no entry claimed. Measured on the test deployment, 2026-09-07.
|
|
732
727
|
//
|
|
733
728
|
// This needs NO knowledge of which box it is running on, which is what makes it the right shape: an
|
|
734
729
|
// entry saying a unit runs on no box is contradicted by that unit running HERE, wherever here is.
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.4.0
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
|
@@ -23,6 +23,26 @@ var EXPANSION_MAX = 100000
|
|
|
23
23
|
// characters) so legitimate input is unaffected.
|
|
24
24
|
var EXPANSION_MAX_LENGTH = 4000000
|
|
25
25
|
|
|
26
|
+
// `expand` recurses once per level of brace *nesting* - both when expanding a
|
|
27
|
+
// set's comma members and when re-wrapping a set whose body is a single part.
|
|
28
|
+
// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
|
|
29
|
+
// level per chained group), which left nesting depth unbounded: about 3,100
|
|
30
|
+
// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
|
|
31
|
+
// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
|
|
32
|
+
// will follow nesting. It sits far above any realistic pattern and well below
|
|
33
|
+
// the depth at which the stack runs out.
|
|
34
|
+
var EXPANSION_MAX_DEPTH = 1000
|
|
35
|
+
|
|
36
|
+
// Bash keeps a quirk where a brace group followed by a comma set still expands
|
|
37
|
+
// (`{a},b}`). The parser implements it by rewriting the string and restarting
|
|
38
|
+
// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
|
|
39
|
+
// full passes over a string that itself grows by one `escClose` sentinel each
|
|
40
|
+
// time - quadratic in `n`, with a ~26x constant from the sentinel's length.
|
|
41
|
+
// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
|
|
42
|
+
// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
|
|
43
|
+
// times the scan may restart. Real `{a},b}` input needs a handful.
|
|
44
|
+
var EXPANSION_MAX_REWRITES = 1000
|
|
45
|
+
|
|
26
46
|
function numeric(str) {
|
|
27
47
|
return parseInt(str, 10) == str
|
|
28
48
|
? parseInt(str, 10)
|
|
@@ -46,34 +66,54 @@ function unescapeBraces(str) {
|
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
|
|
69
|
+
// Like `target.push(...items)` but doesn't overflow the stack
|
|
70
|
+
function pushAll(target, items) {
|
|
71
|
+
for (var i = 0; i < items.length; i++) {
|
|
72
|
+
target.push(items[i]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
// Basically just str.split(","), but handling cases
|
|
50
77
|
// where we have nested braced sections, which should be
|
|
51
78
|
// treated as individual members, like {a,{b,c},d}
|
|
52
79
|
function parseCommaParts(str) {
|
|
53
|
-
if (!str)
|
|
54
|
-
return [''];
|
|
55
|
-
|
|
56
80
|
var parts = [];
|
|
57
|
-
var m = balanced('{', '}', str);
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
// Walk the brace groups iteratively. Recursing on `post` once per group let a
|
|
83
|
+
// chain of them exhaust the stack - the parsing-side counterpart to
|
|
84
|
+
// the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
|
|
85
|
+
// `maxLength` can bound, since it happens before expansion.
|
|
86
|
+
//
|
|
87
|
+
// The part the next chunk continues
|
|
88
|
+
var carry = '';
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
var post = m.post;
|
|
65
|
-
var p = pre.split(',');
|
|
90
|
+
for (;;) {
|
|
91
|
+
var m = balanced('{', '}', str);
|
|
66
92
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
if (!m) {
|
|
94
|
+
var tail = str.split(',');
|
|
95
|
+
tail[0] = carry + tail[0];
|
|
96
|
+
pushAll(parts, tail);
|
|
97
|
+
return parts;
|
|
98
|
+
}
|
|
73
99
|
|
|
74
|
-
|
|
100
|
+
var pre = m.pre;
|
|
101
|
+
var body = m.body;
|
|
102
|
+
var post = m.post;
|
|
103
|
+
var p = pre.split(',');
|
|
75
104
|
|
|
76
|
-
|
|
105
|
+
p[0] = carry + p[0];
|
|
106
|
+
p[p.length-1] += '{' + body + '}';
|
|
107
|
+
|
|
108
|
+
if (!post.length) {
|
|
109
|
+
pushAll(parts, p);
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
carry = p.pop();
|
|
114
|
+
pushAll(parts, p);
|
|
115
|
+
str = post;
|
|
116
|
+
}
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
function expandTop(str, options) {
|
|
@@ -83,6 +123,8 @@ function expandTop(str, options) {
|
|
|
83
123
|
options = options || {};
|
|
84
124
|
var max = options.max == null ? EXPANSION_MAX : options.max;
|
|
85
125
|
var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
|
|
126
|
+
var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
|
|
127
|
+
var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
|
|
86
128
|
|
|
87
129
|
// I don't know why Bash 4.3 does this, but it does.
|
|
88
130
|
// Anything starting with {} will have the first two bytes preserved
|
|
@@ -94,7 +136,7 @@ function expandTop(str, options) {
|
|
|
94
136
|
str = '\\{\\}' + str.substr(2);
|
|
95
137
|
}
|
|
96
138
|
|
|
97
|
-
return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
|
|
139
|
+
return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
function identity(e) {
|
|
@@ -218,8 +260,18 @@ function expand(
|
|
|
218
260
|
str,
|
|
219
261
|
max,
|
|
220
262
|
maxLength,
|
|
263
|
+
maxDepth,
|
|
264
|
+
depth,
|
|
265
|
+
maxRewrites,
|
|
221
266
|
isTop
|
|
222
267
|
) {
|
|
268
|
+
// Too deeply nested to keep following: treat the rest as literal, the same
|
|
269
|
+
// way a group that cannot expand is already handled. Truncating rather than
|
|
270
|
+
// throwing keeps expansion total, matching `max` and `maxLength`.
|
|
271
|
+
if (depth > maxDepth) {
|
|
272
|
+
return [str];
|
|
273
|
+
}
|
|
274
|
+
|
|
223
275
|
// Consume the string's top-level brace groups left to right, threading a
|
|
224
276
|
// running set of combined prefixes (`acc`). Expanding the tail iteratively -
|
|
225
277
|
// rather than recursing on `m.post` once per group - keeps the native stack
|
|
@@ -240,6 +292,9 @@ function expand(
|
|
|
240
292
|
// `accBase[a]` records how much of `acc[a]` predates the current run;
|
|
241
293
|
// `combine` treats an expansion as empty when it adds nothing past that.
|
|
242
294
|
var accBase = [0]
|
|
295
|
+
// How many times the `{a},b}` rewrite below has restarted the scan. Each pass
|
|
296
|
+
// re-reads the whole string, so leaving this unbounded is quadratic.
|
|
297
|
+
var rewrites = 0
|
|
243
298
|
var dropEmpties = false
|
|
244
299
|
var firstGroup = true
|
|
245
300
|
var nextBase
|
|
@@ -271,7 +326,8 @@ function expand(
|
|
|
271
326
|
var isOptions = m.body.indexOf(',') >= 0;
|
|
272
327
|
if (!isSequence && !isOptions) {
|
|
273
328
|
// {a},b}
|
|
274
|
-
if (m.post.match(/,(?!,).*\}/)) {
|
|
329
|
+
if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
|
|
330
|
+
rewrites++;
|
|
275
331
|
str = m.pre + '{' + m.body + escClose + m.post;
|
|
276
332
|
// The rewritten string is expanded as if it were a fresh top-level one,
|
|
277
333
|
// so start a new empty-drop run: anchor the baseline at what `acc`
|
|
@@ -310,7 +366,7 @@ function expand(
|
|
|
310
366
|
var n = parseCommaParts(m.body);
|
|
311
367
|
if (n.length === 1 && n[0] !== undefined) {
|
|
312
368
|
// x{{a,b}}y ==> x{a}y x{b}y
|
|
313
|
-
n = expand(n[0], max, maxLength, false).map(embrace);
|
|
369
|
+
n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
|
|
314
370
|
//XXX is this necessary? Can't seem to hit it in tests.
|
|
315
371
|
/* c8 ignore start */
|
|
316
372
|
if (n.length === 1) {
|
|
@@ -349,7 +405,7 @@ function expand(
|
|
|
349
405
|
values = []
|
|
350
406
|
var valuesLength = 0
|
|
351
407
|
outer: for (var j = 0; j < n.length; j++) {
|
|
352
|
-
var expanded = expand(n[j], max, maxLength, false)
|
|
408
|
+
var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
|
|
353
409
|
for (var k = 0; k < expanded.length; k++) {
|
|
354
410
|
var v = expanded[k]
|
|
355
411
|
if (dropsEmpties && !v) continue
|
|
@@ -22,6 +22,26 @@ var EXPANSION_MAX = 100000
|
|
|
22
22
|
// characters) so legitimate input is unaffected.
|
|
23
23
|
var EXPANSION_MAX_LENGTH = 4000000
|
|
24
24
|
|
|
25
|
+
// `expand` recurses once per level of brace *nesting* - both when expanding a
|
|
26
|
+
// set's comma members and when re-wrapping a set whose body is a single part.
|
|
27
|
+
// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
|
|
28
|
+
// level per chained group), which left nesting depth unbounded: about 3,100
|
|
29
|
+
// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
|
|
30
|
+
// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
|
|
31
|
+
// will follow nesting. It sits far above any realistic pattern and well below
|
|
32
|
+
// the depth at which the stack runs out.
|
|
33
|
+
var EXPANSION_MAX_DEPTH = 1000
|
|
34
|
+
|
|
35
|
+
// Bash keeps a quirk where a brace group followed by a comma set still expands
|
|
36
|
+
// (`{a},b}`). The parser implements it by rewriting the string and restarting
|
|
37
|
+
// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
|
|
38
|
+
// full passes over a string that itself grows by one `escClose` sentinel each
|
|
39
|
+
// time - quadratic in `n`, with a ~26x constant from the sentinel's length.
|
|
40
|
+
// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
|
|
41
|
+
// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
|
|
42
|
+
// times the scan may restart. Real `{a},b}` input needs a handful.
|
|
43
|
+
var EXPANSION_MAX_REWRITES = 1000
|
|
44
|
+
|
|
25
45
|
function numeric(str) {
|
|
26
46
|
return parseInt(str, 10) == str
|
|
27
47
|
? parseInt(str, 10)
|
|
@@ -45,34 +65,54 @@ function unescapeBraces(str) {
|
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
|
|
68
|
+
// Like `target.push(...items)` but doesn't overflow the stack
|
|
69
|
+
function pushAll(target, items) {
|
|
70
|
+
for (var i = 0; i < items.length; i++) {
|
|
71
|
+
target.push(items[i]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
// Basically just str.split(","), but handling cases
|
|
49
76
|
// where we have nested braced sections, which should be
|
|
50
77
|
// treated as individual members, like {a,{b,c},d}
|
|
51
78
|
function parseCommaParts(str) {
|
|
52
|
-
if (!str)
|
|
53
|
-
return [''];
|
|
54
|
-
|
|
55
79
|
var parts = [];
|
|
56
|
-
var m = balanced('{', '}', str);
|
|
57
80
|
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
// Walk the brace groups iteratively. Recursing on `post` once per group let a
|
|
82
|
+
// chain of them exhaust the stack - the parsing-side counterpart to
|
|
83
|
+
// the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
|
|
84
|
+
// `maxLength` can bound, since it happens before expansion.
|
|
85
|
+
//
|
|
86
|
+
// The part the next chunk continues
|
|
87
|
+
var carry = '';
|
|
60
88
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
var post = m.post;
|
|
64
|
-
var p = pre.split(',');
|
|
89
|
+
for (;;) {
|
|
90
|
+
var m = balanced('{', '}', str);
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
if (!m) {
|
|
93
|
+
var tail = str.split(',');
|
|
94
|
+
tail[0] = carry + tail[0];
|
|
95
|
+
pushAll(parts, tail);
|
|
96
|
+
return parts;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var pre = m.pre;
|
|
100
|
+
var body = m.body;
|
|
101
|
+
var post = m.post;
|
|
102
|
+
var p = pre.split(',');
|
|
103
|
+
|
|
104
|
+
p[0] = carry + p[0];
|
|
105
|
+
p[p.length-1] += '{' + body + '}';
|
|
72
106
|
|
|
73
|
-
|
|
107
|
+
if (!post.length) {
|
|
108
|
+
pushAll(parts, p);
|
|
109
|
+
return parts;
|
|
110
|
+
}
|
|
74
111
|
|
|
75
|
-
|
|
112
|
+
carry = p.pop();
|
|
113
|
+
pushAll(parts, p);
|
|
114
|
+
str = post;
|
|
115
|
+
}
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
function expandTop(str, options) {
|
|
@@ -82,6 +122,8 @@ function expandTop(str, options) {
|
|
|
82
122
|
options = options || {};
|
|
83
123
|
var max = options.max == null ? EXPANSION_MAX : options.max;
|
|
84
124
|
var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
|
|
125
|
+
var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
|
|
126
|
+
var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
|
|
85
127
|
|
|
86
128
|
// I don't know why Bash 4.3 does this, but it does.
|
|
87
129
|
// Anything starting with {} will have the first two bytes preserved
|
|
@@ -93,7 +135,7 @@ function expandTop(str, options) {
|
|
|
93
135
|
str = '\\{\\}' + str.substr(2);
|
|
94
136
|
}
|
|
95
137
|
|
|
96
|
-
return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
|
|
138
|
+
return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
function embrace(str) {
|
|
@@ -205,8 +247,18 @@ function expand(
|
|
|
205
247
|
str,
|
|
206
248
|
max,
|
|
207
249
|
maxLength,
|
|
250
|
+
maxDepth,
|
|
251
|
+
depth,
|
|
252
|
+
maxRewrites,
|
|
208
253
|
isTop
|
|
209
254
|
) {
|
|
255
|
+
// Too deeply nested to keep following: treat the rest as literal, the same
|
|
256
|
+
// way a group that cannot expand is already handled. Truncating rather than
|
|
257
|
+
// throwing keeps expansion total, matching `max` and `maxLength`.
|
|
258
|
+
if (depth > maxDepth) {
|
|
259
|
+
return [str];
|
|
260
|
+
}
|
|
261
|
+
|
|
210
262
|
// Consume the string's top-level brace groups left to right, threading a
|
|
211
263
|
// running set of combined prefixes (`acc`). Expanding the tail iteratively -
|
|
212
264
|
// rather than recursing on `m.post` once per group - keeps the native stack
|
|
@@ -219,6 +271,9 @@ function expand(
|
|
|
219
271
|
// comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
|
|
220
272
|
// is on the final strings, so it is applied to whichever `combine` produces
|
|
221
273
|
// them (the one with no brace set left in the tail).
|
|
274
|
+
// How many times the `{a},b}` rewrite below has restarted the scan. Each pass
|
|
275
|
+
// re-reads the whole string, so leaving this unbounded is quadratic.
|
|
276
|
+
var rewrites = 0
|
|
222
277
|
var dropEmpties = false
|
|
223
278
|
var firstGroup = true
|
|
224
279
|
|
|
@@ -254,7 +309,8 @@ function expand(
|
|
|
254
309
|
var isOptions = m.body.indexOf(',') >= 0;
|
|
255
310
|
if (!isSequence && !isOptions) {
|
|
256
311
|
// {a},b}
|
|
257
|
-
if (m.post.match(/,(?!,).*\}/)) {
|
|
312
|
+
if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
|
|
313
|
+
rewrites++;
|
|
258
314
|
str = m.pre + '{' + m.body + escClose + m.post;
|
|
259
315
|
isTop = true;
|
|
260
316
|
continue;
|
|
@@ -282,7 +338,7 @@ function expand(
|
|
|
282
338
|
var n = parseCommaParts(m.body);
|
|
283
339
|
if (n.length === 1 && n[0] !== undefined) {
|
|
284
340
|
// x{{a,b}}y ==> x{a}y x{b}y
|
|
285
|
-
n = expand(n[0], max, maxLength, false).map(embrace);
|
|
341
|
+
n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
|
|
286
342
|
//XXX is this necessary? Can't seem to hit it in tests.
|
|
287
343
|
/* c8 ignore start */
|
|
288
344
|
if (n.length === 1) {
|
|
@@ -316,7 +372,7 @@ function expand(
|
|
|
316
372
|
values = []
|
|
317
373
|
var valuesLength = 0
|
|
318
374
|
outer: for (var j = 0; j < n.length; j++) {
|
|
319
|
-
var expanded = expand(n[j], max, maxLength, false)
|
|
375
|
+
var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
|
|
320
376
|
for (var k = 0; k < expanded.length; k++) {
|
|
321
377
|
var v = expanded[k]
|
|
322
378
|
if (dropsEmpties && !v) continue
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.0
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
|
@@ -24,6 +24,6 @@
|
|
|
24
24
|
"@types/react-dom": "^19.3.0",
|
|
25
25
|
"@vitejs/plugin-react": "^6.1.1",
|
|
26
26
|
"typescript": "^7.0.2",
|
|
27
|
-
"vite": "^8.3.
|
|
27
|
+
"vite": "^8.3.1"
|
|
28
28
|
}
|
|
29
29
|
}
|