clearotron 0.2.2 → 0.2.4

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.
Files changed (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // RUN EVERY CORPUS, AND REFUSE TO BE SILENT ABOUT ONE THAT WAS NOT RUN.
6
+ //
7
+ // node scripts/test-full.mjs # every corpus
8
+ // node scripts/test-full.mjs --only providers # one of them
9
+ // node scripts/test-full.mjs --list # the plan, run nothing
10
+ //
11
+ // ── WHAT THIS REPLACES, AND WHY A FLAG WAS NOT ENOUGH ────────────────────────────────────────────
12
+ //
13
+ // `npm run test:full --workspaces --if-present` reported this suite green while running part of it.
14
+ // Two separate holes, and only the smaller one is about the flag:
15
+ //
16
+ // - `providers/` holds twelve directories of register-adapter tests. ELEVEN OF THEM ARE NOT
17
+ // WORKSPACES — they have no package.json at all — so no `--workspaces` command can reach them,
18
+ // with or without `--if-present`. That is the larger half and no flag fixes it.
19
+ // - `--if-present` turns "this workspace declares no such script" into a silent skip. An absence
20
+ // reading as a pass is the shape this repository refuses everywhere else.
21
+ //
22
+ // So the population is declared here rather than inferred from a flag, and every member of it is
23
+ // either run or named. A corpus that was not run is a line on stdout, never an omission.
24
+ //
25
+ // ── THE RULE, AND THE ONE THING THAT MAY SOFTEN IT ───────────────────────────────────────────────
26
+ //
27
+ // A declared workspace that does not define `test:full` FAILS this command. That is the permissive
28
+ // half of the old gate written the other way round.
29
+ //
30
+ // One softening, because refusing here would be a false refusal: a workspace whose tests are already
31
+ // run by another corpus is COVERED, not missing. `providers/oauth-mcp-bridge` is a workspace with no
32
+ // scripts block whose ten tests run every time the providers corpus does. Failing it would refuse a
33
+ // workspace whose tests demonstrably execute, and giving it a script of its own would split one
34
+ // corpus across two mechanisms and run those files twice.
35
+ //
36
+ // THE EXEMPTION IS CHECKED, NEVER TRUSTED. For each entry below, this command asserts that the
37
+ // covering corpus's own file list actually reaches that workspace, and that the workspace has test
38
+ // files at all. Both halves matter: an intersection with an empty set is empty, so a workspace that
39
+ // lost its tests would satisfy a reachability check that only asked "is nothing missing". If someone
40
+ // narrows the providers file list until it no longer reaches this workspace, the exemption stops
41
+ // being true and this command says so instead of going quiet.
42
+ import { spawnSync } from "node:child_process";
43
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
44
+ import { join, dirname } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
48
+
49
+ /**
50
+ * Every `*.test.mjs` under `providers/`, at any depth, deduplicated and ordered.
51
+ *
52
+ * RECURSIVE, AND THAT IS THE POINT. This read `providers/<dir>/test/*.test.mjs` and nothing else, one
53
+ * level deep — and the exemption check below counted a covered workspace's own files with the SAME
54
+ * shape. So a test at `providers/x/y.test.mjs`, or at `providers/x/test/sub/y.test.mjs`, was in no
55
+ * corpus at all, both sides missed it together and therefore agreed, and every check reported clean
56
+ * under a command whose whole claim is that it runs every corpus. Two derived sides sharing one
57
+ * assumption cannot disagree about it; that is this file's own argument, and it applied here.
58
+ *
59
+ * `node_modules` is excluded because an installed dependency's tests are not this repository's corpus.
60
+ * Nothing else is: a file is either collected or it is a fault, never quietly outside the shape.
61
+ */
62
+ export function providerTestFiles(root = ROOT) {
63
+ const base = join(root, "providers");
64
+ if (!existsSync(base)) return [];
65
+ const out = new Set();
66
+ const walk = (dir, rel) => {
67
+ for (const d of readdirSync(dir, { withFileTypes: true })) {
68
+ if (d.name === "node_modules") continue;
69
+ const here = `${rel}/${d.name}`;
70
+ if (d.isDirectory()) walk(join(dir, d.name), here);
71
+ else if (d.name.endsWith(".test.mjs")) out.add(here);
72
+ }
73
+ };
74
+ walk(base, "providers");
75
+ // DEDUPLICATED ON PURPOSE. The shell form this replaced listed `providers/_shared/test/*.test.mjs`
76
+ // and then `providers/*/test/*.test.mjs`, which matches `_shared` as well — 51 paths for 37 files.
77
+ // Measured before it was called a defect: `node --test` runs a repeated path once, so the old form
78
+ // executed nothing twice. Building the list here makes that a property rather than a coincidence.
79
+ return [...out].sort();
80
+ }
81
+
82
+ /**
83
+ * A workspace with no `test:full`, whose tests another corpus already runs.
84
+ * `reaches` is the claim this command verifies on every run — not documentation.
85
+ */
86
+ export const COVERED_ELSEWHERE = [
87
+ {
88
+ workspace: "providers/oauth-mcp-bridge",
89
+ corpus: "providers",
90
+ why: "a workspace for its dependencies, not for a suite of its own: its tests sit in "
91
+ + "providers/<name>/test like every other adapter's and run with them. A script here would "
92
+ + "split one corpus across two mechanisms.",
93
+ },
94
+ ];
95
+
96
+ /**
97
+ * The corpora, discovered from the manifest rather than listed.
98
+ *
99
+ * `providerFiles` is injectable for one reason: the exemption check below compares the covering
100
+ * corpus's file list against the workspace's own directory, and if both are always read from the
101
+ * same place the comparison can never fail — coverage in appearance with nothing underneath. The
102
+ * state worth refusing is a NARROWED corpus, which is an edit to `providerTestFiles`, so an arm has
103
+ * to be able to hand in a narrowed list and see the refusal.
104
+ */
105
+ export function plan(root = ROOT, { providerFiles } = {}) {
106
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
107
+ const workspaces = manifest.workspaces ?? [];
108
+ const corpora = [];
109
+ const faults = [];
110
+
111
+ for (const ws of workspaces) {
112
+ let scripts = null;
113
+ try { scripts = JSON.parse(readFileSync(join(root, ws, "package.json"), "utf8")).scripts ?? {}; }
114
+ catch { scripts = null; }
115
+ // A DECLARED WORKSPACE WITH NO READABLE MANIFEST IS A FAULT, not a skip. The old command could
116
+ // not tell this apart from a workspace that simply had nothing to run.
117
+ if (scripts === null) { faults.push(`${ws} is a declared workspace and its package.json could not be read`); continue; }
118
+
119
+ if (scripts["test:full"]) {
120
+ corpora.push({ name: ws, kind: "workspace", argv: ["npm", "run", "test:full", "-w", ws] });
121
+ continue;
122
+ }
123
+
124
+ const cover = COVERED_ELSEWHERE.find((c) => c.workspace === ws);
125
+ if (!cover) {
126
+ faults.push(`${ws} is a declared workspace and defines no \`test:full\` script. Either give it `
127
+ + `one, or add it to COVERED_ELSEWHERE in scripts/test-full.mjs naming the corpus that runs `
128
+ + `its tests — which is checked, so the claim has to be true.`);
129
+ continue;
130
+ }
131
+ corpora.push({ name: ws, kind: "covered", cover });
132
+ }
133
+
134
+ const files = providerFiles ?? providerTestFiles(root);
135
+ corpora.push({
136
+ name: "providers",
137
+ kind: "files",
138
+ files,
139
+ argv: ["node", "scripts/test-run.mjs", "node", "--test", ...files],
140
+ });
141
+
142
+ // ── THE EXEMPTIONS, CHECKED ────────────────────────────────────────────────────────────────────
143
+ for (const c of corpora.filter((x) => x.kind === "covered")) {
144
+ const covering = corpora.find((x) => x.name === c.cover.corpus);
145
+ if (!covering || !Array.isArray(covering.files)) {
146
+ faults.push(`${c.name} is recorded as covered by the \`${c.cover.corpus}\` corpus, and this `
147
+ + `command has no such corpus with a file list to check that against.`);
148
+ continue;
149
+ }
150
+ const own = covering.files.filter((f) => f.startsWith(c.name + "/"));
151
+ // BOTH HALVES. A workspace that lost its own tests would pass a check that only asked whether
152
+ // the covering corpus was missing any of them, because nothing is missing from nothing.
153
+ const here = existsSync(join(root, c.name, "test"))
154
+ ? readdirSync(join(root, c.name, "test")).filter((f) => f.endsWith(".test.mjs")).length : 0;
155
+ if (!here) {
156
+ faults.push(`${c.name} is recorded as covered by the \`${c.cover.corpus}\` corpus, but it has `
157
+ + `no test files of its own, so that claim covers nothing. Remove the entry or restore the tests.`);
158
+ } else if (own.length !== here) {
159
+ faults.push(`${c.name} is recorded as covered by the \`${c.cover.corpus}\` corpus, which reaches `
160
+ + `${own.length} of its ${here} test file(s). The exemption is no longer true.`);
161
+ }
162
+ c.reaches = own.length;
163
+ c.hasTests = here;
164
+ }
165
+
166
+ return { corpora, faults };
167
+ }
168
+
169
+ function main() {
170
+ const only = (() => { const i = process.argv.indexOf("--only"); return i === -1 ? null : process.argv[i + 1]; })();
171
+ const listOnly = process.argv.includes("--list");
172
+ const { corpora, faults } = plan();
173
+
174
+ // FAULTS BEFORE ANYTHING RUNS. A missing corpus discovered after a green suite reads as an
175
+ // afterthought; discovered first, it is the answer.
176
+ if (faults.length) {
177
+ console.error(`\ntest-full: ${faults.length} declared workspace(s) this command cannot account for:\n`);
178
+ for (const f of faults) console.error(` - ${f}`);
179
+ console.error("");
180
+ process.exit(1);
181
+ }
182
+
183
+ const chosen = only ? corpora.filter((c) => c.name === only || c.name.endsWith("/" + only)) : corpora;
184
+ if (only && !chosen.length) {
185
+ console.error(`test-full: no corpus named ${only}. Known: ${corpora.map((c) => c.name).join(", ")}`);
186
+ process.exit(2);
187
+ }
188
+
189
+ if (listOnly) {
190
+ for (const c of chosen) {
191
+ console.log(c.kind === "covered"
192
+ ? ` ${c.name}: covered by the ${c.cover.corpus} corpus (${c.reaches}/${c.hasTests} file(s))`
193
+ : ` ${c.name}: ${c.kind === "files" ? `${c.files.length} file(s)` : "workspace script"}`);
194
+ }
195
+ return;
196
+ }
197
+
198
+ const ran = [];
199
+ for (const c of chosen) {
200
+ if (c.kind === "covered") { ran.push({ ...c, status: "covered" }); continue; }
201
+ console.log(`\n──── ${c.name} ────`);
202
+ // STDIO INHERITED, NOT CAPTURED. CI reads the child stream for the corpus-guard markers, and a
203
+ // runner that buffered its children would take those markers out of the log the check greps.
204
+ const r = spawnSync(c.argv[0], c.argv.slice(1), { cwd: ROOT, stdio: "inherit" });
205
+ ran.push({ ...c, status: r.status === 0 ? "ok" : "FAILED", code: r.status });
206
+ }
207
+
208
+ // ── THE SUMMARY, AT THE END, ON STDOUT ─────────────────────────────────────────────────────────
209
+ // The point of the whole file. A reader who scrolls to the bottom of a long run sees every corpus
210
+ // this command knows about and what became of it, so "the full suite is green" is a claim they can
211
+ // check rather than one they have to trust.
212
+ console.log(`\n──── what test-full ran ────`);
213
+ for (const c of ran) {
214
+ if (c.status === "covered") {
215
+ console.log(` covered ${c.name} — ${c.hasTests} file(s), run by the ${c.cover.corpus} corpus: ${c.cover.why}`);
216
+ } else {
217
+ const n = c.kind === "files" ? `${c.files.length} file(s)` : "workspace suite";
218
+ console.log(` ${c.status === "ok" ? "ran " : "FAILED "} ${c.name} — ${n}${c.code ? ` (exit ${c.code})` : ""}`);
219
+ }
220
+ }
221
+ const bad = ran.filter((c) => c.status === "FAILED");
222
+ console.log(bad.length ? `\n${bad.length} corpus/corpora failed.` : `\nEvery corpus above was run or accounted for.`);
223
+ if (bad.length) process.exit(1);
224
+ }
225
+
226
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -223,6 +223,22 @@ const providerChosen = ["CLEAROTRON_DATABASE"]
223
223
  .some((n) => String(process.env[n] ?? "").trim() !== "");
224
224
  if (!providerChosen) process.env.CLEAROTRON_DATABASE = "corsearch";
225
225
 
226
+ // ── THE SUITE'S OWN BRAND-OWNER FIXTURES, ASKED FOR IN ONE PLACE ──────────────────────────────────
227
+ //
228
+ // Three profiles in `driver/profiles/` are the suite's, and a resolved roster no longer offers them:
229
+ // an outside user's brand-owner picker listed them on a `git clone` install, where the packaging
230
+ // exclusion that protects the tarball does not apply. They are marked `testFixture` and the loader
231
+ // refuses them on every route.
232
+ //
233
+ // The checks still need them, under the same keys the baselines use, and most do not call the loader —
234
+ // they exercise code that does, so there is no argument to pass. One variable here is the whole ask,
235
+ // and it is visible: a reader of this file can see that the suite runs against a roster no install
236
+ // resolves, which is a thing worth being able to see rather than a default nobody set.
237
+ process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ??= "1";
238
+ // The demo account is refused from a fresh install's roster for the same reason and by the same
239
+ // mechanism; the suite asks for it here, once, so no check has to know it was hidden.
240
+ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
241
+
226
242
  // ── A PARTLY-BLIND SUITE IS NOT A FAILING SUITE ────────────────────────────────────────────
227
243
  //
228
244
  // `git worktree add` gives you no `node_modules`, nothing in this repo creates one, and the tracker asks
package/shared/README.md CHANGED
@@ -25,7 +25,8 @@ resolves the running commit for artifact provenance, but it cannot be the shared
25
25
  | `listen.mjs` | The one place a bind failure becomes a sentence rather than a stack trace. |
26
26
  | `driver-dir.mjs` | Where a run's `_driver/` is: the name, the path, and the one creation call. Product code no longer builds it by hand, so the directory's location — and one day its mode — is a decision in one place rather than a convention in 1123. |
27
27
  | `site-nav.mjs`, `anon-overlay.mjs` | The single top nav shared by the internal staff pages, and the display-only overlay that masks client names and marks so those pages can be screen-shared. |
28
- | `identifier-scan.mjs`, `identifier-blocklist.mjs`, `vetted-identities.mjs` | One matcher, one name table, one list of declared exceptions — shared by the guard that sweeps the tracked tree and the scan that sweeps the whole history. |
28
+ | `identifier-scan.mjs`, `identifier-sentinels.mjs`, `vetted-identities.mjs` | One matcher, one name table, one list of declared exceptions — shared by the guard that sweeps the tracked tree and the scan that sweeps the whole history. The real table is private; the sentinels are what make a zero from that sweep mean something. |
29
+ | `reference-guard-classes.mjs` | What a public tree must not acquire, as one table: citations, account names, home directories on the build machines, private repository names, this project's own words for how it is organised, and both attribution trailers. Read by the diff guard in `scripts/added-reference-check.mjs` and by the per-file floor beside it. |
29
30
 
30
31
  `brand/assets/` holds the vendored logo SVGs. `tools/` holds the two brand-system generators — see
31
32
  [`tools/README.md`](tools/README.md).
@@ -6,7 +6,7 @@
6
6
  // Feedback console went with the quality subsystem, so two of those five had not rendered for
7
7
  // months. Lets us screen-share the site without
8
8
  // revealing real client names or the marks being cleared: client names map to descriptive aliases
9
- // (e.g. Zephyr Beverages → "Beverage company") and marks blur out, behind ONE site-wide toggle (no
9
+ // (e.g. Coastline Drinks → "Beverage company") and marks blur out, behind ONE site-wide toggle (no
10
10
  // hover-reveal). Display-only — it never mutates stored data, run metadata, or form values.
11
11
  //
12
12
  // SHAPE (mirrors site-nav.mjs's NAV_CSS/siteNav split):
@@ -19,7 +19,7 @@
19
19
  // • anonToggle(cfg?) — the toggle button markup for the nav ('' when disabled).
20
20
  //
21
21
  // Tagging conventions the browser overlay understands:
22
- // <span data-anon="client" data-anon-key="zephyr">Zephyr Beverages</span> → text swapped for the alias
22
+ // <span data-anon="client" data-anon-key="coastline">Coastline Drinks</span> → text swapped for the alias
23
23
  // <span data-anon="mark" data-anon-key="…" data-anon-run="…">NOVA PULSE</span> → blurred (unless exempt)
24
24
  // <a data-anon-href data-anon-key="…" data-anon-run="…" href="…"> → href neutralised to # (unless exempt)
25
25
  // A run/client is EXEMPT (shown real) when its key ∈ demoAllow.clientKeys or its run matches
@@ -81,7 +81,7 @@ const OVERLAY_BODY = `(function(){
81
81
  var DEAD_TITLE = "hidden in privacy mode — toggle Privacy OFF to open";
82
82
  var DA = C.demoAllow || {clientKeys:[],runs:[]};
83
83
  // Case-insensitive client lookup, and the reason it is not redundant: surfaces tag rows by profile
84
- // KEY ("zephyr") and by display NAME ("Zephyr"), so lowercasing both makes a single-token name
84
+ // KEY ("coastline") and by display NAME ("Coastline"), so lowercasing both makes a single-token name
85
85
  // resolve to its key's alias. The two surfaces that tagged by NAME were Quality and Feedback, both
86
86
  // retired (tracker issue 265) — the fold stays because it is what makes the lookup total over either spelling,
87
87
  // not because those pages are coming back.
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // browser-temp-root.mjs — give a browser launch its own temp root, so an untidy exit leaks nothing shared.
4
+ //
5
+ // ── WHAT LEAKS, AND WHY IT IS NOT THE PROFILE DIRECTORY ───────────────────────────────────────────
6
+ //
7
+ // Chrome does not keep its process-singleton lock in `--user-data-dir`. It creates a directory named
8
+ // `com.google.Chrome.XXXXXX` in the system temp directory, holding `SingletonCookie` and a
9
+ // `SingletonSocket` unix socket, and symlinks the profile's entry at it. The reason is length: a
10
+ // profile path may be too long for a unix socket, so the lock is shortened through the temp root.
11
+ //
12
+ // Every launcher here already removes its own profile directory. None of them could remove that one,
13
+ // because none of them knows its name. Measured on a development machine 2026-09-09: 4,855 such
14
+ // directories under one shared temp root, the oldest five days old, still accumulating — the residue
15
+ // of runs that ended without running their cleanup, mostly cancelled continuous-integration jobs.
16
+ //
17
+ // The fix is not to find and delete them. It is to stop them being shared: the directory is created in
18
+ // `GetTempDir()`, which honours `TMPDIR`, so a run that exports its own temp root gets its own copy of
19
+ // the lock inside it and takes it away when the root goes.
20
+ //
21
+ // ── THE BYTE BUDGET, WHICH IS WHY THIS FILE REFUSES RATHER THAN TRUSTS ────────────────────────────
22
+ //
23
+ // A unix socket address is a fixed 108-byte field, so the socket path must fit in 107 bytes plus a
24
+ // terminator. The lock directory and socket name add a fixed 41 characters to the temp root, leaving
25
+ // 66 for the root itself. One character more and Chrome does not degrade — it aborts, with a fatal
26
+ // log line about the socket path and a core dump.
27
+ //
28
+ // That failure is why the check below exists. A crash in a continuous-integration step, reported as a
29
+ // core dump, is not something a reader connects to the length of a checkout path; and the length is
30
+ // not a constant of this project, because a run can be rooted anywhere a caller or a runner puts it.
31
+ // So the budget is asserted at the point the root is chosen, and the refusal states the length it
32
+ // measured, the limit, and the path — which is the whole of what a reader needs to fix it.
33
+ //
34
+ // The boundary is measured, not derived: walking root lengths across it, 66 starts and 67 aborts.
35
+ import { mkdtempSync, mkdirSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { join } from "node:path";
38
+ import { removeOnExit } from "./reap-on-exit.mjs";
39
+
40
+ /** A unix socket address field is 108 bytes; the last is the terminator. */
41
+ export const SUN_PATH_MAX = 107;
42
+
43
+ /** `/com.google.Chrome.XXXXXX/SingletonSocket` — what the lock adds to the temp root. */
44
+ export const LOCK_SUFFIX = "/com.google.Chrome.XXXXXX/SingletonSocket";
45
+
46
+ /** The longest temp root a browser can be launched under. */
47
+ export const MAX_ROOT_LENGTH = SUN_PATH_MAX - LOCK_SUFFIX.length;
48
+
49
+ /**
50
+ * The root's own prefix, and it is SHORT ON PURPOSE — this is the one lever on the budget.
51
+ *
52
+ * Every character here is spent on every browser run, and roots nest: under the suite runner the
53
+ * ambient temp directory already carries one `ct-testrun-XXXXXX` level before this is appended.
54
+ * Measured on a development machine 2026-09-09, ambient temp 18 characters:
55
+ *
56
+ * root length worst case with a 22-character name in the root with this prefix
57
+ * ambient 18 47 (19 to spare) 29 (37 to spare)
58
+ * one runner 36 65 (ONE to spare) 47 (19 to spare)
59
+ * nested 54 83 (refuses) 65 (1 to spare)
60
+ *
61
+ * The middle row is why this is not cosmetic. Naming the root after the check spent the budget on a
62
+ * label, and left an ordinary single-level suite run one character from refusing — a host with a
63
+ * temp directory one character longer, or this one renamed a level deeper, and every browser check
64
+ * would refuse at once while the tree looked broken. So the root is named for what it IS, and the
65
+ * check that made it is named by the directory INSIDE it, where length costs nothing.
66
+ *
67
+ * A leaked root is still sweepable by name, which was the point of naming it at all, and now it is
68
+ * attributable too: open it and the profile directory says which check left it.
69
+ */
70
+ export const ROOT_PREFIX = "ctb-";
71
+
72
+ /**
73
+ * Why `dir` cannot be a browser temp root, or `null` if it can.
74
+ *
75
+ * Returned rather than thrown so a caller can decide: a check that has already produced a result
76
+ * should say this and exit, not lose the result to an exception.
77
+ */
78
+ export function rootRefusal(dir) {
79
+ if (typeof dir !== "string" || dir === "") return "a browser temp root must be a non-empty path";
80
+ if (dir.length <= MAX_ROOT_LENGTH) return null;
81
+ return (
82
+ `browser temp root is ${dir.length} characters and the limit is ${MAX_ROOT_LENGTH}: ${dir}\n` +
83
+ ` A browser writes its process-singleton socket at <root>${LOCK_SUFFIX}, and a unix socket ` +
84
+ `address holds ${SUN_PATH_MAX} characters. Over that the browser aborts with a fatal error about ` +
85
+ `the socket path rather than reporting anything this check could read.\n` +
86
+ ` Run from a shorter path, or set TMPDIR to one.`
87
+ );
88
+ }
89
+
90
+ /** The refusal as an exception, for a caller with nothing to lose by throwing. */
91
+ export function assertRootFits(dir) {
92
+ const why = rootRefusal(dir);
93
+ if (why) throw new Error(why);
94
+ return dir;
95
+ }
96
+
97
+ /**
98
+ * A temp root for one browser run, removed when this process exits by any route it can observe.
99
+ *
100
+ * Rooted at the ambient temp directory rather than anywhere relative to the checkout, and that is a
101
+ * decision rather than a convenience: the ambient root is short by construction, while a checkout,
102
+ * a worktree or a per-session scratch directory is routinely past the budget above. Nesting is
103
+ * still correct — under a test runner that has already exported its own root, this lands inside it
104
+ * and is carried away with it.
105
+ */
106
+ export function browserTempRoot() {
107
+ const root = mkdtempSync(join(tmpdir(), ROOT_PREFIX));
108
+ assertRootFits(root);
109
+ removeOnExit(root);
110
+ return root;
111
+ }
112
+
113
+ /**
114
+ * The environment a browser must be spawned with so its singleton lock lands under `root`.
115
+ *
116
+ * `TMPDIR` is the whole mechanism, so this refuses rather than passing a root that cannot work.
117
+ */
118
+ export function browserEnv(root, env = process.env) {
119
+ assertRootFits(root);
120
+ return { ...env, TMPDIR: root };
121
+ }
122
+
123
+ /**
124
+ * A temp root and a profile directory inside it, plus the environment to spawn with.
125
+ *
126
+ * The profile goes INSIDE the root so one removal covers both. Several call sites used to make the
127
+ * profile directly in the shared temp directory and remove it on their success path; that left the
128
+ * singleton lock behind on every other path, which is the leak this module exists for.
129
+ */
130
+ export function browserRun(label = "browser") {
131
+ const root = mkdtempSync(join(tmpdir(), ROOT_PREFIX));
132
+ assertRootFits(root);
133
+ const keep = removeOnExit(root);
134
+ // The CALLER'S NAME GOES HERE, inside the root, where it costs nothing against the socket budget.
135
+ // Callers pass the name they used to prefix the root with, trailing dash and all, so this reads as
136
+ // `<root>/home-check` and an operator opening a leaked root learns which check left it.
137
+ const profile = join(root, label.replace(/-+$/, "") || "browser");
138
+ mkdirSync(profile, { recursive: true });
139
+ // `keep` deregisters the root from the exit sweep. A caller with a `--keep` flag MUST call it, or
140
+ // the flag reads as working while the directory it promised goes at exit.
141
+ return { root, profile, env: browserEnv(root), keep };
142
+ }
@@ -9,7 +9,7 @@
9
9
  // so that one line decides which tree the whole deployment executes — and connect said nothing about
10
10
  // having written it.
11
11
  //
12
- // Driven on testuser: a lane made a worktree to test a branch, ran `connect` from it once, and
12
+ // Driven on the test deployment: a lane made a worktree to test a branch, ran `connect` from it once, and
13
13
  //
14
14
  // · the next deploy tick fast-forwarded the DETACHED worktree and failed with "You are not currently
15
15
  // on a branch" — a message about branches, for a box that was silently a merge behind; and
@@ -41,6 +41,7 @@
41
41
 
42
42
  import { join } from "node:path";
43
43
  import { challengeVerdict, blockedByAccessChallenge, challengeNote } from "./mcp-challenge.mjs"; // — F57
44
+ import { whatHoldsPort } from "./os-advice.mjs";
44
45
 
45
46
  /** The unit that runs the client door. Installed like any other; started only by this module. */
46
47
  export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
@@ -162,7 +163,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
162
163
  const standing = fenceOn && unitInstalled;
163
164
  // ── CONFIGURED IS NOT RUNNING ──────────────────────────────────────────────
164
165
  //
165
- // Measured by role-e2e: a `connect` that died at `daemon-reload` had ALREADY written the denylist and
166
+ // Measured in testing: a `connect` that died at `daemon-reload` had ALREADY written the denylist and
166
167
  // installed both unit files, and `doctor` then said "the client door is on" while the unit was
167
168
  // inactive and nothing listened on its port. The trigger was a shell without a session bus, which is
168
169
  // not the product's fault — but any failure at that step leaves the same half-applied state
@@ -187,7 +188,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
187
188
  * FIVE answers, and the two that matter are the two the caller used to get wrong in opposite directions.
188
189
  *
189
190
  * CONFIGURED IS NOT RUNNING. A `connect` that died at `daemon-reload` has already
190
- * written the fence and placed both units, so every angle reads as set up; role-e2e measured `doctor`
191
+ * written the fence and placed both units, so every angle reads as set up; testing measured `doctor`
191
192
  * saying "the client door is on" over an inactive unit with nothing on its port. That state is a
192
193
  * problem and it is the reason this function exists.
193
194
  *
@@ -278,7 +279,7 @@ export function describeDoorState(door, {
278
279
  + "That is very likely why it cannot start: the product's ports are fixed defaults, so another "
279
280
  + "install or a stray process holds it, and the unit fails over and over against a port it will "
280
281
  + `never get. Find the holder before re-applying anything — \`${startCmd}\`'s own refusal names `
281
- + "the port and the variable, and `ss -ltnp` names the process." };
282
+ + `the port and the variable, and \`${whatHoldsPort("<the door's port>")}\` names the process.` };
282
283
  }
