fix(cli): confine browser mounts and keep halt on binding damage (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 22:34:36 -06:00
1 parent 905d313cdf
commit fce831c053
7 files changed
+291 -58

No files matched your search

+2
View File
@@ -6200,3 +6200,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa
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.
2026-11-06 | #797 ui797 support R3 P1 corrections verified | Before: 19e6cb0f1 still emitted WebRTC STUN/TURN UDP despite its flag, and unavailable ledger directories bypassed halt and terminated serve. After: mandatory Bubblewrap network/PID namespaces with an isolated, up loopback; a single namespace TCP relay over a 0600 Unix socket to the existing origin-only proxy, preserving private NSS/TLS validation, control socket permissions and awaited browser-before-proxy cleanup. Real Chromium regressions from both origins create nine ICE agents against six independent IPv4/IPv6/127.0.0.2 STUN/TURN UDP listeners and three TCP listeners; zero datagrams/connections, existing WebSocket/HTTP/SSO/CA negatives retained. Campaign initialization now exclusively pins owner/config/binding/ledger evidence beside the campaign, independently of the ledger volume; missing/renamed/0500 storage still attempts supported CLI halt, explicitly reports nondurable latch/receipt, preserves evidence and never recreates an epoch. Serve survives failures and reasserts each tick/Resume, retaining its stop across storage recovery; invalid/foreign/changed targets refuse. Older intact ledger format preserved. Final sequential whole files: identity 8, browser 10, budget 43 = 61 pass / 0 fail / 0 skip / 0 cancelled. All R1/R2 probes rerun: zero forbidden TCP/UDP, all four storage variants count one halt, original latch preserved, stale-lock/corruption/cap/cutoff/identity negatives retained, SIGTERM/SIGKILL/startup-failure survivors zero. Original R1 general probe exits 1 at its correctly refused split-route setup. Independent final sweep: zero owned processes/listeners at fixed and 43 recorded endpoints; scratch sweep parser corrected before its terminal run. Exactly packages/cli/live plus this single entry and one SESSIONS line; linked-worktree queue refused, no queue/forge writes, push, stash, model call, .53 contact, host network/DNS/trust changes or unrelated-session cleanup. Local implementation ready for independent exact-head review; owner-service successful quiescence remains a later acceptance gate. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-evidence/ and ui797-support-fix-r3-report.md.
2026-11-06 UTC | #797 ui797 support R4 review corrections verified | Before: unchanged 905d313cd reproduces 13 host pathname D-Bus connections/11 AUTH EXTERNAL requests, four damaged-sidecar cases with zero halt attempts, and a coherent epoch edit accepting 4 of 8 real stored tokens against cap 5. After: empty Bubblewrap mount view with read-only system/browser dependencies, minimal dev/proc, private tmpfs tmp/run/home, browser-owned writable evidence/NSS mounts and explicit environment; actual Chromium D-Bus traffic zero on HTTP/HTTPS, diagnostic pathname/abstract/TCP attempts fail, UDP listener sees zero, synthetic 0600 file/config/runtime paths hidden. Private NSS trust and unchanged production Console OIDC sign-in pass. Version-2 ledger independently pins config bytes; sidecar read/parse/mode/deletion and semantic corruption stop with explicit evidenceErrors and counted supported halts, preserving damage. Serve retains verified target/config/epoch evidence and revalidates marker/root/store/config before targeting and immediately before halt. Normal-path independent campaign hashes/epochs must agree; real coherent-epoch-edit and zero pin hash halt without journal reset. Modern sidecar deletion stops; legacy version-1 requires intact independent config-byte proof and an unpinned legacy ledger refuses, without migration/reinitialization. README requires Bubblewrap/user/network/PID namespaces on every browser host and states identity is HTTPS 17443/oidc behind the Console TLS forwarder, never 17442. Sequential whole files budget 89, identity 8, browser 13, request-lifecycle-live 2 = 112 pass / 0 fail / 0 skip / 0 cancelled. Both unchanged reviewer probes first fail semantically on baseline, then all six controls pass; three further retained probes pass, 31 network records/12 denied CONNECTs and both WebRTC origins retain zero forbidden traffic. External-reader positive control, changed-path 64-hex pin search, ten syntax checks and whitespace pass. Development missing import and explicit PWD assertion corrected; evidence retained. Cleanup finds zero processes from this run, no fixed test listeners and empty/removed TMPDIR; 29 older other-session daemons untouched after overbroad sweep was narrowed to this run. Five packages/cli/live paths plus this entry and one SESSIONS line only; original log bytes preserved. Queue read refused linked worktree; no queue/forge writes, push, stash, model spend, real secrets, .53/live-service/host-trust changes or attribution trailers. Local implementation ready for independent exact-commit review; owner-service quiescence remains outside this author charter. Report/evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md and ui797-support-fix-r4-evidence/.
+1
View File
@@ -2068,3 +2068,4 @@ are never rewritten or removed; corrections are new entries.
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.
2026-11-06 | ui797-support-fix-r3 implementation sub-agent | #797 two P1 corrections above 19e6cb0f1 on feat/ui797-support | Mandatory browser network namespace/private proxy relay and independently bound unavailable-ledger halt with persistent serve reassertion; final sequential whole-file suites 61 pass, 0 fail/skip/cancel; all R1/R2 probes rerun, zero forbidden TCP/UDP, storage halts counted and latch preserved, zero owned processes/listeners. One BUILD-LOG entry and packages/cli/live changes only; local commit authorized, independent review pending. Queue refused linked worktree; no push/stash/forge/model/.53/host network-DNS-trust or unrelated-session action. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-report.md.
2026-11-06 UTC | ui797-support-fix-r4 implementation sub-agent | #797 review corrections on feat/ui797-support above 905d313cd | Minimal browser mount/environment boundary; independent config-byte proof and retained binding keep halt on sidecar damage; normal-path campaign hash/epoch agreement; unconditional namespaces and HTTPS 17443/oidc docs. Baseline failures retained; six controls corrected; whole files 112 pass, 0 fail/skip/cancel; five retained probes exit 0, zero forbidden traffic, no owned survivors/listeners, TMPDIR removed. Five support paths, one appended BUILD entry and this line; prefix bytes verified. Queue refused linked worktree; no queue/forge/push/stash/model/secrets/.53/live or unrelated-session action. Local author candidate for independent review. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md.
+26 -12
View File
@@ -72,10 +72,11 @@ the instance data root. Use owner-only directories (0700) and campaign files
The test instance uses its own proxy; there is no shared-proxy attachment here.
Requirements: the repository's pinned Node dependencies, Node with `node:sqlite`,
OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) when the
OS user's legacy `~/.pki/nssdb` exists. All examples assume a provisioner already
created/validated the named instance through `scripts/bootstrap.sh --instance
ui797`, with a matching `instance.json`. Never change HOME or clear agent markers.
OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) with
usable user, mount, network and PID namespaces on every browser host. All
examples assume a provisioner already created/validated the named instance through `scripts/bootstrap.sh --instance
ui797`, with a matching `instance.json`. Preserve the owner shell HOME and agent
markers.
For temporary test directories set `TMPDIR` using
`mktemp -d /mnt/storage/scratch/tmp/XXXXXX` (never `/tmp`).
@@ -97,8 +98,9 @@ node packages/cli/live/isolated-identity.mjs prepare \
Add that exact `console.sso` block through the approved test-config setup step.
The public origin is `https://127.0.0.1:17443`, issuer
`https://127.0.0.1:17443/oidc/`. Only `ui797-agent-owner` can be issued, displayed
as `ui797-owner`. Authorization immediately redirects back with a random,
`https://127.0.0.1:17443/oidc/`, behind the TLS forwarder to Console port 17440.
There is no identity listener on port 17442. Only `ui797-agent-owner` can be
issued, displayed as `ui797-owner`. Authorization immediately redirects back with a random,
single-use 60-second code, state and S256 PKCE; signed ID tokens last 300 seconds.
There is no refresh grant, alternate subject or live identity-provider integration.
@@ -149,9 +151,15 @@ 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
directory. If Chromium would prefer the user's legacy NSS store, Bubblewrap
mounts the private database over that path **only inside the browser process's
mount namespace**. HOME and host trust files remain unchanged. Certificate
directory. Bubblewrap starts with an empty mount view, adds read-only `/usr`,
libraries and selected NSS/font/certificate configuration, a minimal private
`/dev` and `/proc`, and private tmpfs `/tmp`, `/run` and home. Only this browser's
evidence/profile/download directories and proxy/control sockets are writable
host mounts. Host home, runtime directories, pathname sockets and credential
paths are hidden. An explicit environment allowlist supplies a private HOME and
XDG directories; host D-Bus/SSH-agent addresses and secrets are not inherited.
The private NSS database is mounted at the sandbox HOME's `.pki/nssdb`; the
owner shell HOME and host trust files remain unchanged. Certificate
verification stays enabled; a different CA is rejected. Startup refuses when the
required trust setup or namespace is unavailable; it does not fall back to a
certificate-error exception.
@@ -268,9 +276,15 @@ Initialization exclusively writes `campaign.json.binding.json` beside the owner
campaign, outside the ledger volume, before creating the ledger. Preserve this
0600 file and the campaign: they independently pin the target and ledger path
when accounting storage is unavailable. Checks never recreate that file or an
accounting epoch. A campaign initialized by an older implementation without this
pin requires intact ledger evidence to identify its target; do not reinitialize
or reset an existing campaign to work around missing evidence.
accounting epoch. The version-2 ledger manifest independently pins config bytes,
so missing, unreadable, malformed or wrong-mode sidecars stop and still attempt halt against
the verified target. Each report and halt receipt includes `evidenceErrors`;
damaged files are preserved. An intact sidecar must agree with both the owner
campaign bytes and ledger campaign/epoch. A running service retains its verified
binding and epoch while revalidating the actual target on every tick. Legacy
version-1 ledgers require their intact independent sidecar to verify config bytes;
a sidecar-less legacy ledger refuses targeting. Never reinitialize or reset an
existing campaign to work around missing evidence.
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
+53 -24
View File
@@ -61,11 +61,12 @@ export function initialize({ campaign, ledger }) {
outsideRuntime(ledger,{dataRoot:b.dataRoot},c.config);
// Independent owner evidence survives loss of the accounting volume. Created
// only at initialization; a check must never reconstruct an epoch or this pin.
exclusive(campaign+'.binding.json',JSON.stringify({version:1,hash,configHash:digest(readFileSync(c.config,'utf8')),binding:b,ledger})+'\n');
const configHash=digest(readFileSync(c.config,'utf8'));
exclusive(campaign+'.binding.json',JSON.stringify({version:1,hash,configHash,binding:b,ledger,campaign:c})+'\n');
flushDir(dirname(campaign));
privateDir(ledger,true);
for (const cap of c.caps) exclusive(join(ledger,filename(cap)),JSON.stringify({ version:1,cap })+'\n');
exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:1,hash,binding:b,campaign:c })+'\n');
exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:2,hash,configHash,binding:b,campaign:c })+'\n');
flushDir(ledger);
return { initialized:true,ledger };
}
@@ -132,33 +133,53 @@ function journal(ledger,cap) {
}
function loadLedger(ledger) {
privateDir(ledger); const p=JSON.parse(readPrivate(join(ledger,'campaign.json')));
if (p.version !== 1 || !p.binding || !p.campaign) fail('ledger-manifest-invalid');
if (![1,2].includes(p.version) || !p.binding || !p.campaign ||
(p.version===2 && !/^[0-9a-f]{64}$/.test(p.configHash))) fail('ledger-manifest-invalid');
return p;
}
function checkTarget({campaign,ledger}) {
function checkTarget({campaign,ledger}, retained=null) {
let p, pin, reason=null, writable=true;
const evidenceErrors=[];
try { privateDir(ledger); } catch { writable=false; reason='ledger-storage-unavailable'; }
try {
pin=JSON.parse(readPrivate(campaign+'.binding.json'));
if(pin?.version!==1 || !pin.binding || !/^[0-9a-f]{64}$/.test(pin.configHash)) throw new Error('invalid');
} catch(e) {
pin=null;
evidenceErrors.push(e.code==='ENOENT'?'owner-binding-missing':'owner-binding-unreadable-or-invalid');
}
try { if (!writable) throw new Error(reason); p=loadLedger(ledger); } catch {
const {c,hash}=campaignAt(campaign);pin=JSON.parse(readPrivate(campaign+'.binding.json'));
if(pin.version!==1 || pin.hash!==hash || pin.ledger!==ledger || !pin.binding) fail('owner-binding-invalid-or-changed');
p={campaign:c,hash,binding:pin.binding}; reason??='ledger-manifest-invalid';
reason??='ledger-manifest-invalid';
if(retained) p=retained.p;
else {
if(!pin || pin.ledger!==ledger) fail('owner-binding-unverified');
const {c,hash}=campaignAt(campaign);
// A fallback must never infer a new target/epoch from altered owner bytes.
if(pin.hash!==hash) fail('owner-binding-invalid-or-changed');
p={campaign:pin.campaign??c,hash:pin.hash,configHash:pin.configHash,binding:pin.binding};
}
}
const c=p.campaign;
// 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);
// Older intact ledgers retain their original binding format. New campaigns
// additionally pin config bytes in independent owner evidence, without rewriting them.
if(pin || existsSync(campaign+'.binding.json')) {
pin??=JSON.parse(readPrivate(campaign+'.binding.json'));
if(pin.version!==1 || pin.ledger!==ledger || JSON.stringify(pin.binding)!==JSON.stringify(p.binding) ||
pin.configHash!==digest(readFileSync(c.config,'utf8')))fail('campaign-instance-changed');
const configHash=p.configHash??pin?.configHash??retained?.configHash;
if(!configHash) fail('owner-binding-unverified');
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
for(const source of [pin,retained && {...retained.p,ledger,configHash:retained.configHash}].filter(Boolean)) {
if(source.ledger!==ledger || JSON.stringify(source.binding)!==JSON.stringify(p.binding) || source.configHash!==configHash) {
if(source!==pin || !p.configHash) fail('campaign-instance-changed');
evidenceErrors.push('owner-binding-target-changed');
}
if(source.hash!==p.hash || (source.campaign && JSON.stringify(source.campaign)!==JSON.stringify(c)))
evidenceErrors.push('owner-binding-epoch-changed');
}
return {p,c,reason,writable};
reason??=evidenceErrors[0]??null;
return {p,c,reason,writable,configHash,evidenceErrors};
}
export async function check(opts, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync, stopReason=null }={}) {
export async function check(opts, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync, stopReason=null, retained=null }={}) {
const {ledger,campaign}=opts;
const {p,c,reason:targetReason,writable}=checkTarget(opts);
const {p,c,reason:targetReason,writable,configHash,evidenceErrors}=checkTarget(opts,retained);
const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false;
if(writable) try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; }
// An interrupted/active/unwritable lock prevents accounting, never the verified halt.
@@ -210,27 +231,33 @@ export async function check(opts, { now=Date.now, read=readAccounting, halt=supp
const storageErrors=[];
try {
if(!writable) throw new Error('unavailable');
if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals})+'\n'); flushDir(ledger); }
if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals,evidenceErrors})+'\n'); flushDir(ledger); }
} catch { storageErrors.push('stop-latch-not-durable'); }
// Always attempt halt, even when the evidence volume cannot persist the latch.
let result,error;
try { checkBinding(p.binding,c); result=await halt(p.binding); } catch { error='supported-halt-refused-or-unknown'; }
try {
checkBinding(p.binding,c);
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
result=await halt(p.binding);
} catch { error='supported-halt-refused-or-unknown'; }
try {
if(!writable) throw new Error('unavailable');
const attempt=readdirSync(ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length+1;
exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError:storageErrors[0]??null})+'\n'); flushDir(ledger);
exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError:storageErrors[0]??null,evidenceErrors})+'\n'); flushDir(ledger);
} catch { storageErrors.push('stop-receipt-not-durable'); }
if (storageErrors.length) throw Object.assign(new Error(storageErrors.join('; ')),{
result:{stopped:true,reason,totals,halt:result??null,haltError:error??null,storageErrors},
result:{stopped:true,reason,totals,halt:result??null,haltError:error??null,storageErrors,evidenceErrors},
});
if (error) fail(error);
return {stopped:true,reason,totals,halt:result};
if (error) throw Object.assign(new Error(error),{result:{stopped:true,reason,totals,evidenceErrors}});
return {stopped:true,reason,totals,halt:result,evidenceErrors};
}
return {stopped:false,totals};
} finally { if (locked) try { rmdirSync(lock); } catch { /* Preserve an unavailable lock; never recreate its parent. */ } }
}
export function status({ campaign,ledger }) {
const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign);
const target=checkTarget({campaign,ledger});
if(target.reason) fail(target.reason);
outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config);
const owner=campaignAt(campaign);
if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed');
@@ -238,11 +265,13 @@ export function status({ campaign,ledger }) {
return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals };
}
export async function serveBudget(opts, {interval=1000,signal,report=value=>console.log(JSON.stringify(value)),...deps}={}) {
checkTarget(opts); // Refuse an invalid initial target before entering the service loop.
// Retain independent target/config/epoch evidence, but revalidate the actual
// config, marker, root and store on EVERY tick, including just before halt.
const target=checkTarget(opts),retained=structuredClone({p:target.p,configHash:target.configHash});
let stopReason=null;
while(!signal?.aborted) {
try {
const result=await check(opts,{...deps,stopReason});
const result=await check(opts,{...deps,stopReason,retained});
if(result.stopped) stopReason=result.reason;
report(result);
} catch(e) {
+116 -8
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync,chmodSync,rmSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { join } from 'node:path';
import { initialize,check,status,readAccounting,supportedHalt,main,serveBudget,CUTOFF } from './budget-supervisor.mjs';
@@ -275,12 +276,22 @@ test('an unavailable ledger refuses invalid, foreign, changed or unpinned target
}
});
test('an older intact ledger keeps its epoch without creating an owner binding during checks',async t=>{
test('a required modern sidecar cannot be deleted to regain an allowance; unpinned legacy evidence refuses',async t=>{
const f=setup(t,100),manifest=readFileSync(join(f.ledger,'campaign.json'),'utf8');
unlinkSync(f.campaign+'.binding.json');
assert.equal((await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt})).stopped,false);
const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('missing required pin'),halt:f.halt});
assert.equal(result.stopped,true);assert.deepEqual(result.evidenceErrors,['owner-binding-missing']);
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),manifest);
assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,0);
assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,1);
const g=setup(t,100),file=join(g.ledger,'campaign.json'),old=JSON.parse(readFileSync(file));
old.version=1;delete old.configHash;writeFileSync(file,JSON.stringify(old));unlinkSync(g.campaign+'.binding.json');
await assert.rejects(check(g.opts,{now:()=>NOW,halt:g.halt}),/owner-binding-unverified/);
assert.equal(g.halts.length,0);
const h=setup(t,100),hfile=join(h.ledger,'campaign.json'),previous=JSON.parse(readFileSync(hfile));
previous.version=1;delete previous.configHash;writeFileSync(hfile,JSON.stringify(previous));
const before=readFileSync(hfile,'utf8');
assert.equal((await check(h.opts,{now:()=>NOW,halt:h.halt,read:()=>[]})).stopped,false);
assert.equal(readFileSync(hfile,'utf8'),before);
});
test('serve survives unavailable storage, reasserts after Resume each tick and preserves its in-memory stop on recovery',async t=>{
@@ -317,8 +328,9 @@ test('serve keeps retrying a refused halt and refuses targeting if the config ch
assert.equal(existsSync(join(f.ledger,'stop.json')),true);
});
test('real serve CLI stays alive and attempts supported halt every tick with a missing ledger',async t=>{
const f=setup(t,1);renameSync(f.ledger,f.ledger+'-offline');
for(const damage of ['missing-ledger','read','parse','mode'])test(`real serve CLI stays alive and attempts supported halt every tick with ${damage} damage`,async t=>{
const f=setup(t,1);
if(damage==='missing-ledger')renameSync(f.ledger,f.ledger+'-offline');else damagePin(f,damage);
const child=spawn(process.execPath,['packages/cli/live/budget-supervisor.mjs','serve','--campaign',f.campaign,'--ledger',f.ledger,'--interval','0.1'],{
env:{...process.env,MOSAIC_AGENT_RUN:'test-unavailable-ledger'},stdio:['ignore','pipe','pipe'],
});
@@ -335,8 +347,104 @@ test('real serve CLI stays alive and attempts supported halt every tick with a m
const [code,signal]=await once(child,'close');clearTimeout(timer);
assert.equal(signal,null,errors);assert.equal(code,0,errors);assert.ok(reports.length>=3,errors);
for(const r of reports) {
assert.equal(r.stopped,true);assert.equal(r.haltError,'supported-halt-refused-or-unknown');
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
assert.equal(r.stopped,true);
if(damage==='missing-ledger'){
assert.equal(r.haltError,'supported-halt-refused-or-unknown');
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
}else{assert.match(r.error,/supported-halt-refused/);assert.deepEqual(r.evidenceErrors,['owner-binding-unreadable-or-invalid']);}
}
assert.equal(existsSync(f.ledger),damage!=='missing-ledger');
});
// Every damaged-sidecar case uses the real usage store and preserves the evidence.
function damagePin(f,kind) {
const pin=f.campaign+'.binding.json';
if(kind==='read') chmodSync(pin,0o000);
if(kind==='parse') writeFileSync(pin,'{');
if(kind==='mode') chmodSync(pin,0o400);
return ()=>{
if(kind==='read'){assert.equal(lstatSync(pin).mode&0o777,0);chmodSync(pin,0o600);}
if(kind==='mode')assert.equal(lstatSync(pin).mode&0o777,0o400);
assert.equal(readFileSync(pin,'utf8'),kind==='parse'?'{':readFileSync(pin+'-original','utf8'));
};
}
for(const kind of ['read','parse','mode']) for(const state of ['restart','cutoff','over-cap','latched-resume'])
test(`sidecar ${kind} damage at ${state} still latches and halts on restart and between service ticks`,async t=>{
for(const running of [false,true]) await t.test(running?'running service':'fresh service',async t=>{
const f=setup(t,state==='over-cap'||state==='latched-resume'?3:100);
insert(f);
const pin=f.campaign+'.binding.json',epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');
writeFileSync(pin+'-original',readFileSync(pin),{mode:0o600});
const now=()=>state==='cutoff'?CUTOFF:NOW;
if(state==='latched-resume')await check(f.opts,{now,halt:f.halt});
let assertDamage;
if(!running)assertDamage=damagePin(f,kind);
const controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now,halt:f.halt,report:r=>{
reports.push(r);
if(running && reports.length===1){assertDamage=damagePin(f,kind);f.halts.length=0;}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.ok(r.evidenceErrors.includes('owner-binding-unreadable-or-invalid'));}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,running?2:state==='latched-resume'?4:3);
assertDamage();assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch);
assert.ok(existsSync(join(f.ledger,'stop.json')));
assert.ok(JSON.parse(readFileSync(join(f.ledger,`halt-${readdirSync(f.ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length}.json`))).evidenceErrors.length);
});
});
test('running service retains validated target evidence when both sidecar and ledger become unavailable',async t=>{
const f=setup(t,100),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{now:()=>NOW,interval:1,signal:controller.signal,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);
if(reports.length===1){writeFileSync(f.campaign+'.binding.json','{');renameSync(f.ledger,f.ledger+'-offline');}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.equal(r.storageErrors.length,2);assert.ok(r.evidenceErrors.length);}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,2);assert.equal(existsSync(f.ledger),false);
});
for(const kind of ['read','parse','mode'])test(`damaged sidecar (${kind}) never permits changed config bytes or foreign marker/root/store`,async t=>{
for(const changed of ['config','invalid','marker','root','store']) {
const f=setup(t,1);damagePin(f,kind);
if(changed==='config')writeFileSync(f.config,readFileSync(f.config,'utf8')+' ');
if(changed==='invalid')writeFileSync(f.config,'{');
if(changed==='marker')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'foreign'}));
if(changed==='root')renameSync(f.dataRoot,f.dataRoot+'-offline');
if(changed==='store')renameSync(join(f.dataRoot,'bus/bus.sqlite'),join(f.dataRoot,'bus/bus.sqlite-offline'));
await assert.rejects(check(f.opts,{now:()=>CUTOFF,halt:f.halt}));assert.equal(f.halts.length,0,changed);
}
});
for(const variant of ['coherent-epoch-edit','zero-pin-hash'])test(`real-store ${variant} stops without accepting a fresh accounting epoch`,async t=>{
const f=setup(t,5);insert(f);
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
const pin=f.campaign+'.binding.json',original=readFileSync(pin,'utf8');
if(variant==='coherent-epoch-edit') {
f.c.start='2026-11-06T02:29:30.000Z';const text=JSON.stringify(f.c);writeFileSync(f.campaign,text);
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;p.hash=createHash('sha256').update(text).digest('hex');writeFileSync(file,JSON.stringify(p));
for(const n of readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')))writeFileSync(join(f.ledger,n),readFileSync(join(f.ledger,n),'utf8').split('\n')[0]+'\n');
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-06T02:29:45.000Z',f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t2','transcript','end','known',4,0,0,0);
assert.equal(f.store.get('SELECT sum(input+output+cache_write+cache_read) total FROM usage_records').total,8);
} else {const p=JSON.parse(original);p.hash='0'.repeat(64);writeFileSync(pin,JSON.stringify(p));}
const before=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.reason,'owner-binding-epoch-changed');assert.equal(f.halts.length,1);
assert.deepEqual(readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
if(variant==='coherent-epoch-edit')assert.equal(readFileSync(pin,'utf8'),original);
assert.throws(()=>status(f.opts),/owner-binding-epoch-changed/);
});
test('semantic sidecar target damage stops using independent config/marker/root/store evidence',async t=>{
for(const field of ['ledger','binding','configHash']) {
const f=setup(t,100),path=f.campaign+'.binding.json',pin=JSON.parse(readFileSync(path));
if(field==='ledger')pin.ledger+='-changed';
if(field==='binding')pin.binding.business='changed-business';
if(field==='configHash')pin.configHash='0'.repeat(64);
const damaged=JSON.stringify(pin);writeFileSync(path,damaged);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('owner-binding-target-changed'));
assert.equal(f.halts.length,1);assert.equal(f.halts[0].business,f.c.business);assert.equal(readFileSync(path,'utf8'),damaged);
}
assert.equal(existsSync(f.ledger),false);
});
+23 -13
View File
@@ -5,10 +5,9 @@ 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, readlinkSync, unlinkSync } from 'node:fs';
import { appendFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs';
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);
@@ -33,13 +32,8 @@ async function chromium(evidence, ca, proxy) {
}
for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600);
const profile = join(evidence, 'profile'); privateDir(profile, true);
// NSS prefers ~/.pki/nssdb when it exists, even with XDG_DATA_HOME set. Overlay it
// in a private mount namespace, never mutate the owner's trust store or change HOME.
// On newer clean hosts, the process-local XDG database alone is sufficient.
const legacyNss = join(homedir(), '.pki/nssdb');
const legacy = existsSync(legacyNss);
if (legacy) plainPath(legacyNss);
const executable = process.env.CHROMIUM || '/usr/bin/chromium';
plainPath(executable);
const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage',
'--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic',
`--proxy-server=http://127.0.0.1:${proxy.port}`,'--proxy-bypass-list=<-loopback>',
@@ -47,12 +41,28 @@ async function chromium(evidence, ca, proxy) {
`--user-data-dir=${profile}`];
// Bubblewrap brings up the new namespace's own loopback. There is no veth,
// route, DNS forwarder or host-network FD. Only the fixed proxy has a relay.
const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-net','--unshare-pid',
'--bind','/','/','--dev-bind','/dev','/dev','--proc','/proc',
...(legacy ? ['--bind',nss,legacyNss] : []),'--',process.execPath,
fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),readlinkSync('/proc/self/ns/net'),
// Start with bwrap's empty root. A network namespace does not hide pathname
// Unix sockets: never expose host /run, /tmp, home, devices or the repository.
const system = ['/usr','/etc/ld.so.cache','/etc/nsswitch.conf','/etc/passwd','/etc/group',
'/etc/fonts','/etc/ssl/certs','/etc/localtime'];
const mounts = system.filter(path=>existsSync(path)).flatMap(path=>['--ro-bind',path,path]);
for (const path of ['/bin','/sbin','/lib','/lib64']) if (existsSync(path)) {
mounts.push(...(lstatSync(path).isSymbolicLink() ? ['--symlink',readlinkSync(path),path] : ['--ro-bind',path,path]));
}
const home='/home/browser', runtime='/run/browser';
const environment={PATH:'/usr/bin:/bin',LANG:'C.UTF-8',HOME:home,PWD:home,TMPDIR:'/tmp',
XDG_RUNTIME_DIR:runtime,XDG_DATA_HOME:xdg,XDG_CONFIG_HOME:home+'/.config',XDG_CACHE_HOME:home+'/.cache'};
const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-user','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts',
...mounts,'--dev','/dev','--proc','/proc','--tmpfs','/tmp','--tmpfs','/run','--tmpfs','/home',
'--dir',home,'--dir',runtime,'--bind',evidence,evidence,
'--dir',home+'/.pki','--bind',nss,home+'/.pki/nssdb',
'--ro-bind',process.execPath,process.execPath,
'--ro-bind',fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),'/opt/ui797/browser-network.mjs',
...(executable.startsWith('/usr/') ? [] : ['--ro-bind',executable,executable]),
'--chdir',home,'--clearenv',...Object.entries(environment).flatMap(([key,value])=>['--setenv',key,value]),
'--',process.execPath,'/opt/ui797/browser-network.mjs',readlinkSync('/proc/self/ns/net'),
proxy.socket,String(proxy.port),executable,...chromeArgs],
{ env:{...process.env,XDG_DATA_HOME:xdg}, stdio: ['ignore','ignore','ignore','pipe','pipe'] });
{ env:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8'}, stdio: ['ignore','ignore','ignore','pipe','pipe'] });
let id = 0, buffer = '';
const pending = new Map(), events = new Map();
const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); };
+70 -1
View File
@@ -1,7 +1,7 @@
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createServer as tcpServer } from 'node:net';
import { createServer as tcpServer,createConnection } from 'node:net';
import { createSocket } from 'node:dgram';
import { readFileSync, readdirSync, existsSync,writeFileSync,lstatSync,readlinkSync } from 'node:fs';
import { join } from 'node:path';
@@ -173,6 +173,75 @@ for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`C
for(const count of counters)assert.equal(count.datagrams??count.connections,0,JSON.stringify(count));
});
for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`actual Chromium ${origin} cannot reach parent pathname D-Bus`,async t=>{
const f=await setup(t,'<!doctype html><h1>D-Bus boundary</h1>');
if(origin.startsWith('https:')){const id=await serve(f.identity);f.after(()=>closeServer(id));}
const path=join(f.root,'dbus.sock');let connections=0,auth=0;
const bus=tcpServer(peer=>{connections++;peer.on('error',()=>{});peer.on('data',data=>{if(data.includes(Buffer.from('AUTH EXTERNAL')))auth++;peer.destroy();});peer.setTimeout(100,()=>peer.destroy());});
await new Promise((r,j)=>{bus.once('error',j);bus.listen(path,r);});f.after(()=>new Promise(r=>bus.close(r)));
// Prove the fixture is reachable from the host before asserting zero traffic.
await new Promise((r,j)=>{const p=createConnection(path);p.once('error',j);p.once('connect',()=>p.end('AUTH EXTERNAL positive control'));p.on('data',()=>{});p.once('close',r);});
assert.equal(connections,1);assert.equal(auth,1);connections=0;auth=0;
const saved=process.env.DBUS_SESSION_BUS_ADDRESS;
process.env.DBUS_SESSION_BUS_ADDRESS='unix:path='+path;
try {await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')});await pause(500);await callBrowser(f.evidence,'stop');}
finally {if(saved===undefined)delete process.env.DBUS_SESSION_BUS_ADDRESS;else process.env.DBUS_SESSION_BUS_ADDRESS=saved;}
t.diagnostic(JSON.stringify({origin,connections,auth}));assert.equal(connections,0);assert.equal(auth,0);
});
test('CHROMIUM diagnostic sees only browser mounts and explicit environment; host sockets and a 0600 file stay hidden',async t=>{
const f=await setup(t,'<!doctype html><h1>Native mount boundary</h1>');
const path=join(f.root,'host-agent.sock'),abstract='\0ui797-native-'+process.pid;
let pathnameHits=0,abstractHits=0;
for(const [address,count] of [[path,()=>pathnameHits++],[abstract,()=>abstractHits++]]) {
const server=tcpServer(peer=>{count();peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{server.once('error',j);server.listen(address,r);});f.after(()=>new Promise(r=>server.close(r)));
await new Promise((r,j)=>{const p=createConnection(address);p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
}
assert.equal(pathnameHits,1);assert.equal(abstractHits,1);pathnameHits=abstractHits=0;
let tcpHits=0,udpHits=0;
const tcp=tcpServer(peer=>{tcpHits++;peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{tcp.once('error',j);tcp.listen(0,'127.0.0.1',r);});f.after(()=>new Promise(r=>tcp.close(r)));
await new Promise((r,j)=>{const p=createConnection({host:'127.0.0.1',port:tcp.address().port});p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
const udp=createSocket('udp4');udp.on('message',()=>udpHits++);
await new Promise(r=>udp.bind(0,'127.0.0.1',r));f.after(()=>new Promise(r=>udp.close(r)));
await new Promise(r=>udp.send('positive',udp.address().port,'127.0.0.1',r));await pause(50);
assert.equal(tcpHits,1);assert.equal(udpHits,1);tcpHits=udpHits=0;
const secret=join(f.root,'synthetic-credential');writeFileSync(secret,'synthetic fixture only',{mode:0o600});
assert.equal(readFileSync(secret,'utf8'),'synthetic fixture only');
const output=join(f.evidence,'native.json'),wrapper=join(f.root,'chromium-diagnostic.mjs');
// The supported override is a single read-only executable. It needs no host
// script directory or inherited variables, then starts the actual Chromium.
writeFileSync(wrapper,`#!${process.execPath}
import {createConnection} from 'node:net';
import {createSocket} from 'node:dgram';
import {readFileSync,writeFileSync,existsSync,readlinkSync,closeSync} from 'node:fs';
import {spawn} from 'node:child_process';
const hostNet=${JSON.stringify(readlinkSync('/proc/self/ns/net'))};
if(readlinkSync('/proc/self/ns/net')===hostNet)throw Error('network namespace missing');
const attempts=[];
for(const path of ${JSON.stringify([path,abstract,{host:'127.0.0.1',port:tcp.address().port}])})attempts.push(await new Promise(resolve=>{
const p=createConnection(path);p.setTimeout(300,()=>p.destroy());p.on('connect',()=>{p.end('unexpected');resolve('connected');});p.on('error',e=>resolve(e.code));p.on('close',()=>resolve('closed'));
}));
const udp=createSocket('udp4');await new Promise(r=>udp.send('negative',${udp.address().port},'127.0.0.1',()=>{udp.close();r();}));
let file;try{readFileSync(${JSON.stringify(secret)});file='readable';}catch(e){file=e.code;}
writeFileSync(${JSON.stringify(output)},JSON.stringify({attempts,file,environment:process.env,hostRoot:existsSync(${JSON.stringify(f.config)}),hostRuntime:existsSync('/run/dbus/system_bus_socket'),privateHome:process.env.HOME==='/home/browser',privateRuntime:process.env.XDG_RUNTIME_DIR==='/run/browser',nss:existsSync(process.env.HOME+'/.pki/nssdb/cert9.db')}));
const child=spawn('/usr/bin/chromium',process.argv.slice(2),{stdio:['ignore','ignore','ignore',3,4]});closeSync(3);closeSync(4);
child.once('exit',(code)=>{process.exitCode=code;});
`,{mode:0o700});
const names=['CHROMIUM','DBUS_SESSION_BUS_ADDRESS','SSH_AUTH_SOCK','XDG_RUNTIME_DIR','UI797_SYNTHETIC_SECRET'];
const saved=Object.fromEntries(names.map(n=>[n,process.env[n]]));
Object.assign(process.env,{CHROMIUM:wrapper,DBUS_SESSION_BUS_ADDRESS:'unix:path='+path,SSH_AUTH_SOCK:path,XDG_RUNTIME_DIR:f.root,UI797_SYNTHETIC_SECRET:'synthetic env canary'});
try {await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});await callBrowser(f.evidence,'stop');}
finally {for(const n of names)if(saved[n]===undefined)delete process.env[n];else process.env[n]=saved[n];}
const result=JSON.parse(readFileSync(output));t.diagnostic(JSON.stringify(result));
assert.equal(result.file,'ENOENT');assert.equal(result.hostRoot,false);assert.equal(result.hostRuntime,false);
assert.equal(result.privateHome,true);assert.equal(result.privateRuntime,true);assert.equal(result.nss,true);
assert.ok(result.attempts.every(r=>r!=='connected'));assert.equal(pathnameHits,0);assert.equal(abstractHits,0);assert.equal(tcpHits,0);assert.equal(udpHits,0);
assert.equal(result.environment.PWD,'/home/browser');
assert.deepEqual(Object.keys(result.environment).sort(),['HOME','LANG','PATH','PWD','TMPDIR','XDG_CACHE_HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_RUNTIME_DIR'].sort());
});
after(async()=>{
const {createConnection}=await import('node:net');
for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{