clearotron 0.3.2-beta.11 → 0.3.2-beta.12

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 (46) hide show
  1. package/.env.example +1 -1
  2. package/bin/clearotron.mjs +14 -0
  3. package/bin/connect.mjs +68 -3
  4. package/bin/key.mjs +6 -1
  5. package/bin/onboard.mjs +7 -1
  6. package/bin/passphrase.mjs +4 -2
  7. package/bin/start.mjs +12 -3
  8. package/build-info.json +2 -2
  9. package/driver/CHANGELOG.md +16 -0
  10. package/driver/contract-vocabulary.mjs +5 -5
  11. package/driver/engine/mcp/recording-server.mjs +1 -1
  12. package/driver/engine/mcp/supplemental.mjs +22 -5
  13. package/driver/knockout-next-step.mjs +72 -0
  14. package/driver/named-band.mjs +1 -1
  15. package/driver/package.json +1 -1
  16. package/driver/pipeline-knockout.mjs +26 -0
  17. package/driver/portal-report.mjs +21 -2
  18. package/driver/portal-service.mjs +8 -8
  19. package/driver/publish/render-knockout.mjs +14 -9
  20. package/driver/register-availability.mjs +1 -1
  21. package/driver/register-plan.mjs +81 -11
  22. package/driver/result-noun-fields.mjs +3 -1
  23. package/driver/stages-knockout.mjs +1 -1
  24. package/driver/suite-census.json +47 -17
  25. package/driver/verify-knockout.mjs +0 -27
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  30. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  34. package/providers/oauth-mcp-bridge/package.json +1 -1
  35. package/scripts/ask-ai-render-check.mjs +26 -1
  36. package/scripts/dead-names.mjs +17 -16
  37. package/scripts/env-audit.mjs +6 -0
  38. package/scripts/repo-writes.mjs +42 -0
  39. package/scripts/report-sections-render-check.mjs +245 -0
  40. package/scripts/report-theme-render-check.mjs +31 -3
  41. package/scripts/settings-render-check.mjs +10 -7
  42. package/scripts/test-run.mjs +63 -4
  43. package/shared/client-door.mjs +20 -0
  44. package/shared/parent-watch.mjs +33 -0
  45. package/shared/running-start.mjs +14 -3
  46. package/shared/scope.mjs +22 -3
