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.
- package/.env.example +1 -1
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +7 -1
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +12 -3
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +16 -0
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +8 -8
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +47 -17
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/dead-names.mjs +17 -16
- package/scripts/env-audit.mjs +6 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/parent-watch.mjs +33 -0
- package/shared/running-start.mjs +14 -3
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
547
|
-
`the card keeps its heading,
|
|
548
|
-
ok(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 ===
|
|
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
|
|
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})`)
|
package/scripts/test-run.mjs
CHANGED
|
@@ -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
|
});
|
package/shared/client-door.mjs
CHANGED
|
@@ -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
|
+
}
|
package/shared/running-start.mjs
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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
|