fix(cli): confine browser mounts and keep halt on binding damage (#797)
This commit is contained in:
1 parent
905d313cdf
commit
fce831c053
7 files changed
+291
-58
No files matched your search
@@ -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/.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
Reference in new issue
Block a user