@@ -0,0 +1,245 @@
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
+ // report-sections-render-check.mjs — the report's section strip sits under the Reads and shows how far the
4
+ // reader has got, measured in a real browser.
5
+ //
6
+ // node scripts/report-sections-render-check.mjs [--shots <dir>] [--keep]
7
+ //
8
+ // THE DEFECT, from the owner on the test instance (2026-09-19): scrolling the report never moved the strip,
9
+ // so "Summary" stayed marked whatever was in view; and with sixteen reads the strip sat BESIDE the Reads, the
10
+ // reads wrapping into two ragged columns while the strip floated at mid-height. His ruling: the strip goes
11
+ // under the Reads on its own full-width row, and it shows PROGRESS. Every section reached so far is marked,
12
+ // none at the top, and scrolling back up un-marks them the same way. A click still jumps.
13
+ //
14
+ // WHAT IS MEASURED: a real renderer's report (the demo's full country search, put through the demo's own
15
+ // publisher and the portal's own embed preparation) inside the real portal build, with twelve reads of one
16
+ // mark. At 1440 and 400 wide, in both themes: the strip's row is under the Reads row; then the page is
17
+ // scrolled so each section's start reaches the bottom of the pinned header, down and back up, and the
18
+ // number of filled entries must equal the number of sections reached. Where each section starts is read
19
+ // from INSIDE the frame, independently of what the document reports to the portal. Last, one entry is
20
+ // pressed and the page must jump to that section and mark it.
21
+ import { spawn, spawnSync } from 'node:child_process'
22
+ import { createServer } from 'node:http'
23
+ import { readFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
24
+ import { join, dirname, extname, normalize } from 'node:path'
25
+ import { tmpdir } from 'node:os'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { reapOnExit } from '../shared/reap-on-exit.mjs'
28
+ import { browserRun } from '../shared/browser-temp-root.mjs'
29
+ import { prepareReportForEmbed } from '../driver/portal-report.mjs'
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url))
32
+ const ROOT = join(HERE, '..')
33
+ const DIST = join(ROOT, 'portal-ui', 'dist')
34
+ const keep = process.argv.includes('--keep')
35
+ const argValue = (flag) => (process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : null)
36
+ const shotsDir = argValue('--shots')
37
+ if (!existsSync(join(DIST, 'index.html'))) { console.error(`no build at ${DIST} — run: npm run build:ui`); process.exit(2) }
38
+ if (shotsDir) mkdirSync(shotsDir, { recursive: true })
39
+
40
+ // ── A REAL REPORT ───────────────────────────────────────────────────────────────────────────────────────
41
+ const pool = mkdtempSync(join(tmpdir(), 'sections-check-'))
42
+ const published = spawnSync(process.execPath, [join(ROOT, 'bin', 'example.mjs'), '--once', '--pool', pool, '--product', 'full-country-search'], { encoding: 'utf8' })
43
+ if (published.status !== 0) { console.error(`the demo publisher failed:\n${published.stderr || published.stdout}`); process.exit(2) }
44
+ const runDir = readdirSync(pool).map((d) => join(pool, d)).find((d) => existsSync(join(d, 'report.html')))
45
+ if (!runDir) { console.error(`the demo publisher wrote no report under ${pool}`); process.exit(2) }
46
+ const prepared = prepareReportForEmbed(readFileSync(join(runDir, 'report.html'), 'utf8'), {})
47
+ const SECTIONS = prepared.sections.map((s) => s.id)
48
+ if (SECTIONS.length < 4) { console.error(`the report announces ${SECTIONS.length} sections; this check needs a long one`); process.exit(2) }
49
+
50
+ // ── TWELVE READS OF ONE MARK ────────────────────────────────────────────────────────────────────────────
51
+ const KEY = 'acme'
52
+ const MARK = 'VENQORI'
53
+ const PRODUCTS = [['full-country-search', 'Full country search'], ['knockout-search', 'Knockout search'],
54
+ ['multi-country-focus-search', 'Multi-country focus search'], ['global-preliminary-search', 'Global preliminary search']]
55
+ const RUNS = Array.from({ length: 12 }, (_, i) => {
56
+ const [product, productName] = PRODUCTS[i % PRODUCTS.length]
57
+ const day = String(18 - i).padStart(2, '0')
58
+ return {
59
+ account: KEY, kind: 'clearance', state: 'delivered', band: 'Moderate', tone: 'medium', bands: [], marks: [],
60
+ reportSchema: 2, held: false, report: `/portal/report/tmpa-read-${i + 1}/`, step: null, stepN: null, stepTotal: null,
61
+ reason: null, failedStage: null, pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
62
+ projectKey: null, projectName: null, markName: MARK, title: MARK, date: `2026-09-${day}`,
63
+ issuedAt: `2026-09-${day}T12:00:00.000Z`, product, productName, runId: `tmpa-read-${i + 1}`,
64
+ }
65
+ })
66
+ const RUN_ID = RUNS[0].runId
67
+
68
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.png': 'image/png', '.woff2': 'font/woff2' }
69
+ const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
70
+ const file = (res, full) => {
71
+ if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
72
+ res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
73
+ res.end(readFileSync(full))
74
+ }
75
+ const server = createServer((req, res) => {
76
+ const p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname)
77
+ if (p === '/portal/api/me') return json(res, { email: 'reader@example-firm.com', permissions: { run: true, manage: false }, access: [{ kind: 'account', account: KEY }], accounts: [KEY], accountNames: { [KEY]: 'Acme' } })
78
+ if (p === '/portal/admin/roster') return json(res, { customers: [{ key: KEY, name: 'Acme' }] })
79
+ if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
80
+ if (p === '/portal/api/runs') return json(res, { runs: RUNS })
81
+ if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
82
+ if (p === '/portal/api/mcp-access') return json(res, { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null, offers: [] })
83
+ if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
84
+ // Every read serves the one real document; its own resources resolve against the run directory and the
85
+ // pool, as they do from a real pool.
86
+ const inReport = /^\/portal\/report\/[^/]+\/(.*)$/.exec(p)
87
+ if (inReport) {
88
+ if (!inReport[1]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(prepared.html) }
89
+ return file(res, join(runDir, normalize(inReport[1]).replace(/^(\.\.[/\\])+/, '')))
90
+ }
91
+ if (p.startsWith('/portal/report/')) return file(res, join(pool, normalize(p.slice('/portal/report/'.length)).replace(/^(\.\.[/\\])+/, '')))
92
+ const base = p.split('?')[0]
93
+ return file(res, join(DIST, base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')))
94
+ })
95
+ await new Promise((r) => server.listen(0, '127.0.0.1', r))
96
+ const origin = `http://127.0.0.1:${server.address().port}`
97
+
98
+ // ── THE BROWSER ────────────────────────────────────────────────────────────────────────────────────────
99
+ const { profile, env: chromeEnv, keep: keepRoot } = browserRun('report-sections-check-')
100
+ if (keep) keepRoot()
101
+ const chrome = spawn('google-chrome', [
102
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
103
+ '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
104
+ `--user-data-dir=${profile}`, '--window-size=1440,900', '--remote-debugging-port=0', 'about:blank',
105
+ ], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
106
+ reapOnExit(chrome)
107
+ const wsUrl = await new Promise((resolve, reject) => {
108
+ let devtools = ''
109
+ const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || '(nothing)'}`)), 60000)
110
+ chrome.stderr.on('data', (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]) } })
111
+ })
112
+ const ws = new WebSocket(wsUrl)
113
+ let id = 0
114
+ const pending = new Map()
115
+ // The report's frame is sandboxed to an opaque origin, so Chrome runs it as a process of its own and it
116
+ // is not in the page's frame tree. It is attached to as its own target; the newest one is the document on
117
+ // screen.
118
+ let frameSession = null
119
+ ws.addEventListener('message', (e) => {
120
+ const m = JSON.parse(e.data)
121
+ if (m.method === 'Target.attachedToTarget' && m.params?.targetInfo?.type === 'iframe') frameSession = m.params.sessionId
122
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
123
+ })
124
+ await new Promise((r) => ws.addEventListener('open', r))
125
+ const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
126
+ const { result: targets } = await send('Target.getTargets')
127
+ const page = targets.targetInfos.find((t) => t.type === 'page')
128
+ const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
129
+ const sessionId = sess.sessionId
130
+ const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
131
+ const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
132
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms))
133
+ const until = async (expr, ms = 15000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await value(expr)) return true; await wait(100) } return false }
134
+ await cmd('Page.enable')
135
+ await cmd('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true })
136
+
137
+ // Where each section starts, read inside the frame's own document — not the list it posted to the portal.
138
+ const anchorsInFrame = async () => {
139
+ if (!frameSession) return null
140
+ const answer = await new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId: frameSession, method: 'Runtime.evaluate',
141
+ params: { expression: `${JSON.stringify(SECTIONS)}.map((id) => { const el = document.getElementById(id); return el ? Math.round(el.getBoundingClientRect().top + window.scrollY) : null })`, returnByValue: true } })) })
142
+ return answer.result?.result?.value ?? null
143
+ }
144
+ const strip = () => value(`[...document.querySelectorAll('nav.report-sections .report-section')].map((b) => (b.dataset.reached === 'true' ? 'y' : 'x') + (b.getAttribute('aria-current') === 'true' ? '*' : '')).join(' ')`)
145
+ const filled = (s) => (s ?? '').split(' ').filter((e) => e.startsWith('y')).length
146
+ const current = (s) => (s ?? '').split(' ').findIndex((e) => e.endsWith('*'))
147
+ // Put a document offset at the reading line — the pinned header's bottom, plus the margin a jump lands at.
148
+ const scrollToDocY = async (docY) => {
149
+ for (let i = 0; i < 6; i++) {
150
+ const delta = await value(`(() => { const f = document.querySelector('iframe'); const h = document.querySelector('.report-head');
151
+ const d = f.getBoundingClientRect().top + ${docY} - (h.getBoundingClientRect().bottom + 10);
152
+ window.scrollTo(0, window.scrollY + d); return d })()`)
153
+ await wait(250)
154
+ if (Math.abs(delta ?? 0) < 1) break
155
+ }
156
+ }
157
+ const atFoot = () => value(`window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2`)
158
+ const atTop = () => value(`window.scrollY <= 0`)
159
+ const shot = async (name) => {
160
+ if (!shotsDir) return
161
+ await wait(250)
162
+ const { result } = await cmd('Page.captureScreenshot', { format: 'png' })
163
+ writeFileSync(join(shotsDir, name), Buffer.from(result.data, 'base64'))
164
+ }
165
+
166
+ const fail = []
167
+ const ok = []
168
+ for (const width of [1440, 400]) {
169
+ await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 })
170
+ for (const theme of ['light', 'dark']) {
171
+ const at = `${width} ${theme}`
172
+ await cmd('Page.navigate', { url: `${origin}/portal/` })
173
+ await value(`(() => { try { localStorage.setItem('cordillera-theme', '${theme}') } catch (e) {} return 1 })()`)
174
+ await cmd('Page.navigate', { url: `${origin}/portal/result/${RUN_ID}` })
175
+ if (!(await until(`document.querySelectorAll('nav.report-sections .report-section').length === ${SECTIONS.length} && document.querySelectorAll('.report-nav .pill').length === ${RUNS.length}`))) {
176
+ fail.push(`${at}: the header never showed ${RUNS.length} reads and ${SECTIONS.length} sections`)
177
+ continue
178
+ }
179
+ await wait(800)
180
+
181
+ // THE PLACEMENT: the strip's row is under the Reads row and starts where it starts.
182
+ const box = await value(`(() => { const r = document.querySelector('.report-nav > div').getBoundingClientRect(); const s = document.querySelector('nav.report-sections').getBoundingClientRect();
183
+ return { readsBottom: r.bottom, readsLeft: r.left, stripTop: s.top, stripLeft: s.left, stripRight: s.right, navRight: document.querySelector('.report-nav').getBoundingClientRect().right } })()`)
184
+ if (!box) fail.push(`${at}: the Reads row or the strip is missing`)
185
+ else if (box.stripTop < box.readsBottom - 1) fail.push(`${at}: the strip (top ${box.stripTop}) is beside the Reads (bottom ${box.readsBottom}), not under them`)
186
+ else if (Math.abs(box.stripLeft - box.readsLeft) > 1) fail.push(`${at}: the strip starts at ${box.stripLeft}, not where the Reads start (${box.readsLeft})`)
187
+ else if (Math.abs(box.stripRight - box.navRight) > 1) fail.push(`${at}: the strip's row is not the header's full width`)
188
+ else ok.push(`${at}: the strip is its own full-width row under the ${RUNS.length} reads`)
189
+ await value('window.scrollTo(0, 0)')
190
+ await wait(300)
191
+ await shot(`sections-${width}-${theme}-top.png`)
192
+
193
+ // THE PROGRESS, down and back up. Nothing at the top; one more filled at each section's start.
194
+ const anchors = await anchorsInFrame()
195
+ if (!anchors || anchors.some((a) => a === null)) { fail.push(`${at}: could not read where the sections start inside the frame`); continue }
196
+ const seen = []
197
+ const expectAt = async (label, want, wantCurrent) => {
198
+ const s = await strip()
199
+ seen.push(`${label}=${s}`)
200
+ if (filled(s) !== want || (wantCurrent !== undefined && current(s) !== wantCurrent)) fail.push(`${at}: at ${label} the strip reads "${s}", expected ${want} filled`)
201
+ }
202
+ await expectAt('top', 0, -1)
203
+ // One step into the document, the first section is reached.
204
+ await value('window.scrollTo(0, 40)')
205
+ await wait(300)
206
+ await expectAt('40px down', 1, 0)
207
+ const order = [...SECTIONS.keys(), ...[...SECTIONS.keys()].reverse().slice(1)]
208
+ for (const i of order) {
209
+ await scrollToDocY(anchors[i])
210
+ // The first section starts under the header, so reaching its start is the top of the page, where
211
+ // the owner's rule is that nothing is marked yet.
212
+ const [top, foot] = [await atTop(), await atFoot()]
213
+ await expectAt(SECTIONS[i], top ? 0 : foot ? SECTIONS.length : i + 1, top ? -1 : foot ? SECTIONS.length - 1 : i)
214
+ if (i === 1 && width === 1440) await shot(`sections-${width}-${theme}-two-reached.png`)
215
+ }
216
+ // Between two starts, the count is the one behind.
217
+ await scrollToDocY(Math.round((anchors[1] + anchors[2]) / 2))
218
+ await expectAt(`between ${SECTIONS[1]} and ${SECTIONS[2]}`, 2, 1)
219
+ await value('window.scrollTo(0, 0)')
220
+ await wait(300)
221
+ await expectAt('top again', 0, -1)
222
+ if (!fail.some((f) => f.startsWith(at))) ok.push(`${at}: progress followed the scroll both ways — ${seen.join(', ')}`)
223
+
224
+ // A CLICK STILL JUMPS, and the section jumped to counts as reached.
225
+ const target = 2
226
+ const before = await value('window.scrollY')
227
+ await value(`document.querySelectorAll('nav.report-sections .report-section')[${target}].click()`)
228
+ let settled = before, last = -1
229
+ for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await value('window.scrollY') }
230
+ const s = await strip()
231
+ const foot = await atFoot()
232
+ if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page`)
233
+ else if (filled(s) !== (foot ? SECTIONS.length : target + 1)) fail.push(`${at}: after the jump to "${SECTIONS[target]}" the strip reads "${s}"`)
234
+ else ok.push(`${at}: a press jumps to "${SECTIONS[target]}" and marks it — ${s}`)
235
+ }
236
+ }
237
+
238
+ for (const line of ok) console.log(` ok ${line}`)
239
+ for (const line of fail) console.log(` FAIL ${line}`)
240
+ try { ws.close() } catch { /* going anyway */ }
241
+ try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
242
+ server.close()
243
+ if (!keep) rmSync(pool, { recursive: true, force: true })
244
+ console.log(fail.length ? `\nreport-sections-render-check: ${fail.length} measurement(s) failed.` : `\nreport-sections-render-check: the strip sits under the reads and follows the reader, both ways, at both widths and both themes.`)
245
+ process.exit(fail.length ? 1 : 0)
@@ -16,6 +16,12 @@
16
16
  // switch is pressed, never an attribute set by hand, and three things must hold on each press: the
