diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 3701f1bc7..05b94ecfe 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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/. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index f58b66b1e..a88e707c0 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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. diff --git a/packages/cli/live/README.md b/packages/cli/live/README.md index 47e13a4e1..400412ad0 100644 --- a/packages/cli/live/README.md +++ b/packages/cli/live/README.md @@ -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 diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs index 03f67cabb..58512b3db 100644 --- a/packages/cli/live/budget-supervisor.mjs +++ b/packages/cli/live/budget-supervisor.mjs @@ -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) { diff --git a/packages/cli/live/budget-supervisor.test.mjs b/packages/cli/live/budget-supervisor.test.mjs index 2309cd80f..2946cc08f 100644 --- a/packages/cli/live/budget-supervisor.test.mjs +++ b/packages/cli/live/budget-supervisor.test.mjs @@ -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); }); diff --git a/packages/cli/live/ui-browser.mjs b/packages/cli/live/ui-browser.mjs index 70c34840c..cb44d5a66 100644 --- a/packages/cli/live/ui-browser.mjs +++ b/packages/cli/live/ui-browser.mjs @@ -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(); }; diff --git a/packages/cli/live/ui-browser.test.mjs b/packages/cli/live/ui-browser.test.mjs index 00ce14915..cc346489f 100644 --- a/packages/cli/live/ui-browser.test.mjs +++ b/packages/cli/live/ui-browser.test.mjs @@ -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,'

D-Bus boundary

'); + 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,'

Native mount boundary

'); + 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)=>{