From 19e6cb0f1e06e5015e1ceab7a93e676aedc7c7b9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:21:50 -0600 Subject: [PATCH] fix(cli): address ui797 support review findings (#797) --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + packages/cli/live/README.md | 27 ++++- packages/cli/live/budget-supervisor.mjs | 60 +++++++--- packages/cli/live/budget-supervisor.test.mjs | 118 ++++++++++++++++++- packages/cli/live/isolated-identity.test.mjs | 39 ++++-- packages/cli/live/support-common.mjs | 2 +- packages/cli/live/test-support.mjs | 47 +++++++- packages/cli/live/ui-browser.mjs | 83 +++++++++++-- packages/cli/live/ui-browser.test.mjs | 72 +++++++++-- 10 files changed, 393 insertions(+), 58 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 1911f6310..11341c2ce 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6196,3 +6196,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa 2026-11-05 | #797 ui797 support implementation phase | Explicit lead charter from 05fdfef9ea1fdddf58c725c04f229ca8559fc054 on feat/ui797-support: local isolated OIDC/TLS, persistent CDP browser CLI and durable metered campaign supervisor only under packages/cli/live. Queue next refused linked worktree (exit 2); canonical queue/forge ownership remains with lead. Initial author tests identified explicit-config selection and browser evidence-count defects, corrected locally; real browser SSO and full focused gates in progress. No .53/live access, push, runtime/role/contract edits, attribution trailers or stash. 2026-11-05 | #797 ui797 support author candidate verified | Added local single-subject HTTPS OIDC and fixed TLS forwarder, persistent real-input Chromium CLI with private NSS trust, and external durable per-model metered-stop ledgers. Final focused run: 34 pass / 0 fail / 0 skip (identity 6, browser 5, budget 23); dependency regressions: 111 pass / 0 fail / 0 skip; supported processing CLI regressions: 4 pass / 0 fail / 0 skip. Initial configuration-selection, Basic credential decoding, browser readiness/count and shutdown issues were corrected; all final gates pass. Source remains confined to packages/cli/live plus append-only protocol records. Local commit authorized; independent exact-candidate review and supervised-service acceptance remain with lead. No .53/live activity, model spend, push or product-runtime/role/contract changes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-report.md. + +2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 4c93312ee..e275c9317 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2066,3 +2066,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. 2026-11-05 | Codex ui797 implementation child | #797 isolated OIDC/TLS, browser CLI and campaign budget support from 05fdfef9e on feat/ui797-support | Author implementation/tests underway in lead-chartered worktree; queue refused linked worktree, no canonical queue writes or protected actions. 2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits. +2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md. diff --git a/packages/cli/live/README.md b/packages/cli/live/README.md index 568d6637c..61d1a756a 100644 --- a/packages/cli/live/README.md +++ b/packages/cli/live/README.md @@ -121,6 +121,9 @@ The Console validates discovery, JWKS, PKCE, nonce, token signature and its norm session/owner rules. A missing bus, business or ancestry proof still refuses. No human/Console binding shortcut or TLS-verification disable flag is used. Trust `ca.pem` via `NODE_EXTRA_CA_CERTS` only in these test services, never globally. +Prepare and serve require the literal `ui797`, a `mosaic-ui797` config directory +and the current matching data-root marker. Serve revalidates persisted metadata; +the budget commands enforce the same binding. Certificates expire after two days; do not overwrite identity material on restart. ### Step-by-step real browser CLI for tester agents @@ -130,7 +133,14 @@ private Unix-socket daemon. Subsequent invocations reuse that daemon. Either set `UI797_EVIDENCE` or pass `--evidence DIR` on each command. Only the exact loopback origins `http://127.0.0.1:17440` and `https://127.0.0.1:17443` are accepted. Every page/subresource redirect must stay on the selected origin. Other targets and -WebSockets are blocked; a blocked network attempt makes the session fail until +WebSocket connections outside that origin are blocked before the handshake by a +forced local HTTP proxy with Chromium's loopback bypass disabled. It opens sockets +only to the selected origin, refuses HTTP upgrades, and permits CONNECT only to +the selected HTTPS host/port. QUIC and non-proxied WebRTC UDP are disabled. The +installed Chromium's CDP WebSocket blocklist did not contain handshakes in the +negative probe; this external boundary does. Request interception also guards +redirects and subresources. A detected +blocked network attempt makes the session fail until `stop`. The driver never contacts 7332 or mosaic.woltje.com. The browser trusts the test CA through a private NSS database under its evidence @@ -199,7 +209,7 @@ An owner creates a single 0600 `campaign.json` **before launching inference**: "start": "2026-11-06T02:00:00.000Z", "end": "2026-11-06T03:00:00.000Z", "caps": [ - {"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":10000000}, + {"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":100000000}, {"provider":"proxy","route":"anthropic","model":"claude-opus-5-5","tokens":10000000}, {"provider":"proxy","route":"anthropic","model":"claude-sonnet-5-5","tokens":10000000} ] @@ -208,7 +218,13 @@ An owner creates a single 0600 `campaign.json` **before launching inference**: Use the approved actual absolute config path, canonical UTC campaign start/end and **all** actual model routes; the dates above illustrate the schema. Caps are -positive safe integers at most 10,000,000; tiny values allow zero-spend tests. +positive safe integers: at most 100,000,000 for exactly +`proxy/glm/glm-5.3-flash`, and 10,000,000 for every other approved model. Lower +caps allow zero-spend tests. Only proxy GLM, Claude (`anthropic` route, `claude-*`) +and Codex (`codex` route, `gpt-*`) bindings are accepted. One provider/model has +one allowance across all routes; conflicting bindings refuse. The campaign must +end by **2026-11-07T06:00:00.000Z**; earlier windows narrow that absolute cutoff, +which checks also enforce independently. The test bus store must exist before initialization. Run the supervisor outside model-writable settings/data in a normally supervised owner service: @@ -241,8 +257,13 @@ for its outcome; unknown/malformed completed usage stops the campaign. Each provider/route/model has a separate append-only ledger, durable across rounds/restarts. Campaign bytes, root identity and store identity are pinned; changed caps/epoch, rewritten/disappeared usage and damaged ledger files stop. +Checks recompute each journal entry's tokens and totals from authoritative usage; +a valid-JSON token mismatch durably latches and halts. Initialization is exclusive. There is no reset, raise or resume command. A stale `check.lock` after interruption requires owner diagnosis; never auto-reclaim it. +An existing or uncreatable lock prevents accounting and still asserts the sticky +halt against the verified ui797 binding on every check/serve tick. The lock is +preserved, including when a stop was already latched. A changed target config refuses rather than halting an unverified data root. At any cap, deadline or accounting refusal, the supervisor fsyncs `stop.json` diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs index 6563d5596..422e3261e 100644 --- a/packages/cli/live/budget-supervisor.mjs +++ b/packages/cli/live/budget-supervisor.mjs @@ -7,6 +7,14 @@ import { join, dirname } from 'node:path'; import { spawnSync } from 'node:child_process'; import { turnRecords } from '../../bus/src/usage.mjs'; import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, ROOT, testInstance } from './support-common.mjs'; +export const CUTOFF = Date.parse('2026-11-07T06:00:00.000Z'); +const modelIdentity = c => JSON.stringify([c.provider,c.model]); +// The charter permits the GLM identity and Claude/Codex coder model families via proxy. +const approvedBinding = c => c.provider === 'proxy' && ( + (c.route === 'glm' && c.model === 'glm-5.3-flash') || + (c.route === 'anthropic' && /^claude-[a-zA-Z0-9._-]+$/.test(c.model)) || + (c.route === 'codex' && /^gpt-[a-zA-Z0-9._-]+$/.test(c.model))); +const ceiling = c => c.provider === 'proxy' && c.route === 'glm' && c.model === 'glm-5.3-flash' ? 100000000 : 10000000; const kinds = ['input','output','cacheWrite','cacheRead']; const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(x)).digest('hex'); const identity = c => JSON.stringify([c.provider,c.route,c.model]); @@ -15,16 +23,19 @@ const canonicalTime = s => typeof s === 'string' && Number.isFinite(Date.parse(s function campaignAt(path) { privateDir(dirname(path)); const text = readPrivate(path), c = JSON.parse(text); - if (c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || + validateCampaign(c); + return { c, hash:digest(text) }; +} +function validateCampaign(c) { + if (c.instance !== 'ui797' || c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || Date.parse(c.end) > CUTOFF || !Array.isArray(c.caps) || !c.caps.length || c.caps.length > 20) fail('campaign-invalid'); const keys = new Set(); for (const cap of c.caps) { if (Object.keys(cap).sort().join(',') !== 'model,provider,route,tokens' || !['proxy','anthropic','zai','openai'].includes(cap.provider) || !['glm','anthropic','codex'].includes(cap.route) || !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$/.test(cap.model ?? '') || - !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > 10000000 || keys.has(identity(cap))) fail('campaign-cap-invalid'); - keys.add(identity(cap)); + !approvedBinding(cap) || !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > ceiling(cap) || keys.has(modelIdentity(cap))) fail('campaign-cap-invalid'); + keys.add(modelIdentity(cap)); } - return { c, hash:digest(text) }; } function binding(c) { const { system } = testInstance(c.instance,c.config); @@ -119,7 +130,7 @@ function loadLedger(ledger) { if (p.version !== 1 || !p.binding || !p.campaign) fail('ledger-manifest-invalid'); return p; } -export async function check({ campaign,ledger }, { now=Date.now, read=readAccounting, halt=supportedHalt }={}) { +export async function check({ campaign,ledger }, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync }={}) { let p, integrityFailure=false; try { p=loadLedger(ledger); } catch { // With a damaged manifest, only the owner campaign and validated TEST marker may identify @@ -131,20 +142,27 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun // Pin the target before any halt. A changed config cannot redirect the stop into live data. checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config); - const lock=join(ledger,'check.lock'); mkdirSync(lock,{mode:0o700}); + const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false; + try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; } + // An interrupted/active/unwritable lock prevents accounting, never the verified halt. + // Only the check that created a lock may remove it on normal completion. try { - let reason=integrityFailure?'ledger-manifest-invalid':null, totals=[]; + let reason=integrityFailure?'ledger-manifest-invalid':lockFailure?'check-lock-unavailable':null, totals=[]; const stopFile=join(ledger,'stop.json'); if (existsSync(stopFile)) { reason='stop-already-latched'; try { JSON.parse(readPrivate(stopFile)); } catch { reason='stop-latch-invalid'; } } try { - if (campaignAt(campaign).hash !== p.hash) fail('campaign-changed'); - const clock=now(); if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended'); + const clock=now(); if (!Number.isFinite(clock)) fail('campaign-clock-invalid'); + if (clock>=CUTOFF) fail('campaign-absolute-cutoff'); + validateCampaign(c); + const owner=campaignAt(campaign); + if (owner.hash !== p.hash || JSON.stringify(owner.c) !== JSON.stringify(c)) fail('campaign-changed'); + if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended'); if (!reason) { const records=await read({...p.binding,start:c.start,end:c.end}), observed=new Set(); - const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap)})); + const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap),total:0})); for (const r of records) { if (!canonicalTime(r.at) || r.at!Number.isSafeInteger(r[k]) || r[k]<0)) fail('usage-unknown-or-malformed'); const a=accounts.find(a=>identity(a.cap)===identity(r)); if (!a) fail('usage-uncapped-route-model'); @@ -152,14 +170,23 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun const tokens=kinds.reduce((sum,k)=>sum+r[k],0); if (!Number.isSafeInteger(tokens)) fail('usage-overflow'); const hash=digest(r), previous=a.entries.get(key); if (previous && previous.digest !== hash) fail('usage-rewritten'); + if (previous && previous.tokens !== tokens) fail('ledger-token-mismatch'); + a.total+=tokens; if (!Number.isSafeInteger(a.total)) fail('ledger-overflow'); if (!previous) { - const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e); a.total+=tokens; - if (!Number.isSafeInteger(a.total)) fail('ledger-overflow'); + const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e); } } for (const a of accounts) for (const key of a.entries.keys()) if (!observed.has(key)) fail('usage-disappeared'); totals=accounts.map(a=>({...a.cap,total:a.total})); - if (totals.some(a=>a.total>=a.tokens)) reason='model-cap-reached'; + // Route journals are evidence, never separate allowances for the same model. + const models=new Map(); + for (const a of totals) { + const key=modelIdentity(a), model=models.get(key) ?? {total:0,tokens:Math.min(a.tokens,ceiling(a))}; + model.total+=a.total; model.tokens=Math.min(model.tokens,a.tokens,ceiling(a)); + if (!Number.isSafeInteger(model.total)) fail('ledger-overflow'); + models.set(key,model); + } + if ([...models.values()].some(a=>a.total>=a.tokens)) reason='model-cap-reached'; } } catch(e) { reason=/^[a-z-]{1,80}$/.test(e.message)?e.message:'accounting-failed'; } if (reason) { @@ -179,12 +206,13 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun return {stopped:true,reason,totals,halt:result}; } return {stopped:false,totals}; - } finally { rmdirSync(lock); } + } finally { if (locked) rmdirSync(lock); } } export function status({ campaign,ledger }) { - const p=loadLedger(ledger); checkBinding(p.binding,p.campaign); + const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config); - if (campaignAt(campaign).hash!==p.hash) fail('campaign-changed'); + const owner=campaignAt(campaign); + if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed'); const totals=p.campaign.caps.map(cap=>({...cap,total:journal(ledger,cap).total})); return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals }; } diff --git a/packages/cli/live/budget-supervisor.test.mjs b/packages/cli/live/budget-supervisor.test.mjs index 4aa922ab1..438f82b0d 100644 --- a/packages/cli/live/budget-supervisor.test.mjs +++ b/packages/cli/live/budget-supervisor.test.mjs @@ -1,16 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync } from 'node:fs'; +import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { join } from 'node:path'; -import { initialize,check,status,readAccounting,supportedHalt } from './budget-supervisor.mjs'; +import { initialize,check,status,readAccounting,supportedHalt,main,CUTOFF } from './budget-supervisor.mjs'; import { fixture } from './test-support.mjs'; import { Store } from '../../bus/src/store.mjs'; const NOW=Date.parse('2026-11-06T02:30:00.000Z'); const row=(extra={})=>({at:'2026-11-06T02:29:00.000Z',run:'r123456789abc',turn:'t1',source:'transcript',instance:'assistant',provider:'proxy',route:'glm',model:'glm-5.3-flash',status:'known',input:1,output:1,cacheWrite:1,cacheRead:1,...extra}); -function setup(t,tokens=5) { - const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); +function setup(t,tokens=5,changes={}) { + const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close()); const campaign=join(f.root,'campaign.json'),ledger=join(f.root,'ledger'); const c={version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens},{provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:6}]}; + Object.assign(c,changes); writeFileSync(campaign,JSON.stringify(c),{mode:0o600});initialize({campaign,ledger}); const halts=[],halt=b=>{halts.push(b);return {halted:true,quiescent:true,generation:'test'};}; return {...f,store,campaign,ledger,c,halts,halt,opts:{campaign,ledger}}; @@ -110,7 +113,7 @@ test('an invalid existing stop latch still invokes the supported test halt',asyn const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'stop-latch-invalid');assert.equal(f.halts.length,1); }); test('model-writable campaign paths refuse initialization',t=>{ - const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); + const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close()); const campaign=join(f.dataRoot,'campaign.json');writeFileSync(campaign,JSON.stringify({version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:1}]}),{mode:0o600}); assert.throws(()=>initialize({campaign,ledger:join(f.root,'ledger')}),/outside/); }); @@ -120,3 +123,108 @@ test('a failed durable-latch write still halts and returns an explicit storage f await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/stop-latch-not-durable/); assert.equal(f.halts.length,1);assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).storageError,'stop-latch-not-durable'); }); + +test('valid JSON journal token corruption durably latches and halts before accepting another turn',async t=>{ + const f=setup(t),deps={now:()=>NOW,read:()=>[row()],halt:f.halt}; + await check(f.opts,deps); + const file=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>join(f.ledger,n)).find(n=>readFileSync(n,'utf8').includes('"key"')); + const lines=readFileSync(file,'utf8').trimEnd().split('\n').map(JSON.parse);lines[1].tokens=0; + writeFileSync(file,lines.map(JSON.stringify).join('\n')+'\n'); + const result=await check(f.opts,{...deps,read:()=>[row(),row({turn:'t2'})]}); + assert.equal(result.reason,'ledger-token-mismatch');assert.equal(f.halts.length,1); + assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-token-mismatch'); +}); + +test('a check killed while holding its lock is halted on restart without reclaiming the lock',async t=>{ + const f=setup(t,100); + const code=`import {check} from ${JSON.stringify(new URL('./budget-supervisor.mjs',import.meta.url).href)}; await check(${JSON.stringify(f.opts)},{now:()=>${NOW},read:()=>{process.send('locked');return new Promise(()=>{});}});`; + const child=spawn(process.execPath,['--input-type=module','-e',code],{stdio:['ignore','ignore','pipe','ipc']}); + f.after(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'close');}}); + await once(child,'message');child.kill('SIGKILL');await once(child,'close'); + const lock=join(f.ledger,'check.lock'),ino=lstatSync(lock).ino; + const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('locked accounting must not run'),halt:f.halt}); + assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1); + assert.equal(lstatSync(lock).ino,ino);assert.ok(existsSync(join(f.ledger,'stop.json'))); +}); + +test('an existing latch reasserts halt on every locked restart and preserves lock evidence',async t=>{ + const f=setup(t,1);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}); + const lock=join(f.ledger,'check.lock');mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'owner'),'diagnose'); + for(let n=0;n<2;n++) assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true); + assert.equal(f.halts.length,3);assert.equal(readFileSync(join(lock,'owner'),'utf8'),'diagnose'); +}); + +test('lock storage failures still durably stop the verified test workload',async t=>{ + for(const code of ['EROFS','EACCES']) { + const f=setup(t,100); + const result=await check(f.opts,{now:()=>NOW,halt:b=>{assert.equal(b.dataRoot,f.dataRoot);assert.ok(existsSync(join(f.ledger,'stop.json')));return f.halt(b);},acquireLock:()=>{throw Object.assign(new Error('storage'),{code});}}); + assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1); + } + const f=setup(t,100),lock=join(f.ledger,'check.lock');writeFileSync(lock,'not a directory',{mode:0o600}); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true); + assert.equal(readFileSync(lock,'utf8'),'not a directory');assert.equal(f.halts.length,1); +}); + +test('budget init, check, status and serve refuse unrelated instance metadata before accounting or halt',async t=>{ + const f=setup(t,100),other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other); + const config=join(other,'config.json');writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + f.c.instance='unrelated';f.c.config=config;writeFileSync(f.campaign,JSON.stringify(f.c)); + assert.throws(()=>initialize({campaign:f.campaign,ledger:join(f.root,'other-ledger')}),/campaign-invalid/); + const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;writeFileSync(file,JSON.stringify(p)); + await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}),/literal ui797/); + assert.throws(()=>status(f.opts),/campaign-invalid/); + await assert.rejects(main(['serve','--campaign',f.campaign,'--ledger',f.ledger]),/literal ui797/); + assert.equal(f.halts.length,0); +}); + +test('persisted campaign metadata is compared to the validated owner manifest before accounting',async t=>{ + const f=setup(t,100),file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file)); + p.campaign.caps[0].tokens=101;writeFileSync(file,JSON.stringify(p)); + assert.equal((await check(f.opts,{now:()=>NOW,read:()=>assert.fail('changed metadata'),halt:f.halt})).reason,'campaign-changed'); + assert.equal(f.halts.length,1); +}); + +test('absolute cutoff is inclusive; later windows refuse and a persisted extension cannot delay a stop',async t=>{ + const end='2026-11-07T06:00:00.000Z',f=setup(t,100,{end}); + assert.equal((await check(f.opts,{now:()=>CUTOFF-1,read:()=>[],halt:f.halt})).stopped,false); + assert.equal((await check(f.opts,{now:()=>CUTOFF,read:()=>[],halt:f.halt})).reason,'campaign-absolute-cutoff'); + assert.equal(f.halts.length,1); + assert.throws(()=>setup(t,100,{end:'2026-11-08T06:00:00.000Z'}),/campaign-invalid/); + const g=setup(t,100,{end}),file=join(g.ledger,'campaign.json'),p=JSON.parse(readFileSync(file)); + p.campaign.end='2026-11-08T06:00:00.000Z';writeFileSync(file,JSON.stringify(p)); + assert.equal((await check(g.opts,{now:()=>CUTOFF,halt:g.halt})).reason,'campaign-absolute-cutoff'); + assert.equal(g.halts.length,1); +}); + +test('only the exact proxy/glm identity receives 100M; other approved models remain capped at 10M',t=>{ + const cap=(provider,route,model,tokens)=>({provider,route,model,tokens}); + const glm=cap('proxy','glm','glm-5.3-flash',100000000),claude=cap('proxy','anthropic','claude-opus-5-5',10000000),codex=cap('proxy','codex','gpt-6-astra',10000000); + for(const c of [glm,claude,codex])assert.ok(setup(t,1,{caps:[c]})); + for(const c of [glm,claude,codex])assert.throws(()=>setup(t,1,{caps:[{...c,tokens:c.tokens+1}]}),/campaign-cap-invalid/); + for(const c of [cap('zai','glm','glm-5.3-flash',100000000),cap('proxy','anthropic','glm-5.3-flash',100000000),cap('proxy','glm','claude-opus-5-5',100000000)])assert.throws(()=>setup(t,1,{caps:[c]}),/campaign-cap-invalid/); +}); + +test('conflicting route bindings refuse initialization and real-store split-route usage stops',async t=>{ + const cap={provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:10000000}; + assert.throws(()=>setup(t,1,{caps:[{...cap,route:'glm'},cap]}),/campaign-cap-invalid/); + assert.throws(()=>setup(t,1,{caps:[cap,{...cap}]}),/campaign-cap-invalid/); + const f=setup(t,1,{caps:[cap]}); + for(const [i,route] of ['anthropic','glm'].entries()) { + const r=row({run:i?'r123456789abd':'r123456789abc',turn:`t${i}`,model:cap.model,input:6000000,output:0,cacheWrite:0,cacheRead:0}); + f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',r.at,f.c.business,r.run,r.instance,r.provider,r.model,r.turn,r.source,'end','known',r.input,0,0,0); + const dir=join(f.dataRoot,'launches',f.c.business,r.run,'bundle');mkdirSync(dir,{recursive:true,mode:0o700}); + writeFileSync(join(dir,'manifest.json'),JSON.stringify({business:f.c.business,run:r.run,instance:r.instance,provider:r.provider,model:r.model,proxyRoute:route,route:'primary'}),{mode:0o600}); + } + const result=await check(f.opts,{now:()=>NOW,halt:f.halt}); + assert.equal(result.reason,'usage-uncapped-route-model');assert.equal(f.halts.length,1); + assert.ok(existsSync(join(f.ledger,'stop.json'))); +}); + +test('the authorized GLM and other-model maxima halt at the exact charged ceiling',async t=>{ + for(const cap of [{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:100000000},{provider:'proxy',route:'anthropic',model:'claude-sonnet-5-5',tokens:10000000}]) { + const f=setup(t,1,{caps:[cap]}); + const r=row({...cap,input:cap.tokens,output:0,cacheWrite:0,cacheRead:0});delete r.tokens; + const result=await check(f.opts,{now:()=>NOW,read:()=>[r],halt:f.halt}); + assert.equal(result.reason,'model-cap-reached');assert.equal(result.totals[0].total,cap.tokens);assert.equal(f.halts.length,1); + } +}); diff --git a/packages/cli/live/isolated-identity.test.mjs b/packages/cli/live/isolated-identity.test.mjs index 0647a173b..fe65ccc4f 100644 --- a/packages/cli/live/isolated-identity.test.mjs +++ b/packages/cli/live/isolated-identity.test.mjs @@ -3,12 +3,11 @@ import assert from 'node:assert/strict'; import { createHash, verify, createPublicKey } from 'node:crypto'; import { request } from 'node:https'; import { createServer } from 'node:http'; -import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { readFileSync, statSync, writeFileSync,renameSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; -import { once } from 'node:events'; import { prepare,serve,SUBJECT } from './isolated-identity.mjs'; -import { fixture } from './test-support.mjs'; +import { fixture,closeServer,closeChild } from './test-support.mjs'; import { readSlot } from '../../webui/src/sso.mjs'; function setup(t) { const f=fixture(t),runtime=join(f.root,'identity'); @@ -43,7 +42,7 @@ test('live/default instances, wrong owner, unclaimed root and unsafe config path assert.throws(()=>prepare(defaults)); }); test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bounded RS256 token',async t=>{ - const f=setup(t),server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const f=setup(t),server=await serve(f.runtime);f.after(()=>closeServer(server)); const discovery=await http(f.runtime,'/oidc/.well-known/openid-configuration');assert.equal(discovery.status,200); const verifier='a'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'test-state',nonce:'test-nonce',code_challenge:challenge,code_challenge_method:'S256'}); @@ -67,23 +66,23 @@ test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bound test('fixed TLS forwarder preserves normal Console Host, cookies and origin without injected identity',async t=>{ const f=setup(t);let headers; const upstream=createServer((req,res)=>{headers=req.headers;res.end('Console upstream');}); - await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});t.after(()=>upstream.close()); - const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(upstream)); + const server=await serve(f.runtime);f.after(()=>closeServer(server)); const r=await http(f.runtime,'/test',{headers:{origin:'https://127.0.0.1:17443',cookie:'fixture=value'}}); assert.equal(r.body,'Console upstream');assert.equal(headers.host,'127.0.0.1:17443');assert.equal(headers.origin,'https://127.0.0.1:17443');assert.equal(headers.cookie,'fixture=value');assert.equal(headers['x-auth-subject'],undefined); }); test('production Console CLI discovers this OP through NODE_EXTRA_CA_CERTS with ordinary TLS validation',async t=>{ const f=setup(t),config=JSON.parse(readFileSync(f.config));config.console=f.output.console;writeFileSync(f.config,JSON.stringify(config)); - const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const server=await serve(f.runtime);f.after(()=>closeServer(server)); const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(f.runtime,'ca.pem')},stdio:['ignore','pipe','pipe']}); - t.after(async()=>{if(child.exitCode===null){child.kill('SIGTERM');await once(child,'exit');}}); + f.after(async()=>{await closeChild(child);}); let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Console startup: '+output)),15000);child.stdout.on('data',()=>{if(output.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error('Console refused: '+output));});}); const health=JSON.parse((await http(f.runtime,'/healthz')).body);assert.equal(health.sso['ui797-local'],'ready'); }); test('authorization codes expire, and tampering with the single subject refuses service',async t=>{ const f=setup(t);let now=Date.now(); - const server=await serve(f.runtime,{clock:()=>now});t.after(()=>{server.closeAllConnections();server.close();}); + const server=await serve(f.runtime,{clock:()=>now});f.after(()=>closeServer(server)); const verifier='v'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'state',nonce:'nonce',code_challenge:challenge,code_challenge_method:'S256'}); const code=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location).searchParams.get('code');now+=60001; @@ -93,3 +92,25 @@ test('authorization codes expire, and tampering with the single subject refuses const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));state.console.sso.providers[0].subjects=[{sub:'jason',name:'jason'}];writeFileSync(stateFile,JSON.stringify(state)); await assert.rejects(serve(f.runtime),/metadata refused/); }); + +test('prepare and persisted serve metadata refuse an otherwise valid unrelated development instance',async t=>{ + const f=setup(t),other=join(f.root,'mosaic-unrelated'); + renameSync(join(f.root,'mosaic-ui797'),other); + const config=join(other,'config.json'); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + assert.throws(()=>prepare({instance:'unrelated',config,owner:'ui797-owner',runtime:join(f.root,'other-id'),tlsPort:17443}),/literal ui797/); + const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); + state.instance='unrelated';state.config=config;writeFileSync(stateFile,JSON.stringify(state)); + await assert.rejects(serve(f.runtime),/literal ui797/); +}); + +test('serve revalidates the config directory and current data marker before listening',async t=>{ + const f=setup(t); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + await assert.rejects(serve(f.runtime),/marker mismatch/); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); + const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); + const other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other); + state.config=join(other,'config.json');writeFileSync(stateFile,JSON.stringify(state)); + await assert.rejects(serve(f.runtime),/matching named test config/); +}); diff --git a/packages/cli/live/support-common.mjs b/packages/cli/live/support-common.mjs index b741d4343..581bd06f4 100644 --- a/packages/cli/live/support-common.mjs +++ b/packages/cli/live/support-common.mjs @@ -48,7 +48,7 @@ export function options(argv, allowed) { return out; } export function testInstance(instance, config) { - if (!/^[a-z][a-z0-9]{1,15}$/.test(instance ?? '') || ['dev','prod','production'].includes(instance)) fail('explicit named test instance required'); + if (instance !== 'ui797') fail('literal ui797 test instance required'); plainPath(config); const copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'mosaic-stack/copies'); if (inside(ROOT, config) || inside(copies, config)) fail('test config and secret slots must be outside source and stack copies'); diff --git a/packages/cli/live/test-support.mjs b/packages/cli/live/test-support.mjs index 5b2e20b2b..9529c3d3d 100644 --- a/packages/cli/live/test-support.mjs +++ b/packages/cli/live/test-support.mjs @@ -1,15 +1,56 @@ // Focused test fixtures only; runtime CLIs never import this file. -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync,readFileSync,readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; export function fixture(t) { if (!process.env.TMPDIR?.startsWith('/mnt/storage/scratch/tmp/') || process.env.TMPDIR.includes('_')) throw new Error('set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX'); const root=mkdtempSync(join(tmpdir(),'ui797-')); - t.after(()=>rmSync(root,{recursive:true,force:true})); + const cleanups=[]; + t.after(async()=>{ + const owned=ownedProcesses(root); + const errors=[]; + for(const cleanup of cleanups.reverse()) try { await cleanup(); } catch(e) { errors.push(e); } + // Preserve evidence if resource shutdown failed; never delete beneath a running service. + const survivors=owned.filter(p=>processAlive(p)); + if(survivors.length) errors.push(new Error(`owned processes survived fixture shutdown: ${survivors.map(p=>p.pid)}`)); + if(errors.length) throw new AggregateError(errors,'fixture shutdown failed'); + rmSync(root,{recursive:true,force:true}); + }); const configDir=join(root,'mosaic-ui797'),dataRoot=join(root,'data'); mkdirSync(configDir,{mode:0o700});mkdirSync(dataRoot,{mode:0o700}); const config=join(configDir,'config.json'); writeFileSync(config,JSON.stringify({configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash'}})); writeFileSync(join(dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); - return {root,config,dataRoot}; + return {root,config,dataRoot,after:fn=>cleanups.push(fn)}; +} + +export async function closeServer(server) { + const closed=new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve())); + server.closeAllConnections(); + await closed; +} +export async function closeChild(child) { + if(child.exitCode!==null || child.signalCode!==null) return; + const closed=new Promise(resolve=>child.once('close',resolve)); + child.kill('SIGTERM'); + await closed; +} + +// Snapshot only this fixture's processes, including reparented Chromium crash handlers. +export function ownedProcesses(root) { + const processes=readdirSync('/proc').filter(n=>/^\d+$/.test(n)).flatMap(pid=>{ + try { + const args=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0'); + const stat=readFileSync(`/proc/${pid}/stat`,'utf8').split(' '); + return [{pid:Number(pid),ppid:Number(stat[3]),start:stat[21],args}]; + } catch { return []; } + }); + const selected=new Set(processes.filter(p=>p.args.some(a=>a===root || a.startsWith(root+'/') || a.startsWith('--user-data-dir='+root+'/'))).map(p=>p.pid)); + for(const p of processes) if(selected.has(p.pid)) for(const a of p.args) if(a.startsWith('--crashpad-handler-pid=')) selected.add(Number(a.split('=')[1])); + let size; + do {size=selected.size;for(const p of processes) if(selected.has(p.ppid))selected.add(p.pid);} while(size!==selected.size); + return processes.filter(p=>selected.has(p.pid)); +} +export function processAlive(p) { + try { const stat=readFileSync(`/proc/${p.pid}/stat`,'utf8').split(' ');return stat[21]===p.start && stat[2]!=='Z'; } catch {return false;} } diff --git a/packages/cli/live/ui-browser.mjs b/packages/cli/live/ui-browser.mjs index d3d421d17..dff554d56 100644 --- a/packages/cli/live/ui-browser.mjs +++ b/packages/cli/live/ui-browser.mjs @@ -2,6 +2,7 @@ // Persistent CDP pipe client, based on webui/tests/browser.mjs. No injected app script. import { spawn, fork, spawnSync } from 'node:child_process'; import { createServer, createConnection } from 'node:net'; +import {createServer as httpServer,request as httpRequest} from 'node:http'; import { once } from 'node:events'; import { createHash, X509Certificate } from 'node:crypto'; import { appendFileSync, chmodSync, existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'; @@ -9,6 +10,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs'; +const ownedDaemons = new Map(); const HERE = fileURLToPath(import.meta.url); const INTERACTIVE = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','tab','menuitem','option','spinbutton','slider']); const delay = ms => new Promise(r => setTimeout(r, ms)); @@ -19,7 +21,7 @@ export function testURL(value, origin = null) { if (origin && u.origin !== origin) fail('navigation must stay on the selected test origin'); return u; } -async function chromium(evidence, ca) { +async function chromium(evidence, ca, proxyPort) { const cert = new X509Certificate(readPrivate(ca)); if (!cert.ca || Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail('valid private test CA required'); const xdg = join(evidence, 'xdg'); privateDir(xdg, true); @@ -39,7 +41,9 @@ async function chromium(evidence, ca) { if (legacy) plainPath(legacyNss); const executable = process.env.CHROMIUM || '/usr/bin/chromium'; const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage', - '--disable-background-networking','--disable-component-update','--disable-sync','--no-proxy-server','--remote-debugging-pipe', + '--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic', + `--proxy-server=http://127.0.0.1:${proxyPort}`,'--proxy-bypass-list=<-loopback>', + '--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--remote-debugging-pipe', `--user-data-dir=${profile}`]; const child = spawn(legacy ? '/usr/bin/bwrap' : executable, legacy ? ['--die-with-parent','--bind','/','/','--dev-bind','/dev','/dev','--proc','/proc','--bind',nss,legacyNss,'--',executable,...chromeArgs] : chromeArgs, { env:{...process.env,XDG_DATA_HOME:xdg}, stdio: ['ignore','ignore','ignore','pipe','pipe'] }); @@ -66,17 +70,58 @@ async function chromium(evidence, ca) { const { targetId } = await send('Target.createTarget', { url:'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId, flatten:true }); const call = (method, params) => send(method, params, sessionId); - return { call, send, on, child, async close() { - await send('Browser.close').catch(() => {}); - if (child.exitCode === null) await once(child, 'exit'); + let closing; + return { call, send, on, child, close() { + return closing ??= (async () => { + const exited=once(child,'close'); + await send('Browser.close').catch(() => {}); + if (child.exitCode === null && child.signalCode === null) await exited; + })(); } }; } +// External transport boundary: Chromium must use this proxy even for loopback. +// It never resolves/opens a socket for an unapproved destination. CDP is defense in depth. +async function originProxy(origin) { + const allowed=new URL(origin),sockets=new Set(); + const track=socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));return socket;}; + const server=httpServer((req,res)=>{ + let url; + try {url=testURL(req.url,origin);if(url.origin!==origin || allowed.protocol!=='http:')throw Error();} + catch {res.writeHead(403);res.end();return;} + const upstream=httpRequest({hostname:'127.0.0.1',port:Number(allowed.port),method:req.method,path:url.pathname+url.search,headers:req.headers},reply=>{ + res.writeHead(reply.statusCode,reply.headers);reply.pipe(res); + }); + upstream.on('socket',track); + upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();}); + req.on('aborted',()=>upstream.destroy());req.pipe(upstream); + }); + server.on('connection',track); + server.on('upgrade',(_req,socket)=>socket.destroy()); + server.on('connect',(req,peer,head)=>{ + // Only TLS to the selected HTTPS origin may tunnel. WS/other-origin tunnels refuse. + if(allowed.protocol!=='https:' || req.url!==allowed.host){peer.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');return;} + const upstream=track(createConnection({host:'127.0.0.1',port:Number(allowed.port)})); + upstream.once('connect',()=>{peer.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);peer.pipe(upstream);upstream.pipe(peer);}); + upstream.on('error',()=>peer.destroy());peer.on('error',()=>upstream.destroy()); + peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy()); + }); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);}); + let closing; + return {port:server.address().port,close(){return closing??=(async()=>{ + const closed=new Promise(resolve=>server.close(resolve));for(const socket of sockets)socket.destroy();await closed; + })();}}; +} export async function daemon(evidence) { privateDir(evidence); const state = JSON.parse(readPrivate(join(evidence, 'browser.json'))), origin = testURL(state.origin).origin; - const b = await chromium(evidence, state.ca); + let blocked=false; + const proxy=await originProxy(origin); + let b; try { - let sequence = 0, generation = 0, refs = new Map(), blocked = false, detail = {}; + b = await chromium(evidence, state.ca,proxy.port); + const closeChrome=b.close; + b.close=async()=>{try{await closeChrome();}finally{await proxy.close();}}; + let sequence = 0, generation = 0, refs = new Map(), detail = {}; const completed = new Map(), pendingDownloads = new Set(); const downloadsDir = join(evidence,'downloads'); privateDir(downloadsDir, true); b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); }); @@ -99,6 +144,11 @@ export async function daemon(evidence) { await b.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }); await b.call('Page.enable'); await b.call('Accessibility.enable'); await b.call('DOM.enable'); await b.call('Network.enable'); await b.call('Network.setBlockedURLs', { urls:['ws://*','wss://*'] }); + // Detection latches the session; the forced proxy enforces BEFORE outbound connection. + // Chromium's own background probes are also refused by the proxy, without poisoning UI actions. + b.on('Network.webSocketCreated',e=>{ + try {testURL(e.url.replace(/^ws/,'http'),origin);} catch {blocked=true;} + }); await b.call('Fetch.enable', { patterns:[{ urlPattern:'*', requestStage:'Request' }] }); await b.send('Browser.setDownloadBehavior', { behavior:'allowAndName', downloadPath:downloadsDir, eventsEnabled:true }); await b.call('Emulation.setDeviceMetricsOverride', { width:1280, height:1000, deviceScaleFactor:1, mobile:false }); @@ -238,32 +288,41 @@ export async function daemon(evidence) { const cleanup = async () => { server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); }; for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => cleanup().finally(() => process.exit(0))); return { server, cleanup }; - } catch (e) { await b.close().catch(() => {}); throw e; } + } catch (e) { await b?.close().catch(() => {}); await proxy.close(); throw e; } } -export function callBrowser(evidence, command, args=[]) { +export async function callBrowser(evidence, command, args=[]) { privateDir(evidence); readPrivate(join(evidence,'browser.json')); - return new Promise((resolve,reject) => { + const value=await new Promise((resolve,reject) => { const peer = createConnection(join(evidence,'browser.sock')); let data = ''; peer.on('error', () => {}); peer.setTimeout(150000, () => peer.destroy(new Error('browser action timed out; inspect receipts before retry'))); peer.on('error',reject); peer.on('connect', () => peer.write(JSON.stringify({ command,args })+'\n')); peer.on('data',chunk => { data += chunk; }); peer.on('end', () => { try { const r = JSON.parse(data); r.ok ? resolve(r.value) : reject(new Error(r.error)); } catch(e) { reject(e); } }); }); + if (command === 'stop' && ownedDaemons.has(evidence)) await ownedDaemons.get(evidence).exited; + return value; } export async function start({ evidence, origin, ca }) { if (testURL(origin).origin !== origin) fail('origin must have no path, query or fragment'); plainPath(ca); readPrivate(ca); plainPath(evidence); privateDir(evidence,true); exclusive(join(evidence,'browser.json'),JSON.stringify({ version:1,origin,ca })+'\n'); const child = fork(HERE,['--daemon',evidence],{ detached:true,stdio:['ignore','ignore','ignore','ipc'] }); + const exited=new Promise(resolve=>child.once('exit',resolve)); + ownedDaemons.set(evidence,{child,exited}); + exited.then(()=>ownedDaemons.delete(evidence)); try { await new Promise((resolve,reject) => { const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('browser daemon startup timed out')); },20000); child.once('error',reject); child.once('exit', () => { clearTimeout(timer); reject(new Error('browser daemon failed to start')); }); child.once('message',m => { clearTimeout(timer); m.ok ? resolve() : reject(new Error(m.error)); }); }); - } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); child.unref(); throw e; } + } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); await exited; throw e; } child.disconnect(); child.unref(); - return callBrowser(evidence,'start'); + try { return await callBrowser(evidence,'start'); } + catch(e) { + await callBrowser(evidence,'stop').catch(async()=>{child.kill('SIGTERM');await exited;}); + throw e; + } } export async function main(argv, env=process.env) { const command = argv.shift(); diff --git a/packages/cli/live/ui-browser.test.mjs b/packages/cli/live/ui-browser.test.mjs index cd37efecf..a693fcfda 100644 --- a/packages/cli/live/ui-browser.test.mjs +++ b/packages/cli/live/ui-browser.test.mjs @@ -1,12 +1,12 @@ -import test from 'node:test'; +import test,{after} from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync,writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { start,callBrowser,testURL } from './ui-browser.mjs'; import { prepare,serve } from './isolated-identity.mjs'; -import { fixture } from './test-support.mjs'; +import { fixture,closeServer,closeChild,ownedProcesses } from './test-support.mjs'; const pause=ms=>new Promise(r=>setTimeout(r,ms)); test('browser URL guard refuses live, remote, credentials, non-test ports and origin changes',()=>{ for(const u of ['http://127.0.0.1:7332','https://mosaic.woltje.com','http://10.1.1.53:17440','http://localhost:17440','http://u:p@127.0.0.1:17440','https://127.0.0.1:17440','http://127.0.0.1:17443'])assert.throws(()=>testURL(u)); @@ -23,9 +23,9 @@ async function setup(t,html) { if(req.url==='/redirect'){res.writeHead(302,{location:'http://127.0.0.1:7332/live'});res.end();return;} res.setHeader('content-type','text/html');res.end(html); }); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});t.after(()=>server.close()); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(server)); const evidence=join(f.root,'browser'); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});}); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); return {...f,identity,evidence,hits}; } test('real Chromium keeps a session across CLI processes; mouse/keyboard refs, screenshots and actual downloads',async t=>{ @@ -70,9 +70,10 @@ test('private CA browser performs the actual authorization redirect into product const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser'); const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); const config=JSON.parse(readFileSync(f.config));config.console=output.console;writeFileSync(f.config,JSON.stringify(config)); - const idp=await serve(identity);t.after(()=>{idp.closeAllConnections();idp.close();}); + const idp=await serve(identity);f.after(()=>closeServer(idp)); const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(identity,'ca.pem')},stdio:['ignore','pipe','pipe']});let text='';child.stdout.on('data',c=>text+=c);child.stderr.on('data',c=>text+=c); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});if(child.exitCode===null){child.kill('SIGTERM');await new Promise(r=>child.once('exit',r));}}); + f.after(()=>closeChild(child)); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error(text)),15000);child.stdout.on('data',()=>{if(text.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error(text));});}); const initial=await start({evidence,origin:'https://127.0.0.1:17443',ca:join(identity,'ca.pem')}); // The sole provider signs in immediately through redirects during start. @@ -84,8 +85,61 @@ test('private CA browser performs the actual authorization redirect into product test('a different private CA is refused by Chromium with TLS verification enabled',async t=>{ const f=fixture(t),g=fixture(t),id=join(f.root,'id'),other=join(g.root,'id'),evidence=join(f.root,'browser'); for(const [config,runtime] of [[f.config,id],[g.config,other]])prepare({instance:'ui797',config,owner:'ui797-owner',runtime,tlsPort:17443}); - const server=await serve(other);t.after(()=>{server.closeAllConnections();server.close();}); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); + const server=await serve(other);f.after(()=>closeServer(server)); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); await assert.rejects(start({evidence,origin:'https://127.0.0.1:17443',ca:join(id,'ca.pem')}),/navigation failed/); assert.ok(readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('false')); }); + +for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`Chromium ${origin} blocks WebSocket handshakes before the forbidden listener sees any TCP connection`,async t=>{ + let connections=0,upgrades=0; + const outside=createServer((_req,res)=>res.end('forbidden')); + outside.on('connection',()=>connections++); + outside.on('upgrade',(_req,socket)=>{upgrades++;socket.destroy();}); + await new Promise((resolve,reject)=>{outside.once('error',reject);outside.listen(17442,'127.0.0.1',resolve);}); + t.after(()=>closeServer(outside)); + const f=await setup(t,`

safe

`); + if(origin.startsWith('https:')) {const server=await serve(f.identity);f.after(()=>closeServer(server));} + await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')}); + await assert.rejects(callBrowser(f.evidence,'click',['--text','WebSocket']),/blocked/); + await pause(300); + assert.equal(connections,0);assert.equal(upgrades,0); + await callBrowser(f.evidence,'stop'); +}); + +test('a failing whole test file shuts down its browser and servers before removing fixture roots',async t=>{ + const f=fixture(t),file=join(f.root,'failure.test.mjs'); + const url=name=>JSON.stringify(new URL(name,import.meta.url).href); + writeFileSync(file,`import test from 'node:test'; +import {createServer} from 'node:http'; +import {join} from 'node:path'; +import {fixture,closeServer} from ${url('./test-support.mjs')}; +import {prepare} from ${url('./isolated-identity.mjs')}; +import {start,callBrowser} from ${url('./ui-browser.mjs')}; +test('intentional failure after browser startup',async t=>{ + const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser'); + console.log('FIXTURE_ROOT='+f.root); + prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); + const server=createServer((req,res)=>res.end('

failure cleanup

')); + await new Promise(r=>server.listen(17440,'127.0.0.1',r));f.after(()=>closeServer(server)); + f.after(()=>callBrowser(evidence,'stop')); + await start({evidence,origin:'http://127.0.0.1:17440',ca:join(identity,'ca.pem')}); + throw new Error('expected-cleanup-failure'); +});`); + const child=spawn(process.execPath,['--test','--test-reporter=tap',file],{env:{...process.env,NODE_TEST_CONTEXT:undefined},stdio:['ignore','pipe','pipe']}); + f.after(()=>closeChild(child)); + let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); + const code=await new Promise(resolve=>child.once('close',resolve)); + assert.equal(code,1,output);assert.match(output,/expected-cleanup-failure/); + const root=output.match(/FIXTURE_ROOT=(\S+)/)?.[1];assert.ok(root,output); + assert.equal(existsSync(root),false,output);assert.deepEqual(ownedProcesses(root),[]); +}); + +after(async()=>{ + const {createConnection}=await import('node:net'); + for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{ + const socket=createConnection({host:'127.0.0.1',port}); + socket.once('connect',()=>{socket.destroy();reject(new Error(`test listener survived on ${port}`));}); + socket.once('error',e=>e.code==='ECONNREFUSED'?resolve():reject(e)); + }); +});