283
284
  if (looping) {
284
285
  return { level: "problem",
@@ -7,7 +7,7 @@
7
7
  // `process.env.PROFILE_DIR || join(HERE, "profiles")` while the runs, the roster, the account picker and
8
8
  // the artifacts door read `CLEAROTRON_CUSTOMERS_DIR`. Nothing set `PROFILE_DIR` — not `onboard`, not
9
9
  // `.env.example`, not the box's own env — so the settings surface served the product's BUNDLED DEMO
10
- // BUNDLE (aurora, generic, petcary, zephyr) while the runs served the real store.
10
+ // BUNDLE while the runs served the real store.
11
11
  //
12
12
  // WHAT THE CLIENT SAW: every brand owner added the documented way was "These settings are not available
13
13
  // to you." The clearance itself submitted and ran under their framework. Only the surface that
@@ -13,7 +13,7 @@
13
13
  // So the rules live here and nowhere else. Anything that wants to know whether a line is clean imports
14
14
  // from this file.
15
15
  //
16
- // THE TABLE IS NOT HERE. shared/identifier-blocklist.mjs owns the names (sentinels, or sentinels ∪ the
16
+ // THE TABLE IS NOT HERE. shared/identifier-sentinels.mjs owns the names (sentinels, or sentinels ∪ the
17
17
  // private roster). This file owns only the matching — it names no identity of its own beyond the
18
18
  // platform tokens ALLOWED_CONTEXT has to spell out, which is why it declares itself in
19
19
  // DECLARATION_SOURCES.
@@ -124,3 +124,38 @@ export function describeAuthGaps({ gaps, modes }, envFile) {
124
124
  }
125
125
  return out;
126
126
  }
127
+
128
+ // ── WHAT PUTS SOMETHING IN FRONT OF A DOOR ───────────────────────────────────────────────────────────
129
+ //
130
+ // A different question from the one above, and kept apart from it deliberately: `authRequirements`
131
+ // answers "what will this unit refuse to start without", which includes values that say nothing about
132
+ // what is in front of it — a grants file, a local passphrase user, an allowed-domain list. This answers
133
+ // "does something OUTSIDE this deployment resolve to these port numbers", which is the only question a
134
+ // launcher may move a door on.
135
+ //
136
+ // Every name here is an alternative in some door's `oneOf`: a Cloudflare Access team, or that door's own
137
+ // OIDC issuer. The AUDIENCES are deliberately absent — they are `all` entries, not alternatives, and an
138
+ // audience set with neither a team nor an issuer refuses to start on every face, so it can never be the
139
+ // only evidence of a proxy.
140
+ //
141
+ // THE CLIENT DOOR HAS ITS OWN ISSUER SPELLING and it is not legacy: `mcp-server/http-server-client.mjs`
142
+ // reads `CLIENT_MCP_OIDC_ISSUER || TRADEMARK_MCP_OIDC_ISSUER`, and its fail-closed admits a start on the
143
+ // client spelling alone with no team set. A deployment fronting only its client door that way once read
144
+ // as unfronted here, which is the state that would have moved a door behind a proxy addressed to the old
145
+ // number — up, and unreachable.
146
+ //
147
+ // An arm holds this list to the doors themselves rather than to this comment: it reads the entrypoints
148
+ // `bin/start.mjs` spawns and asserts every team-or-issuer name they read appears below.
149
+ export const FRONTING_VARIABLES = Object.freeze([
150
+ "CF_ACCESS_TEAM",
151
+ "PORTAL_OIDC_ISSUER",
152
+ "TRADEMARK_MCP_OIDC_ISSUER",
153
+ "CLIENT_MCP_OIDC_ISSUER",
154
+ ]);
155
+
156
+ /**
157
+ * The fronting values this environment has set — empty means nothing outside resolves to these doors.
158
+ *
159
+ * @returns {string[]} the names that are set, in the order above
160
+ */
161
+ export const frontingVariablesSet = (env = {}) => FRONTING_VARIABLES.filter((k) => SET(env, k));
@@ -39,6 +39,7 @@
39
39
  import { existsSync } from "node:fs";
40
40
  import { basename, sep } from "node:path";
41
41
  import { INSTALL_DIR, inspectShim, pathPosition, shimDir, shimPath } from "./verb-shim.mjs";
42
+ import { chdirPrefix } from "./os-advice.mjs";
42
43
 
43
44
  /**
44
45
  * Filesystem reads, injectable so the arms can drive a machine that is not this one.
@@ -92,6 +93,34 @@ export function standFrom(dir) {
92
93
  return at === -1 ? s : s.slice(0, at);
93
94
  }
94
95
 
96
+ /**
97
+ * WHICH ROUTE THIS INSTALL ARRIVED BY — a package, or a source checkout.
98
+ *
99
+ * The setup wizard has two spellings and only one of them works for any given reader. Someone who ran
100
+ * `npm i clearotron` has no package scripts, so `npm run setup` is a command they cannot type; someone
101
+ * working in a clone of the source has the scripts but no `clearotron` binary linked for them, because
102
+ * npm links a package's bin for its dependencies and never for itself. Naming one of the two on a
103
+ * screen is a coin flip, and the screen that names it is read by people who have just installed and
104
+ * have nothing else to go on.
105
+ *
106
+ * The answer is a property of where this file sits on disk, so it is derived from that. A package —
107
+ * local or global — is unpacked inside a `node_modules` directory; a checkout is not. `standFrom`
108
+ * already draws that line for the sibling question of where to stand, and reusing it is deliberate:
109
+ * two predicates for "am I a package" would be two chances to disagree.
110
+ *
111
+ * NO PATH IS RETURNED, and that is the point of splitting it out from `invocationForm`. That function's
112
+ * `prefix` can be `cd /srv/whatever && npx `, which is the server's own layout, its account name and
113
+ * its directory structure. This answers the same question with a word, so the answer can travel to a
114
+ * browser without carrying the machine with it.
115
+ *
116
+ * @param {string} dir where this module is installed
117
+ * @returns {'packaged'|'checkout'}
118
+ */
119
+ export function installRoute(dir = INSTALL_DIR) {
120
+ const s = String(dir ?? "");
121
+ return standFrom(s) === s ? "checkout" : "packaged";
122
+ }
123
+
95
124
  /**
96
125
  * WHERE npm PUT THE EXECUTABLE for a GLOBAL install of this package —.
97
126
  *
@@ -181,7 +210,10 @@ export function invocationForm(env = process.env, io = FS, installDir = INSTALL_
181
210
  // to fix it — through the very shim it had just called broken. The advice for repairing a route
182
211
  // cannot travel that route. Both remaining forms go around it.
183
212
  return {
184
- form: "in-place", prefix: `cd ${standFrom(installDir)} && npx `, shim: path, dir,
213
+ // THE SEPARATOR IS THE PART THAT FAILS OFF POSIX, not the path. `chdirPrefix` answers for the
214
+ // shell the reader is actually in; on Windows PowerShell 5.1 there is no `&&` at all, so this
215
+ // line was a parse error there before its backslashes mattered. Reported from a real run.
216
+ form: "in-place", prefix: `${chdirPrefix(standFrom(installDir))}npx `, shim: path, dir,
185
217
  onPath: false, shadowedBy: null, shimKind: shim.kind, otherInstall: shim.installDir,
186
218
  staleInterpreter: shim.interpreterMissing === true ? shim.interpreter : null,
187
219
  };