17
17
  // document took the theme, its background went the right way, and the load id did not change — the page
18
18
  // repainted in place rather than reloading.
19
+ //
20
+ // AND ONE SECTION MENU, AT BOTH THEMES. The portal draws the report's section menu in its own header and
21
+ // the frame must carry none. The document served here is shaped as a report rendered before 2026-09-18,
22
+ // with the menu beside the report's header rather than inside it. That is most of the archive, and the
23
+ // shape that left a second menu in the frame, its current item red on red in the dark theme. The probe
24
+ // counts the menus inside the frame; the portal's are counted on the page.
19
25
  import { spawn } from 'node:child_process'
20
26
  import { createServer } from 'node:http'
21
27
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
@@ -53,7 +59,7 @@ const FRAMEWORK = { framework_key: 'house-triage', title: 'House triage',
53
59
  const PROBE = `<script>(function(){
54
60
  var id=Math.random().toString(36).slice(2);
55
61
  function post(){try{parent.postMessage({themeProbe:1,loadId:id,theme:document.documentElement.getAttribute('data-theme'),
56
- bg:getComputedStyle(document.body).backgroundColor},'*');}catch(e){}}
62
+ bg:getComputedStyle(document.body).backgroundColor,menus:document.querySelectorAll('nav.strip,[data-sec]').length},'*');}catch(e){}}
57
63
  new MutationObserver(post).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});
58
64
  post();
59
65
  })();</script>`
@@ -61,7 +67,18 @@ const REPORT = (() => {
61
67
  const html = renderKnockoutHtml({ marks: [{ name: MARK, rating: 'Low', classes: [9], basis: 'Nothing identical in the field screened.',
62
68
  factors: ['No identical name was found.'], counterFactors: [], mitigation: '', assessment: '', findings: [] }],
63
69
  batch: { executiveSummary: 'One name screened.' } }, FRAMEWORK, { runId: RUN_ID, overall: 'Low', identity: { identity: 'Knockout search' }, issuedDate: '2026-09-18' })
64
- const served = prepareReportForEmbed(html, {}).html
70
+ // THE OLD SHAPE: today's menu moved to just after the header's closing tag, where the renderer put it
71
+ // before 2026-09-18. A document with no menu at all would make the count below prove nothing.
72
+ const strip = html.match(/<nav class="[^"]*\bstrip\b[^"]*"[^>]*>[\s\S]*?<\/nav>/)?.[0]
73
+ if (!strip) { console.error('the rendered report has no section menu, so this check could not build the archived shape'); process.exit(2) }
74
+ const without = html.replace(strip, '')
75
+ const head = without.indexOf('<div class="rep-stickyhead')
76
+ const tags = /<\/?div\b[^>]*>/g
77
+ tags.lastIndex = head
78
+ let depth = 0, m, close = -1
79
+ while ((m = tags.exec(without))) { depth += m[0].startsWith('</') ? -1 : 1; if (depth === 0) { close = m.index + m[0].length; break } }
80
+ if (close < 0) { console.error('the report header has no closing tag, so this check could not build the archived shape'); process.exit(2) }
81
+ const served = prepareReportForEmbed(without.slice(0, close) + strip + without.slice(close), {}).html
65
82
  const at = served.lastIndexOf('</body>')
66
83
  return at < 0 ? served + PROBE : served.slice(0, at) + PROBE + served.slice(at)
67
84
  })()
@@ -151,9 +168,19 @@ const press = () => value(`(() => { const b = document.querySelector('button[ari
151
168
 
152
169
  const fail = []
153
170
  const ok = []
171
+ // ONE MENU: none in the frame, one in the portal's header, under whichever theme is showing.
172
+ const menus = async (theme, probe) => {
173
+ const portal = await value(`document.querySelectorAll('nav.report-sections').length`)
174
+ if (!probe || typeof probe.menus !== 'number') fail.push(`${theme}: the frame never reported its section menus`)
175
+ else if (probe.menus !== 0) fail.push(`${theme}: the frame carries ${probe.menus} section-menu element(s) beside the portal's own`)
176
+ else ok.push(`${theme}: no section menu inside the frame`)
177
+ if (portal !== 1) fail.push(`${theme}: the portal draws ${portal} section menu(s) where it should draw one`)
178
+ else ok.push(`${theme}: the portal draws the one section menu`)
179
+ }
154
180
  const light = await settle('light')
155
181
  if (!light || light.theme !== 'light') fail.push(`the embedded report never took the portal's light theme — last report from the frame: ${JSON.stringify(light)}`)
156
182
  else ok.push(`light: the report took the portal's theme, ground ${light.bg} (luminance ${luminance(light.bg)?.toFixed(2)})`)
183
+ await menus('light', light)
157
184
  await shot('report-light.png')
158
185
 
159
186
  if (!(await press())) fail.push('the portal\'s theme switch is not on the Result screen')
@@ -165,6 +192,7 @@ else {
165
192
  if (light && dark.loadId !== light.loadId) fail.push('the report reloaded to change theme — it must repaint in place')
166
193
  else ok.push('dark: repainted in place, the same load of the document')
167
194
  }
195
+ await menus('dark', dark)
168
196
  await shot('report-dark.png')
169
197
 
170
198
  await press()
@@ -178,5 +206,5 @@ for (const line of fail) console.log(` FAIL ${line}`)
178
206
  try { ws.close() } catch { /* going anyway */ }
179
207
  try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
180
208
  server.close()
181
- console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme, in place.`)
209
+ console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme in place, under the portal's one section menu.`)
182
210
  process.exit(fail.length ? 1 : 0)
@@ -381,7 +381,10 @@ const SPEC = {
381
381
  caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
382
382
  adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
383
383
  blurLine: 'On the top bar. It covers every mark and company on screen, and an open report whole. It stays as you left it on this computer.',
384
- reset: 'Lost the passphrase? Run clearotron passphrase --reset on the machine running this portal. It mints a new one and prints it once.',
384
+ // The sign-in page's wording is the owner's, approved 2026-09-19 and used verbatim.
385
+ identity: (email) => `This Clearotron has one user: ${email}. Enter its passphrase.`,
386
+ reset: 'The passphrase was printed once when this Clearotron first started. Lost it? Run clearotron passphrase --reset on the machine running this portal. It prints a new one, once, for the same user.',
387
+ key: 'A key from clearotron key issue is for an AI assistant, not for this page.',
385
388
  addPeople: 'To add people, put it behind a login system such as your company single sign-on. How to set that up',
386
389
  }
387
390
 
@@ -543,18 +546,18 @@ for (const theme of ['light', 'dark']) {
543
546
  await setTheme(theme)
544
547
  const closed = (await evalIn(signInProbe)) ?? {}
545
548
  out[`${label}-closed-${theme}`] = closed
546
- ok(closed.heading === 'Sign in' && closed.identity === `Clearotron portal, as ${EMAIL}.` && closed.field && closed.button === 'Sign in',
547
- `the card keeps its heading, identity line, field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
548
- ok(closed.lead === 'This Clearotron signs in one person: you.' && closed.leadBold, `one bold line under the button (saw ${JSON.stringify(closed.lead)})`)
549
+ ok(closed.heading === 'Sign in' && closed.identity === SPEC.identity(EMAIL) && closed.field && closed.button === 'Sign in',
550
+ `the card keeps its heading, names the install's one user, and keeps the field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
551
+ ok(!closed.lead && !closed.leadBold, `the "signs in one person" line is gone from under the button (saw ${JSON.stringify(closed.lead)})`)
549
552
  ok(closed.summary === 'Administrator help' && closed.open === false && closed.hintsShown === 0,
550
553
  `the administrator's lines sit in a closed "Administrator help" fold (saw ${JSON.stringify({ summary: closed.summary, open: closed.open, shown: closed.hintsShown })})`)
551
554
  if (!reset) await capture(`sign-in-help-closed-${theme}`)
552
555
  await press('.card details.fold > summary')
553
556
  const opened = (await evalIn(signInProbe)) ?? {}
554
557
  out[`${label}-open-${theme}`] = opened
555
- ok(opened.open === true && opened.hintsShown === 2, `pressing it opens both lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
556
- ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.addPeople,
557
- `the fold holds the product's two lines word for word (saw ${JSON.stringify(opened.hints)})`)
558
+ ok(opened.open === true && opened.hintsShown === 3, `pressing it opens its three lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
559
+ ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.key && opened.hints[2] === SPEC.addPeople,
560
+ `the fold holds the owner's three lines word for word (saw ${JSON.stringify(opened.hints)})`)
558
561
  ok(opened.setUp?.text === 'How to set that up' && opened.setUp?.href === LOGIN_IN_FRONT_DOC, `with the "How to set that up" link (saw ${JSON.stringify(opened.setUp)})`)
559
562
  ok(opened.codeInside && !opened.overflowsPage && opened.cardWidth <= 420,
560
563
  `the reset command stays inside the card at its own width (card ${opened.cardWidth}px, ${opened.codeLines} line(s), inside: ${opened.codeInside})`)
@@ -46,11 +46,11 @@
46
46
  // about paths it did not create is how a tidy-up becomes an outage.
47
47
 
48
48
  import { spawn } from "node:child_process";
49
- import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync } from "node:fs";
49
+ import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync, writeFileSync, realpathSync } from "node:fs";
50
50
  import { delimiter, dirname, join, parse as parsePath, resolve, sep } from "node:path";
51
51
  import { fileURLToPath } from "node:url";
52
- import { tmpdir } from "node:os";
53
- import { snapshotRepo, repoWrites, explainRepoWrites } from "./repo-writes.mjs";
52
+ import { tmpdir, homedir } from "node:os";
53
+ import { snapshotRepo, repoWrites, explainRepoWrites, snapshotHome, explainHomeWrites } from "./repo-writes.mjs";
54
54
 
55
55
 
56
56
  // ── TAIL — THIS WRAPPER READS BOTH SPELLINGS; IT DOES NOT TRANSLATE THE ENVIRONMENT ───────────
@@ -699,7 +699,57 @@ mkdirSync(process.env.CLEAROTRON_SUITE_TELEMETRY_DIR, { recursive: true });
699
699
  // Taken HERE, at the last statement before the child exists, so nothing this runner does to the tree
700
700
  // between the two reads can be mistaken for something a test did. The comparison is in `close`, below.
701
701
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
702
+
703
+ // ── AND NO PRODUCT COMMAND A TEST STARTS REBUILDS THIS CHECKOUT'S PORTAL BUNDLE ─────────────────────
704
+ //
705
+ // `start` rebuilds `portal-ui/dist` when it is older than `portal-ui/src` — right for a reader whose pull
706
+ // left the bundle behind, and a write into this checkout when a test starts `start` from it. Eight driver
707
+ // files do. On a clone where somebody once built the portal and then pulled, the first of them to run
708
+ // rebuilt the bundle mid-suite and the guard above failed the run; the next run found the bundle fresh and
709
+ // passed (measured 2026-09-18). A fresh clone never sees it: with no bundle there is nothing to rebuild.
710
+ //
711
+ // THE SAME SEAT AS THE ENGINE SHIMS, AND NO PRODUCT CHANGE. `start` runs the bare word `npm`, so a shim at
712
+ // the front of PATH answers it for every child that keeps this PATH. It refuses exactly one command —
713
+ // `npm run build:ui` with this checkout as its working directory — and hands everything else to the npm
714
+ // it found on PATH. `start` reports a refused rebuild and carries on serving, which is its behaviour on
715
+ // any failed build. A child that composes its own PATH walks past this, and the guard above still
716
+ // catches what it writes.
717
+ function onPath(name, pathValue) {
718
+ for (const d of String(pathValue ?? "").split(delimiter).filter(Boolean)) {
719
+ const p = join(d, name);
720
+ try { if (statSync(p).isFile()) return p; } catch { /* not here */ }
721
+ }
722
+ return null;
723
+ }
724
+ // COUNTED, because the refusal itself is printed into a child that a test usually captures: the run
725
+ // reports how many rebuilds it turned away, so a stale bundle is named once where a reader can see it.
726
+ let npmShimDir = null;
727
+ if (process.platform !== "win32") {
728
+ const realNpm = onPath("npm", process.env.PATH);
729
+ if (realNpm) {
730
+ const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
731
+ npmShimDir = join(root, "npm-shim");
732
+ mkdirSync(npmShimDir, { recursive: true });
733
+ writeFileSync(join(npmShimDir, "npm"), [
734
+ "#!/bin/sh",
735
+ "# Written by scripts/test-run.mjs for one suite run; removed with the run's temp root.",
736
+ `if [ "$1" = run ] && [ "$2" = build:ui ] && [ "$(pwd -P)" = ${q(realpathSync(REPO_ROOT))} ]; then`,
737
+ " echo '[test-run] refused: npm run build:ui in the checkout. A suite run does not rebuild its portal bundle.' >&2",
738
+ ` echo refused >> ${q(join(npmShimDir, "refused"))}`,
739
+ " exit 1",
740
+ "fi",
741
+ `exec ${q(realNpm)} "$@"`,
742
+ "",
743
+ ].join("\n"), { mode: 0o755 });
744
+ process.env.PATH = npmShimDir + delimiter + process.env.PATH;
745
+ }
746
+ }
702
747
  const repoBefore = snapshotRepo(REPO_ROOT);
748
+ // AND THE HOME IT RUNS AS, read at the same moment for the same reason (`repo-writes.mjs` says which
749
+ // folders and why). HOME as the child inherits it, which is the home every unpinned product command in
750
+ // the run resolves.
751
+ const RUN_HOME = String(process.env.HOME ?? "").trim() || homedir();
752
+ const homeBefore = snapshotHome(RUN_HOME);
703
753
 
704
754
 
705
755
  child = spawn(argv[0], argv.slice(1), {
@@ -741,7 +791,14 @@ child.on("error", (e) => {
741
791
  });
742
792
 
743
793
  child.on("close", (code, signal) => {
794
+ let refusedBuilds = 0;
795
+ try { if (npmShimDir) refusedBuilds = readFileSync(join(npmShimDir, "refused"), "utf8").split("\n").filter(Boolean).length; }
796
+ catch { /* none refused */ }
744
797
  cleanup();
798
+ if (refusedBuilds) {
799
+ console.error(`[test-run] turned away ${refusedBuilds} rebuild(s) of this checkout's portal bundle: it is older than`);
800
+ console.error(" portal-ui/src. `npm run build:ui` brings it current; until then doctor's arms report it stale.");
801
+ }
745
802
  // The exit code IS the result — CI reads it. Never swallow a failure to report a tidy cleanup.
746
803
  // A SIGNALLED RUN IS NOT EVIDENCE ABOUT WRITES: it was cancelled mid-flight, so a half-finished
747
804
  // fixture proves nothing and re-raising is the honest answer. The guard below never runs on that path.
@@ -749,8 +806,10 @@ child.on("close", (code, signal) => {
749
806
 
750
807
  const wrote = repoWrites(repoBefore, snapshotRepo(REPO_ROOT), REPO_ROOT);
751
808
  if (wrote.length) for (const line of explainRepoWrites(wrote)) console.error(line);
809
+ const wroteHome = repoWrites(homeBefore, snapshotHome(RUN_HOME), RUN_HOME);
810
+ if (wroteHome.length) for (const line of explainHomeWrites(wroteHome, RUN_HOME)) console.error(line);
752
811
  // THIS MAY TURN A GREEN RUN RED. IT MUST NEVER TURN A RED RUN GREEN — a failing suite keeps its own
753
812
  // exit code, because what the tests found matters more than what they wrote while finding it.
754
813
  const childCode = code ?? 1;
755
- process.exit(childCode !== 0 ? childCode : (wrote.length ? 1 : 0));
814
+ process.exit(childCode !== 0 ? childCode : (wrote.length || wroteHome.length ? 1 : 0));
756
815
  });
@@ -134,6 +134,26 @@ export function demoTokenSecret(base, io) {
134
134
  * cannot type, and it names the demo's base so the verb reads that demo's secret and guest list. An
135
135
  * install moved with `--base` is named too, for its guest list; the default install needs neither.
136
136
  */
137
+ /**
138
+ * What an account key is for, in the owner's words (2026-09-19), printed first by every command that
139
+ * hands one over. ONE COPY: `key issue` and `connect --base` both print it, and a second copy of an
140
+ * approved sentence is the one that drifts. `reset` is the passphrase command as the caller composes it.
141
+ */
142
+ export function keyPurposeLine({ email, brand, reset }) {
143
+ return `This key lets an AI assistant act as ${email} through ${brand}'s client door. `
144
+ + `It is not a portal sign-in; the portal uses the passphrase (${reset}).`;
145
+ }
146
+
147
+ /**
148
+ * The command that connects an assistant to a running DEMO: it mints the key and names the client door
149
+ * in one step, so nobody issues a key by hand (owner, 2026-09-19). A demo keeps its own secret and guest
150
+ * list in its folder, which is why the folder is named.
151
+ */
152
+ export function demoConnectCommand({ prefix = "", base }) {
153
+ const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
154
+ return `${prefix}clearotron connect --base ${q(base)}`;
155
+ }
156
+
137
157
  export function keyIssueCommand({ prefix = "", demo = false, user = null, base = null, defaultBase = null } = {}) {
138
158
  const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
139
159
  const who = demo && user ? user : "<email>";
@@ -0,0 +1,33 @@
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
+ //
4
+ // NOTICE WHEN THE PROCESS THAT STARTED US IS GONE.
5
+ //
6
+ // `npx clearotron demo` runs as npm → `sh -c clearotron demo …` → the launcher → the demo. A TERM to the
7
+ // pid a shell hands back for a backgrounded `npx …` reaches npm, npm passes it to that `sh`, and `sh` exits
8
+ // without passing it on. The launcher is reparented and the demo runs on, holding its three ports, with
9
+ // nothing saying so. Measured on npm 10.9.8, 2026-09-18. Neither npm nor that `sh` is ours to change, and
10
+ // a process is told nothing when its parent dies on Linux or macOS, so the launcher asks: its parent pid
11
+ // changes the moment it is reparented, to init or to the nearest subreaper.
12
+ //
13
+ // A process whose parent is already init when it starts (started under `setsid`, or by a service manager
14
+ // that exits) never sees its parent pid change, so this never fires for it.
15
+
16
+ /**
17
+ * Call `onGone` once, the first time this process's parent pid differs from the one it had when this was
18
+ * called. Returns a function that stops watching. The timer never keeps the process alive on its own.
19
+ */
20
+ export function watchParent(onGone, { intervalMs = 1000, parentPid = () => process.ppid } = {}) {
21
+ const first = parentPid();
22
+ let fired = false;
23
+ const timer = setInterval(() => {
24
+ if (fired) return;
25
+ const now = parentPid();
26
+ if (now === first) return;
27
+ fired = true;
28
+ clearInterval(timer);
29
+ onGone({ was: first, now });
30
+ }, intervalMs);
31
+ timer.unref?.();
32
+ return () => clearInterval(timer);
33
+ }
@@ -13,9 +13,9 @@
13
13
  // record whose process is gone is read as absent, and `status` asks the portal itself before saying the
14
14
  // product is up. Three answers, all honest: up; started but not answering; not running.
15
15
 
16
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
- import { join } from "node:path";
18
+ import { dirname, join } from "node:path";
19
19
 
20
20
  /**
21
21
  * Where the records live: one file per serving process, beside the settings and the revocation list.
@@ -37,9 +37,14 @@ export function pidAlive(pid) {
37
37
  /**
38
38
  * Record one serving start. Returns the function that removes the record; calling it twice is harmless,
39
39
  * so the caller can hang it on both its own shutdown and the process's `exit`.
40
+ *
41
+ * THE FOLDERS THIS RECORD MADE GO WITH IT, when nothing else is in them. On a home that had none, the
42
+ * demo's record created `~/.config/clearotron/running` and its parent, and a clean stop left both behind
43
+ * under a banner saying nothing of the demo was left (measured 2026-09-19). A folder that was already
44
+ * there, or that holds anything else, stays: `rmdirSync` removes only an empty directory.
40
45
  */
41
46
  export function recordRunning(rec, { dir = runningDir() } = {}) {
42
- mkdirSync(dir, { recursive: true });
47
+ const made = mkdirSync(dir, { recursive: true });
43
48
  const file = join(dir, `${rec.pid}.json`);
44
49
  const tmp = `${file}.tmp`;
45
50
  writeFileSync(tmp, `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
@@ -49,6 +54,12 @@ export function recordRunning(rec, { dir = runningDir() } = {}) {
49
54
  if (gone) return;
50
55
  gone = true;
51
56
  try { rmSync(file, { force: true }); } catch { /* already gone */ }
57
+ if (made) {
58
+ for (let d = dir; ; d = dirname(d)) {
59
+ try { rmdirSync(d); } catch { break; } // not empty, or gone: either way, not ours to take
60
+ if (d === made) break;
61
+ }
62
+ }
52
63
  };
53
64
  }
54
65
 
package/shared/scope.mjs CHANGED
@@ -259,7 +259,7 @@ export const TOOL_SCOPES = {
259
259
  // The ONLY artifact a user (report-link) token may read via read_artifact — THE report (one report;
260
260
  // clientSummary was a second version by another name and is retired from client reach — the file
261
261
  // remains an internal cover-note source ops tokens may read). Everything else
262
- // (narrative, audit, run.jsonl, skepticFlags, lisaEyeReview, matterContext, caseLaw, register axes,
262
+ // (narrative, audit, run.jsonl, skepticFlags, seniorEyeReview, matterContext, caseLaw, register axes,
263
263
  // status.json, …) is internal and stays sealed from a user token.
264
264
  // Exported so the server's Resources surface (ListResources/ReadResource) gates to the SAME set.
265
265
  /**
@@ -833,7 +833,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
833
833
  throw new Error("forbidden: client account access is not enabled on this door");
834
834
  return { kind: "account", runId: null, sub: t.sub, verbs: null, ...personScope(t.sub, t.accounts) };
835
835
  }
836
- if (t.scope !== "user") throw new Error("forbidden: the client surface accepts only a run-scoped user token or an account key");
836
+ if (t.scope !== "user") throw new Error(`forbidden: the client surface accepts only a run-scoped user token or an account key${otherDoor("staff")}`);
837
837
  return { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
838
838
  }
839
839
  if (local) return { kind: "ops", runId: null, sub: "local", verbs: null, accounts: "*" };
@@ -842,7 +842,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
842
842
  // An account key belongs to the CLIENT door and nowhere else. Falling through would land it in the
843
843
  // `user` arm below with runId:null — a "run-bound" scope bound to no run, which every run-pinning check
844
844
  // downstream would then wave through. Refuse it here instead.
845
- if (t.scope === "account") throw new Error("forbidden: an account key is only accepted on the client surface");
845
+ if (t.scope === "account") throw new Error(`forbidden: an account key is only accepted on the client surface${otherDoor("client")}`);
846
846
  return t.scope === "ops"
847
847
  ? { kind: "ops", runId: null, sub: t.sub, verbs: t.verbs, accounts: t.accounts ?? "*" }
848
848
  : { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
@@ -866,6 +866,25 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
866
866
  throw new Error("forbidden: no run-scoped token and not a firm-staff identity — refusing (internal read-all requires proven firm staff)");
867
867
  }
868
868
 
869
+ /**
870
+ * WHERE THE OTHER DOOR IS, for the refusal that turns a key away from the wrong one: " (client surface:
871
+ * <address>)", or nothing when this process was not told. A key refused as "only accepted on the client
872
+ * surface" read as a permissions problem, and the next thing a reader did was the wrong thing; the demo's
873
+ * own output led an assistant to the staff door with an account key (measured on a published beta,
874
+ * 2026-09-19). Each door is handed the other's port and host by `start` and by the shared unit settings,
875
+ * under the names the other door listens on. The client door's public address, when one is set, is the
876
+ * one a remote assistant can reach, so it wins. A wildcard bind is named as loopback, where a reader on
877
+ * this machine reaches it. PURE given its env.
878
+ */
879
+ export function otherDoor(which, env = process.env) {
880
+ const [url, host, port] = which === "client"
881
+ ? [env.CLEAROTRON_CLIENT_MCP_URL, env.CLIENT_MCP_HTTP_HOST, env.CLIENT_MCP_HTTP_PORT]
882
+ : [null, env.TRADEMARK_MCP_HTTP_HOST, env.TRADEMARK_MCP_HTTP_PORT];
883
+ const at = String(url ?? "").trim() || (String(port ?? "").trim()
884
+ ? `http://${!host || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${String(port).trim()}/mcp` : "");
885
+ return at ? ` (${which} surface: ${at})` : "";
886
+ }
887
+
869
888
  // The ENFORCEMENT chokepoint. Returns the (possibly run-pinned) args to dispatch, or throws an Error the
870
889
  // CallTool handler surfaces as an MCP error. ops ⇒ everything. user/internal ⇒ no write tools. user ⇒ also
871
890
  // no cross-run tool, and every run-scoped call is PINNED to the token's bound run (a mismatching explicit