fix(row253): supervise socket queries across sudo sessions
This commit is contained in:
@@ -5191,3 +5191,9 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-21 18:45 UTC | Gander row 196 preservation before rebase | Sage-2051, Mos order mos-1193, explicitly authorizes commit and fast-forward push of the current build before deferred full verification. Product and evidence preserved together; prior focused checks and stopped gates are recorded in the row196 packet, with no claim of full acceptance. Next: rebase onto a36523713 on new gander/row196-r2, report range-diff/patch-id/conflicts, then await Sage's clear for M1-M29 and all requested suites. No live action, force push, branch deletion or tests during hold.
|
||||
|
||||
2026-10-21 21:24:47 UTC | Gander row253 #1758 design r1 | Investigation began21:12Z under sage-2162; recorded now. Before: case2 installer still renders tester TCP and case1 acceptance requires it. After: proposed design covers socket marker migration, actual configured dataRoot within unchanged hardening, target-uid metadata queries, kernel-peer acceptance observation and explicit same-uid residual. Initial inventory saved; no passing final static-check claim, product edit, test, host or live action. Duckworth design review precedes code.
|
||||
|
||||
### 2026-10-21 row253 implementation prepared, gate pending
|
||||
|
||||
Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-script socket migrations, fake fixtures, updated retirement docs and 33 unique change checks. Static syntax/diff and source inventory checks pass; behavioral tests have not run. Sage-2191 queues the requested full scope and will send CLEAR. No host, unit or live config changed.
|
||||
|
||||
2026-10-22 02:13:30 UTC | Gander row253 C4 committed candidate, gate pending | Sage-2335 explicitly directs committing runnable source and tests before the whole gate. The repair implements the approved after-uid supervisor with separate inner/outer bounds; the static packet is b64aa316a. Current repaired bytes have run no syntax check, test, probe or host experiment. Earlier static syntax results apply only to the old build. Results from 8a8df4171 are historical and do not carry. Duckworth static verdict and Sage CLEAR precede all gate execution. F5 is row276 / #1781 in DEFERRED.md.
|
||||
|
||||
@@ -181,3 +181,5 @@ M1-M33 were retargeted where the repair changed source, with M34-M39 for absent
|
||||
F5 now names row 276 / #1781 in docs/plans/DEFERRED.md (sage-2331). That path will be in the row253 manifest. c4/UPSTREAM-PATHS.json records the requested 968db0bcd..ddfb29af7 comparison and the branch-base comparison. Only DEFERRED.md overlaps upstream since c3d88840c; its current upstream text was preserved before appending this row's entry. The branch-to-refactor comparison also lists the unlanded test files on our side. No rebase was performed.
|
||||
|
||||
c4/REPAIR.diff, *.repair.txt and REPAIR-SHAS.json freeze the complete repair for Duckworth's C1/C3/C5 static look. The original c4/*.sh.txt and FROZEN-PRODUCT.json stay unchanged. The runnable product and test files remain uncommitted pending the applicable gate; this packet commit is records only. No node_modules path is included.
|
||||
|
||||
2026-10-22 02:13:30 UTC: sage-2335 publishes b64aa316a and explicitly requires a committed runnable candidate before the gate. Accordingly the seven repaired source/test/doc paths, the existing BUILD-LOG append and this record are committed now. All seven bytes still match c4/REPAIR-SHAS.json; this commit changes no reviewed packet source. CANDIDATE-SHAS.json is refreshed for the new gate. MANIFEST.sha256 covers the eight product/test/doc/log paths, including DEFERRED.md. No repaired-source verification ran; the static verdict and CLEAR remain pending, and all old results are historical only.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"scripts/case1-accept.sh": "1302cfc659ff9073035b99d7b4071aa2c75070da142bb201d2f58a66e113d71f",
|
||||
"scripts/case2-unit.sh": "a84bb266d52a5c078171582e2f9a511becf56421ae218266e3238d704727f6dd",
|
||||
"packages/cli/tests/case1-accept.test.mjs": "21d6471c6ce2b3c0852cc723eae0731d1ced8ad1ddd6269a4f2e5026d556bfb8",
|
||||
"packages/cli/tests/case2-unit.test.mjs": "93cc7b6a534ba0fff12667f7c8957ec35349055de2428282ac94831390594649",
|
||||
"packages/webui/README.md": "2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9"
|
||||
"scripts/case2-unit.sh": "3ec41265b47770f70d60640a786b4f547ffb0157512fd8477ea7712e91a724fe",
|
||||
"scripts/case1-accept.sh": "f93eac4cc9dd9fc95a67be19d9fa7c7f6e7d7bfc84e662360b4aed05763850c7",
|
||||
"packages/cli/tests/case-sudo-fixture.mjs": "b259b8c7926e49829c8a956674021cb0cc85ddd65aebdc786d05cc08d7790008",
|
||||
"packages/cli/tests/case2-unit.test.mjs": "72a07e9ad9d5c8bb01851eef18f792f2fe9b95d56803e313fad6aa1f1d6c3e8c",
|
||||
"packages/cli/tests/case1-accept.test.mjs": "6cfd2963303315aa6911c4d4f6d75d3ab87d9fcba8e128b056216e7f85fd8901",
|
||||
"packages/webui/README.md": "2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9",
|
||||
"docs/plans/DEFERRED.md": "67bbf7bf57aa70dcade36ca4642f01cccb71ce01c130a88b84e1edb7ea674377"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Row253 committed C4 candidate manifest
|
||||
|
||||
MANIFEST.sha256 names the eight source/test/doc/log landing paths and pins their bytes. Its digest is the review identifier. The seven repair paths match c4/REPAIR-SHAS.json from b64aa316a; BUILD-LOG.md is append-only phase evidence. Land its row253 additions as appends, preserving later upstream entries.
|
||||
|
||||
Base: c3d88840c. Static repair packet: b64aa316a. Full records live under agents/gander/work/queue-253/. Those records are review evidence; the manifest separately identifies the eight landing paths. The current upstream overlap check against ddfb29af7 is c4/UPSTREAM-PATHS.json. DEFERRED.md preserves upstream entries and adds the F5 follow-up row276 / #1781.
|
||||
|
||||
Sage-2335 authorizes this runnable commit before the whole gate. No repaired-source syntax check, test, probe or host experiment has run. No results from 8a8df4171 carry. Duckworth's static verdict and Sage's CLEAR are required before gate execution. The final build review will receive fresh verification records.
|
||||
@@ -0,0 +1,8 @@
|
||||
43d3f1664f195839ff303dbb0bd0f040c177ac1923d0760a23c09cc7130ec1b5 BUILD-LOG.md
|
||||
67bbf7bf57aa70dcade36ca4642f01cccb71ce01c130a88b84e1edb7ea674377 docs/plans/DEFERRED.md
|
||||
b259b8c7926e49829c8a956674021cb0cc85ddd65aebdc786d05cc08d7790008 packages/cli/tests/case-sudo-fixture.mjs
|
||||
6cfd2963303315aa6911c4d4f6d75d3ab87d9fcba8e128b056216e7f85fd8901 packages/cli/tests/case1-accept.test.mjs
|
||||
72a07e9ad9d5c8bb01851eef18f792f2fe9b95d56803e313fad6aa1f1d6c3e8c packages/cli/tests/case2-unit.test.mjs
|
||||
2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9 packages/webui/README.md
|
||||
f93eac4cc9dd9fc95a67be19d9fa7c7f6e7d7bfc84e662360b4aed05763850c7 scripts/case1-accept.sh
|
||||
3ec41265b47770f70d60640a786b4f547ffb0157512fd8477ea7712e91a724fe scripts/case2-unit.sh
|
||||
+48
-14
@@ -270,20 +270,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
(`:1538`, `:1689`, `:1691`). For a later row that edits
|
||||
`stack-release.sh`. Webby N1 on row 238 r1 (REVIEW-r1.md, 433a95292,
|
||||
#1744). (2026-10-21)
|
||||
- **Gate runs mount the host's pi auth file.** `compose.yaml:43` mounts
|
||||
`${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only into
|
||||
the agent container. No suite sets `PI_AUTH_FILE`, so the
|
||||
`test-task.sh` cases that call `scripts/run-task.sh`, and
|
||||
`test-release.sh` through `release.sh` `health_check` (`:41-54`), run
|
||||
Compose with the host file. Row 197's lane `cleanEnv`
|
||||
(`scripts/testing/gate-lane.mjs:109`) keeps the default too: it neither
|
||||
sets nor passes `PI_AUTH_FILE`. The mount is read-only, and the legacy
|
||||
gate already worked this way before row 197. `test-auth.sh` uses its
|
||||
own fixtures but calls `auth.sh`, not Compose. The fix: a fixture
|
||||
`PI_AUTH_FILE` for the Compose-reaching cases, set by the suites or by
|
||||
the lane's `cleanEnv`, and a test that no gate step mounts the host
|
||||
default. It needs its own row. Webby N4 on the row 197 proof driver
|
||||
(bba870ee9), traced by Herb (herb-0051). (2026-10-21)
|
||||
- **Lane image prep inspects a base that BuildKit never tagged.**
|
||||
(N7 of the row 197 proof.) After the Compose build,
|
||||
`scripts/testing/lane-image.mjs:41` (`prepareImage`) runs
|
||||
@@ -360,6 +346,40 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
do nothing yet. The row that adds the Console library routes decides
|
||||
whether the tester listener gets `library.sync`. Duckworth's view is
|
||||
that it shouldn't, because sync is a human write. (2026-10-21)
|
||||
- **Console library routes: four library codes still map to 502.**
|
||||
(Dewey N2 on the row 254 manifest, #1759 comment 30173.) Row 254
|
||||
gave six library codes a STATUS entry in `packages/webui/src/bus.mjs`.
|
||||
`library-mapping-invalid`, `library-remote-invalid`,
|
||||
`library-state-invalid` and `library-git` still fall through
|
||||
`busStatus` to 502. The row that adds the Console library routes
|
||||
decides their statuses and gives its `humanCall` for `library.sync`
|
||||
a timeout over 130 seconds. (2026-10-21)
|
||||
- **T27 sweep test leaves two `sleep 60` children running.**
|
||||
(Darkwing row 251 slot, darkwing-0113 and darkwing-0117; Dewey on
|
||||
r2, comment 30168.) The sweep test spawns two detached shells,
|
||||
`planted` at `host-run.test.mjs:528` and `decoy` at `:536`, each
|
||||
running `sleep 60; :` with the run dir as `$0`. `sweepRunDir` kills
|
||||
the planted shell by pid (`host-run.mjs:238`), and `t.after` kills
|
||||
only the decoy shell. Neither kill reaches the `sleep` child, so each
|
||||
one lives for up to 60 s after the test and trips any busy check that
|
||||
follows. The fix is to kill the process group in both `t.after`
|
||||
hooks: `process.kill(-pid, 'SIGKILL')`. Each shell is spawned
|
||||
detached, so it leads its own group. `exec sleep 60` doesn't work.
|
||||
The sweep matches the run dir in `/proc/<pid>/cmdline`
|
||||
(`host-run.mjs:236`), and exec drops the `$0` that carries it, so the
|
||||
planted process would survive and the "planted process killed" assert
|
||||
would fail. This binds the next row that touches
|
||||
`packages/bus/tests/host-run.test.mjs`. (2026-10-21)
|
||||
- **Real-model suite cases run outside the gate.** Row 244 moved
|
||||
`test-task.sh` fork recall (M11) and user recall (M16) behind
|
||||
`MOSAIC_LIVE_PROVIDER=1`, and `test-release.sh` activations to the mock
|
||||
adapter. Before any re-pin whose tree changes `packages/harness` or the
|
||||
session fork code (`scripts/mosaic-task.mjs` session and
|
||||
`sessionForkFrom` handling, the pi adapter), an operator runs
|
||||
`MOSAIC_LIVE_PROVIDER=1 bash scripts/test-task.sh` with the real pi
|
||||
credential and a real `release.sh activate`, and records both results on
|
||||
the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244
|
||||
r1 (#1757). (2026-10-21)
|
||||
|
||||
## Queue
|
||||
|
||||
@@ -425,3 +445,17 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
at a time (Filbert F2; Darkwing and Filbert T1 on #1522;
|
||||
flows.test.mjs).
|
||||
The frozen-cgroup test (Filbert F1) stays open above.
|
||||
- Gate runs mount the host's pi auth file (Webby N4 on the row 197 proof
|
||||
driver, 059577886): closed by row 244 (#1757), landing e69260a3c. The
|
||||
suites and the lane's `cleanEnv` export a 0600 fixture `PI_AUTH_FILE`,
|
||||
`guardDocker` refuses a Compose run without one, and
|
||||
`packages/cli/tests/gate-lane-auth.test.mjs` proves no gate step mounts
|
||||
the host default. The real-model cases this moved off the gate are open
|
||||
above.
|
||||
|
||||
## Row253 F5: legacy Case AS_USER supervision
|
||||
|
||||
Tracked as row 276, Gitea #1781 (sage-2331), owner Gander, reviewer Duckworth, after row253.
|
||||
Brief: docs/plans/2026-10-22_case-sudo-legacy-followup.md, “Brief: supervised legacy AS_USER calls”.
|
||||
|
||||
case1-accept.sh's legacy timeout330 around AS_USER talk and its AS_USER precheck, Claude version, PM launches and Node version diagnostics predate the new socket metadata/probe helpers. sudo's pty monitor can move them to a separate session; the old outer timeout does not prove their descendants are killed. The C4 repair in row253 covers only its new config query and peer probe. These legacy paths need the reviewed supervisor or a header explicitly explaining why each remains unsupervised. The follow-up must preserve the 300-second talk/reply contract, concurrent F5 scan, diagnostics and secret-output refusals, with fake-only session-hop tests and Sage's slot. No host or live unit change is authorized.
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
// Shared fake process model for the two Case host-script tests. No real sudo.
|
||||
// Call case_sudo_run RECORD_DIR COMMAND... after the caller's argv checks.
|
||||
// Missing session-mode preserves the original exec-style fake.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
export const CASE_SUDO_SESSION = String.raw`
|
||||
case_sudo_entry() {
|
||||
local record="$1" here sid; shift
|
||||
here="$BASHPID"
|
||||
read -r sid < <(/bin/ps -o sid= -p "$here")
|
||||
printf "%s %s\n" "$here" "$sid" >> "$record/session-pids"
|
||||
if { [ -f "$record/stall-config" ] && [[ "$*" == *MOSAIC_TESTER_SOCKET_V1* ]]; } ||
|
||||
{ [ -f "$record/stall-probe" ] && [[ "$*" == *SO_PEERCRED* ]]; }; then
|
||||
trap '' TERM HUP
|
||||
[ ! -f "$record/stall-output" ] || /bin/cat "$record/stall-output"
|
||||
exec /bin/sleep 60
|
||||
fi
|
||||
exec "$@"
|
||||
}
|
||||
case_sudo_run() {
|
||||
local record="$1" child="" rc=0; shift
|
||||
if [ "$(cat "$record/session-mode" 2>/dev/null)" != hop ]; then exec "$@"; fi
|
||||
local record="$1" child="" rc=0 mode=exec; shift
|
||||
[ ! -f "$record/session-mode" ] || mode="$(<"$record/session-mode")"
|
||||
if [ "$mode" != hop ]; then case_sudo_entry "$record" "$@"; return "$?"; fi
|
||||
# The fake stays as parent, just as sudo does on its pty path. KILL cannot
|
||||
# be caught or forwarded. The child creates the separate terminal session.
|
||||
trap '[ -z "$child" ] || kill -TERM -- "-$child" 2>/dev/null || :' TERM
|
||||
trap '[ -z "$child" ] || kill -HUP -- "-$child" 2>/dev/null || :' HUP
|
||||
trap '[ -z "$child" ] || kill -INT -- "-$child" 2>/dev/null || :' INT
|
||||
/usr/bin/setsid /bin/bash -c '
|
||||
record="$1"; shift
|
||||
here="$BASHPID"
|
||||
read -r pid sid < <(/bin/ps -o pid=,sid= -p "$here")
|
||||
printf "%s %s\n" "$pid" "$sid" >> "$record/session-pids"
|
||||
exec "$@"
|
||||
' -- "$record" "$@" &
|
||||
export -f case_sudo_entry
|
||||
/usr/bin/setsid /bin/bash -c 'case_sudo_entry "$@"' -- "$record" "$@" &
|
||||
child=$!
|
||||
# An interrupted wait returns while the child still runs; keep waiting.
|
||||
while :; do
|
||||
@@ -28,7 +40,9 @@ case_sudo_run() {
|
||||
`;
|
||||
|
||||
export function liveRecordedSessions(recordFile, readFile, procDirs) {
|
||||
const sessions = new Set(readFile(recordFile).trim().split('\n').filter(Boolean).map(line=>Number(line.trim().split(/\s+/)[1])));
|
||||
// Exec fake entries share the test runner's session. Only self-led sessions
|
||||
// are isolated and safe to scan here; exec coverage also checks exact PIDs.
|
||||
const sessions = new Set(readFile(recordFile).trim().split('\n').filter(Boolean).map(line=>line.trim().split(/\s+/).map(Number)).filter(([pid,sid])=>pid===sid).map(([,sid])=>sid));
|
||||
const live=[];
|
||||
for(const entry of procDirs()){
|
||||
if(!/^\d+$/.test(entry))continue;
|
||||
@@ -39,3 +53,90 @@ export function liveRecordedSessions(recordFile, readFile, procDirs) {
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
export function recordedPids(file) {
|
||||
try{return readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>line.trim().split(/\s+/).map(Number));}
|
||||
catch(error){if(error.code==='ENOENT')return [];throw error;}
|
||||
}
|
||||
|
||||
export function recordedLivePids(file) {
|
||||
return sessionPairs(recordedPids(file)).filter(([pid,sid])=>{
|
||||
try{
|
||||
const raw=readFileSync(`/proc/${pid}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' ');
|
||||
return Number(fields[3])===sid && fields[0]!=='Z' && fields[0]!=='X';
|
||||
}catch(error){if(error.code==='ENOENT'||error.code==='ESRCH')return false;throw error;}
|
||||
}).map(([pid])=>pid);
|
||||
}
|
||||
|
||||
export function killRecordedPids(file) {
|
||||
killPairs(recordedPids(file));
|
||||
}
|
||||
|
||||
function killPairs(pairs) {
|
||||
for(const [pid,sid] of sessionPairs(pairs)){
|
||||
try{
|
||||
const raw=readFileSync(`/proc/${pid}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' ');
|
||||
if(Number(fields[3])===sid && fields[0]!=='Z' && fields[0]!=='X')process.kill(pid,'SIGKILL');
|
||||
}catch(error){if(error.code!=='ENOENT'&&error.code!=='ESRCH')throw error;}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionPairs(pairs) {
|
||||
// Only self-led records authorize a session scan. Exec-mode records belong
|
||||
// to the runner's session and authorize checking their exact PIDs only.
|
||||
const sessions=new Set(pairs.filter(([pid,sid])=>pid===sid).map(([,sid])=>sid));
|
||||
const found=new Map(pairs.map(pair=>[pair[0],pair]));
|
||||
if(sessions.size)for(const entry of readdirSync('/proc')){
|
||||
if(!/^\d+$/.test(entry))continue;
|
||||
try{
|
||||
const raw=readFileSync(`/proc/${entry}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' '),sid=Number(fields[3]);
|
||||
if(sessions.has(sid))found.set(Number(entry),[Number(entry),sid]);
|
||||
}catch(error){if(error.code!=='ENOENT'&&error.code!=='ESRCH')throw error;}
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
child.kill('SIGKILL');await exited;
|
||||
};
|
||||
t.after(stop);return stop;
|
||||
}
|
||||
|
||||
export function cleanupFixture(t,files) {
|
||||
let captured=[];
|
||||
const capture=()=>{captured=files.flatMap(recordedPids);};
|
||||
// tmp(t)'s directory-removal hook may run first. Retain PID/SID pairs in
|
||||
// memory before assertions, then verify the kernel sid again before killing.
|
||||
t.after(()=>killPairs([...captured,...files.flatMap(recordedPids)]));
|
||||
return capture;
|
||||
}
|
||||
|
||||
export async function pollFixtureGone(files) {
|
||||
for(let n=0;n<50;n++){
|
||||
if(files.every(file=>recordedLivePids(file).length===0))return [];
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
return files.flatMap(recordedLivePids);
|
||||
}
|
||||
|
||||
export function blockingQuerySource(records,kind='signals',member,proof) {
|
||||
const py=`import os,sys\nos.setpgid(0,0)\nwith open(sys.argv[2],"a") as f:f.write(str(os.getpid())+" "+str(os.getsid(0))+chr(10))\nos.execv(sys.argv[1],[sys.argv[1],"60"])`;
|
||||
return `import fs from 'node:fs';import {spawn} from 'node:child_process';
|
||||
const records=${JSON.stringify(records)};
|
||||
function record(pid){const s=fs.readFileSync('/proc/'+pid+'/stat','utf8');const sid=Number(s.slice(s.lastIndexOf(') ')+2).split(' ')[3]);fs.appendFileSync(records,pid+' '+sid+String.fromCharCode(10));}
|
||||
record(process.pid);
|
||||
export function loadSystem(){
|
||||
${kind==='sleep'?`const c=spawn('/usr/bin/sleep',['60'],{stdio:'inherit'});record(c.pid);`:''}
|
||||
${kind==='signals'?`process.on('SIGTERM',()=>{});process.on('SIGHUP',()=>{});`:''}
|
||||
${kind==='member'?`const c=spawn('/usr/bin/python3',['-I','-S','-c',${JSON.stringify(py)},${JSON.stringify(member)},records],{stdio:'inherit'});for(let n=0;n<200;n++){try{const s=fs.readFileSync('/proc/'+c.pid+'/stat','utf8');if(s.includes('('+${JSON.stringify(member?.split('/').at(-1))}+') ')){fs.writeFileSync(${JSON.stringify(proof)},s);break;}}catch{}Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,5);}`:''}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sym
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { CASE_SUDO_SESSION } from "./case-sudo-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const SCRIPT = join(REPO, "scripts", "case1-accept.sh");
|
||||
@@ -87,7 +87,8 @@ np=""; IFS=: read -ra parts <<< "$p"; for x in "\${parts[@]}"; do np+="$ROOT_DIR
|
||||
cmd="$1"; shift
|
||||
args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then args+=("$ROOT_DIR$a"); else args+=("$a"); fi; done
|
||||
export HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH"
|
||||
case_sudo_run "$HC" "$ROOT_DIR$cmd" "\${args[@]}"`,
|
||||
[ ! -e "$ROOT_DIR$cmd" ] || cmd="$ROOT_DIR$cmd"
|
||||
case_sudo_run "$HC" "$cmd" "\${args[@]}"`,
|
||||
};
|
||||
|
||||
// Executables inside the tree, run by the fake sudo or by the script itself. Node marks a run outside
|
||||
@@ -97,7 +98,11 @@ const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-ro
|
||||
exec "$REAL_NODE" "$@"`;
|
||||
// The real Python payload runs against a mock socket object, never an OS listener.
|
||||
const PYTHON_STUB = `[ -f "$HC/peer-output" ] && { cat "$HC/peer-output"; exit "$(cat "$HC/peer-status")"; }
|
||||
[ -f "$HC/peer-hang" ] && { echo "$BASHPID" > "$HC/peer-pid"; exec sleep 60; }
|
||||
[ -f "$HC/peer-hang" ] && {
|
||||
here="$BASHPID"; read -r sid < <(/bin/ps -o sid= -p "$here")
|
||||
echo "$here" > "$HC/peer-pid"; printf '%s %s\\n' "$here" "$sid" >> "$HC/query-pids"
|
||||
trap '' TERM HUP; exec sleep 60
|
||||
}
|
||||
if [ -f "$HC/peer-change" ]; then cat "$HC/peer-change" > "$HC/stat-socket"; fi
|
||||
if [ -f "$HC/peer-pid-change" ]; then cat "$HC/peer-pid-change" > "$HC/show-mosaic-webui.service"; fi
|
||||
exec /usr/bin/python3 -I -S -c '
|
||||
@@ -301,6 +306,64 @@ test('row253 P8 refuses both retired TCP and board listeners',t=>{
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 acceptance query inner reason holds under both sudo models',async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=host(t),files=[join(h.hc,'session-pids'),join(h.hc,'query-pids')];
|
||||
put(join(h.hc,'session-mode'),mode);
|
||||
put(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/cli/src/config.mjs'),blockingQuerySource(files[1]));
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=run(h),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,2,r.out+r.err);assert.equal(r.err,'case1-accept: refusing: tester config query inner-timeout\n');
|
||||
assert.ok(seconds<7,`${mode}: ${seconds}s`);assert.equal(r.out,'');assert.deepEqual(calls(h,'mosaic'),[]);
|
||||
assert.deepEqual(await pollFixtureGone(files),[]);await stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 acceptance query outer reason discards stdout and names residual cleanup',async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=host(t),files=[join(h.hc,'session-pids')];put(join(h.hc,'session-mode'),mode);put(join(h.hc,'stall-config'),'');
|
||||
put(join(h.hc,'stall-output'),JSON.stringify({dataRoot:HOME+'/.mosaic-dev',socket:true}));
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=run(h),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,2,r.out+r.err);assert.equal(r.err,'case1-accept: refusing: tester config query outer-timeout\n');
|
||||
assert.ok(seconds>=7&&seconds<9,`${mode}: ${seconds}s; 2s preflight slack`);
|
||||
assert.equal(r.out,'');assert.deepEqual(calls(h,'mosaic'),[]);
|
||||
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'R3 session remains before fixture cleanup');
|
||||
killRecordedPids(files[0]);assert.deepEqual(await pollFixtureGone(files),[],'explicit R3 fixture cleanup');await stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 acceptance query overflow is output-limit in both models',async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=host(t),files=[join(h.hc,'session-pids')];put(join(h.hc,'session-mode'),mode);const capture=cleanupFixture(t,files);
|
||||
put(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/cli/src/config.mjs'),`process.stdout.write('X'.repeat(8192));export const loadSystem=()=>({dataRoot:'${HOME}/.mosaic-dev'});`);
|
||||
const r=run(h);capture();assert.equal(r.status,2,r.out+r.err);assert.equal(r.err,'case1-accept: refusing: tester config query output-limit\n');
|
||||
assert.equal(r.out,'');assert.deepEqual(calls(h,'mosaic'),[]);assert.deepEqual(await pollFixtureGone(files),[]);
|
||||
}
|
||||
});
|
||||
|
||||
for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} reason holds under both sudo models`,async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=host(t),files=[join(h.hc,'session-pids'),join(h.hc,'query-pids')];put(join(h.hc,'session-mode'),mode);
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
const start=performance.now(),r=run(h),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
|
||||
assert.doesNotMatch(r.out,/boundary=observed/);
|
||||
if(fault==='inner')assert.ok(seconds<4,`${mode}: ${seconds}s includes functional checks`);
|
||||
if(fault==='outer'){
|
||||
assert.ok(seconds>=4&&seconds<6,`${mode}: ${seconds}s; 2s functional-check slack`);
|
||||
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
|
||||
for(const file of files)killRecordedPids(file);
|
||||
}
|
||||
assert.deepEqual(await pollFixtureGone(files),[],fault==='outer'?'explicit R3 cleanup':mode);await stop();
|
||||
}
|
||||
});
|
||||
|
||||
// One fault per F check; each makes that check, and only that one, fail.
|
||||
const F_FAULTS = {
|
||||
F1: (h) => proc(h.root, 100, { ppid: 1, uid: 0, exe: NODE, comm: "node" }),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { CASE_SUDO_SESSION, liveRecordedSessions } from "./case-sudo-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const UNIT = join(REPO, "scripts", "case2-unit.sh");
|
||||
@@ -93,7 +93,7 @@ if [ -f '${root}/query-hang' ]; then
|
||||
echo "$BASHPID" >>'${root}/query-pids'; wait; exit
|
||||
fi
|
||||
if [ -f '${root}/query-orphan' ]; then
|
||||
(trap '' TERM; exec sleep 60) </dev/null >/dev/null 2>&1 &
|
||||
(trap '' TERM; here="$BASHPID"; read -r sid < <(/bin/ps -o sid= -p "$here"); printf '%s %s\\n' "$here" "$sid" >'${root}/query-owned'; exec sleep 60) </dev/null >/dev/null 2>&1 &
|
||||
echo "$!" >'${root}/query-pids'
|
||||
cat '${root}/query-output'; exit 0
|
||||
fi
|
||||
@@ -186,41 +186,62 @@ test('row253 protected homes refuse before any filesystem access',()=>{
|
||||
}
|
||||
});
|
||||
|
||||
// Draft counterexample for DESIGN-C4. Written after sage-2307, not run.
|
||||
test('row253 C4 query cleanup handles both exec and sudo session-hop fakes', async t=>{
|
||||
// C4 cases run through both sudo process models. Exec shares the runner's
|
||||
// session, so its session-wide check would be vacuous; exact recorded PIDs
|
||||
// are checked in both modes. Hop additionally isolates the fake session.
|
||||
for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query has exact inner reason and cleans all members`,async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=hub(t),pids=join(h.root,'session-pids'),queryPids=join(h.root,'query-pids'),copy=join(copyDir(h),'packages/cli/src/config.mjs');
|
||||
const h=hub(t),sessions=join(h.root,'session-pids'),queries=join(h.root,'query-pids'),files=[sessions,queries];
|
||||
writeFileSync(join(h.root,'session-mode'),mode);
|
||||
writeFileSync(pids,'');
|
||||
writeFileSync(copy,`import fs from 'node:fs';process.on('SIGTERM',()=>{});process.on('SIGHUP',()=>{});fs.appendFileSync(${JSON.stringify(queryPids)},process.pid+'\\n');const s=fs.readFileSync('/proc/self/stat','utf8');const sid=Number(s.slice(s.lastIndexOf(') ')+2).split(' ')[3]);if(sid===process.pid)fs.appendFileSync(${JSON.stringify(pids)},process.pid+' '+sid+'\\n');export function loadSystem(){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);}`);
|
||||
t.after(()=>{
|
||||
if(!existsSync(pids))return;
|
||||
// Explicit fixture cleanup is safe only for these recorded fixture PIDs.
|
||||
for(const line of [read(pids),existsSync(queryPids)?read(queryPids):''].join('\n').trim().split('\n')){
|
||||
const pid=Number(line.trim().split(/\s+/)[0]);
|
||||
if(pid>0)try{process.kill(pid,'SIGKILL');}catch{}
|
||||
}
|
||||
});
|
||||
const r=unit(h,{profile:'factory'});refuses(r,/query/);
|
||||
for(let n=0;n<50;n++){
|
||||
if(!liveRecordedSessions(pids,read,()=>readdirSync('/proc')).length)break;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
assert.deepEqual(liveRecordedSessions(pids,read,()=>readdirSync('/proc')),[],mode);
|
||||
for(const pid of read(queryPids).trim().split('\n')){
|
||||
let state='gone';try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{}
|
||||
assert.ok(state==='gone'||state==='Z',`${mode}: query ${pid} still ${state}`);
|
||||
}
|
||||
assert.equal(existsSync(h.log),false,mode);
|
||||
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
|
||||
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
|
||||
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
|
||||
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
||||
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],mode);
|
||||
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
|
||||
assert.equal(existsSync(h.log),false,mode);await stopGuard();
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 overflow is output-limit in both sudo modes',async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=hub(t),files=[join(h.root,'session-pids')];writeFileSync(join(h.root,'session-mode'),mode);const capture=cleanupFixture(t,files);
|
||||
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),`process.stdout.write('X'.repeat(8192));export const loadSystem=()=>({dataRoot:${JSON.stringify(h.dataRoot)}});`);
|
||||
const r=unit(h,{profile:'factory'});capture();refuses(r,/^case2-unit: tester config query output-limit\n$/);
|
||||
assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false);
|
||||
assert.deepEqual(await pollFixtureGone(files),[]);
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 stalled supervisor uses the outer reason and explicit residual cleanup',async t=>{
|
||||
for(const mode of ['exec','hop']){
|
||||
const h=hub(t),file=join(h.root,'session-pids'),files=[file];writeFileSync(join(h.root,'session-mode'),mode);
|
||||
writeFileSync(join(h.root,'stall-config'),'');writeFileSync(join(h.root,'stall-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
|
||||
refuses(r,/^case2-unit: tester config query outer-timeout\n$/);
|
||||
assert.ok(seconds>=7&&seconds<9,`${mode}: ${seconds}s; 2s slack includes root preflight`);
|
||||
assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false);
|
||||
if(mode==='hop')assert.ok(recordedLivePids(file).length>0,'R3 residual exists before fixture cleanup');
|
||||
killRecordedPids(file);assert.deepEqual(await pollFixtureGone(files),[], 'explicit fixture cleanup of R3 residual');await stopGuard();
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
||||
const extract=s=>s.split('# row253-supervisor-begin\n')[1]?.split('# row253-supervisor-end')[0];
|
||||
const a=extract(read(UNIT)),b=extract(read(join(REPO,'scripts/case1-accept.sh')));
|
||||
assert.ok(a?.length>100);assert.equal(a,b);
|
||||
});
|
||||
|
||||
test('row253 query reaps its group even when a child closes stdout before the query returns',async t=>{
|
||||
const h=hub(t);writeFileSync(join(h.root,'query-orphan'),'');
|
||||
writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));
|
||||
let pid;
|
||||
t.after(()=>{if(pid)try{process.kill(pid,'SIGKILL');}catch{}});
|
||||
const r=unit(h,{profile:'factory'});
|
||||
const capture=cleanupFixture(t,[join(h.root,'query-owned')]);
|
||||
const r=unit(h,{profile:'factory'});capture();
|
||||
pid=Number(read(join(h.root,'query-pids')).trim());
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
let state='gone';
|
||||
@@ -232,8 +253,8 @@ test('row253 query reaps its group even when a child closes stdout before the qu
|
||||
assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`);
|
||||
});
|
||||
|
||||
test('row253 config parser is strict and output capture is bounded',()=>{
|
||||
const source=read(UNIT), body=source.slice(source.indexOf('tester_capture() ('),source.indexOf('tester_location() {'));
|
||||
test('row253 config parser is strict',()=>{
|
||||
const source=read(UNIT), body=source.slice(source.indexOf('tester_facts() {'),source.indexOf('tester_location() {'));
|
||||
const valid=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1" && printf "%s %s" "$TS_DATA" "$TS_MARKED"','--','{"dataRoot":"/srv/mosaic/data","socket":true}'],{encoding:'utf8'});
|
||||
assert.equal(valid.status,0);assert.equal(valid.stdout,'/srv/mosaic/data true');
|
||||
const facts=['{"dataRoot":"/srv/mosaic/data","socket":true,"extra":1}','{"dataRoot":4,"socket":true}','{"dataRoot":"/srv/mosaic/data","socket":"true"}','{"dataRoot":"/srv/mosaic/data","socket":true}\nP10 forged closed'];
|
||||
@@ -241,8 +262,6 @@ test('row253 config parser is strict and output capture is bounded',()=>{
|
||||
const r=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1"','--',fact]);
|
||||
assert.notEqual(r.status,0,fact);
|
||||
}
|
||||
const r=spawnSync('/bin/bash',['-c',body+'\ntester_capture 1 32 /usr/bin/head -c 100 /dev/zero']);
|
||||
assert.notEqual(r.status,0);assert.equal(r.stdout.length,0);
|
||||
});
|
||||
|
||||
function refuses(r, pattern) {
|
||||
|
||||
@@ -125,9 +125,9 @@ copies are retained. Existing Console-only copies are not migrated or pruned.
|
||||
With `--instance NAME` (row 190) every verb works on
|
||||
`mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install
|
||||
checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then
|
||||
need `--port`, and 7330, 7332 or 7333 as `--port` or `--tester-port` exits 4
|
||||
need `--port`, and 7330, 7332 or 7333 as the main or legacy tester port exits 4
|
||||
with port-reserved. For every instance, install refuses a `--port` or
|
||||
`--tester-port` that another installed Console unit in the unit directory
|
||||
legacy tester port that another installed Console unit in the unit directory
|
||||
already takes. That refusal is port-claimed, exit 1, and writes nothing. A
|
||||
unit with no `--port` holds 7332. See
|
||||
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
|
||||
@@ -141,7 +141,7 @@ and test fixtures; they stop before the live sequence.
|
||||
|
||||
## Sign-in in the Console (Q175 phase 1, #1690)
|
||||
|
||||
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
|
||||
`serve --sso [--send] [--tester-socket]` makes the Console an OpenID Connect
|
||||
relying party. It reads `console.sso` from the system config (`publicOrigin`
|
||||
and one or more providers, each with its issuer, client id, a client secret
|
||||
slot, the signing algorithms it accepts and the subjects allowed in). The
|
||||
@@ -173,7 +173,7 @@ takes `--sso` in place of the preview set.
|
||||
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
|
||||
ends the session and goes to the provider's end-session endpoint when it has
|
||||
one, or to `/auth/signed-out`.
|
||||
- With `--tester-port`, the tester listener refuses any request carrying an
|
||||
- With `--tester-socket`, the tester listener refuses any request carrying an
|
||||
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
|
||||
cookie, or an `x-authentik-*` header (403 `tester listener takes no
|
||||
sign-in`). It never serves `/auth/*`.
|
||||
|
||||
+257
-15
@@ -5,7 +5,7 @@
|
||||
# case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N
|
||||
#
|
||||
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
|
||||
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
|
||||
# user, home, copy, node, claude, main port and tester socket marker from the args file, which must pass stack-upgrade.sh's
|
||||
# rules and be a factory file for --business. Every command that runs the stack's code runs as
|
||||
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
|
||||
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
|
||||
@@ -13,7 +13,13 @@
|
||||
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
|
||||
# --node is the pinned path and it and every directory above it are root's with no group or other
|
||||
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
|
||||
# opens claude-session.env.
|
||||
# opens claude-session.env. A bounded config/target query imports the copy only as the stack
|
||||
# user, with sudo and an empty environment. Root parses its two facts in bash. P10 uses the
|
||||
# fixed system Python, also as the stack user, for a read-only SO_PEERCRED observation.
|
||||
# The config/probe supervisor is fixed root-validated system Bash plus setsid
|
||||
# and sleep, after the uid drop: query inner/outer 5/7 s, probe 2/4 s. Root
|
||||
# independently bounds its sudo wait. Legacy AS_USER talk and diagnostics below
|
||||
# retain their old behavior and are NOT covered by this supervisor (F5 follow-up).
|
||||
#
|
||||
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
|
||||
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
|
||||
@@ -57,12 +63,18 @@ st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
|
||||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
||||
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
|
||||
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_PORT=""
|
||||
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
|
||||
copies=0 past=""
|
||||
for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
|
||||
if [ -n "$past" ]; then
|
||||
case "$a" in --port) PORT="$v"; i=$((i + 1)) ;; --tester-port) TESTER_PORT="$v"; i=$((i + 1)) ;; esac
|
||||
case "$a" in
|
||||
--port) PORT="$v"; i=$((i + 1)) ;;
|
||||
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
|
||||
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
|
||||
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
|
||||
*) refuse "unknown serve flag $a" ;;
|
||||
esac
|
||||
continue
|
||||
fi
|
||||
case "$a" in
|
||||
@@ -84,8 +96,8 @@ done
|
||||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
|
||||
[ "$PROFILE" = factory ] || refuse "$ARGS_FILE has --profile '${PROFILE}', not factory"
|
||||
[ "$A_BUSINESS" = "$BUSINESS" ] || refuse "$ARGS_FILE is for business $A_BUSINESS, not $BUSINESS"
|
||||
[[ "$PORT" =~ ^[1-9][0-9]{0,4}$ ]] && [[ "$TESTER_PORT" =~ ^[1-9][0-9]{0,4}$ ]] ||
|
||||
refuse "$ARGS_FILE lacks a numeric --port or --tester-port after --"
|
||||
[[ "$PORT" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$PORT" -le 1023 ] || refuse "$ARGS_FILE lacks a privileged numeric --port after --"
|
||||
[ "$TESTER_SOCKET" = 1 ] || refuse "$ARGS_FILE needs one bare --tester-socket after --"
|
||||
|
||||
COPY="$S_HOME/.local/share/mosaic-stack/copies/$COPY_ID"
|
||||
DATA_ROOT="$S_HOME/.mosaic-dev"
|
||||
@@ -99,9 +111,185 @@ AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/b
|
||||
WORK="$(mktemp -d)" || refuse "cannot make a temp dir"
|
||||
trap 'rm -rf -- "$WORK"' EXIT
|
||||
|
||||
# Row253: bounded unprivileged metadata, parsed by root without running copy code.
|
||||
# row253-supervisor-begin
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw rest
|
||||
if ! raw="$(<"/proc/$pid/stat")" 2>/dev/null; then
|
||||
[ -e "/proc/$pid/stat" ] && return 1
|
||||
return 3
|
||||
fi
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until
|
||||
clock_cs || return 1
|
||||
until=$((CLOCK_CS + 25))
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
trap - EXIT HUP INT TERM
|
||||
if [ "$verified" = 1 ]; then
|
||||
clean_session || rc=86
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
p="$target"
|
||||
while :; do
|
||||
st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" || return 1
|
||||
[ "${st% *}" = 0 ] && [[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#022) == 0 )) || return 1
|
||||
[ "$p" != / ] || break
|
||||
p="${p%/*}"; [ -n "$p" ] || p=/
|
||||
done
|
||||
magic="$(/usr/bin/od -An -tx1 -N4 -- "$target" 2>/dev/null | tr -d ' \n')"
|
||||
[ "$magic" = 7f454c46 ] || return 1
|
||||
case "$input" in
|
||||
/bin/bash) TS_BASH="$target" ;; /usr/bin/setsid) TS_SETSID="$target" ;;
|
||||
/usr/bin/sleep) TS_SLEEP="$target" ;; /usr/bin/timeout) TS_TIMEOUT="$target" ;;
|
||||
/usr/bin/head) TS_HEAD="$target" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
tester_capture_reason() {
|
||||
case "$1" in
|
||||
81) echo inner-timeout ;; 82) echo outer-timeout ;; 83) echo output-limit ;;
|
||||
84) echo supervisor-tools ;; 85) echo inner-session ;; 86) echo inner-cleanup ;;
|
||||
*) echo inner-command ;;
|
||||
esac
|
||||
}
|
||||
tester_capture() (
|
||||
local inner="$1" outer="$2" maximum="$3" user="$4" home="$5" path="$6" file notice child="" rc=0; shift 6
|
||||
tester_tools || exit 84
|
||||
file="$(mktemp)" || exit 88
|
||||
notice="$(mktemp)" || { rm -f -- "$file"; exit 88; }
|
||||
trap 'if [ -n "$child" ]; then kill -KILL -- "-$child" 2>/dev/null || :; wait "$child" 2>/dev/null || :; fi; rm -f -- "$file" "$notice"' EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
LC_ALL=C "$TS_TIMEOUT" --verbose --signal=KILL "${outer}s" "$TS_BASH" -c '
|
||||
set -o pipefail
|
||||
maximum="$1"; head_bin="$2"; shift 2
|
||||
"$@" 2>/dev/null | "$head_bin" -c "$((maximum + 1))"
|
||||
' -- "$maximum" "$TS_HEAD" sudo -u "$user" -H env -i "HOME=$home" "PATH=$path" "$TS_BASH" -c "$TESTER_SUPERVISOR" -- "$inner" "$TS_SETSID" "$TS_SLEEP" "$@" > "$file" 2> "$notice" &
|
||||
child=$!
|
||||
wait "$child" 2>/dev/null || rc=$?
|
||||
kill -KILL -- "-$child" 2>/dev/null || :
|
||||
# Only GNU timeout and the trusted head can write this root-side channel.
|
||||
if [ "$rc" = 137 ] && [[ "$(<"$notice")" == *': sending signal KILL to command '* ]]; then exit 82; fi
|
||||
[ "$(wc -c < "$file")" -le "$maximum" ] || exit 83
|
||||
[ "$rc" = 0 ] || { case "$rc" in 81|85|86|87|88) exit "$rc" ;; *) exit 87 ;; esac; }
|
||||
cat -- "$file"
|
||||
)
|
||||
|
||||
TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));'
|
||||
tester_facts() {
|
||||
local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$'
|
||||
[[ "$1" =~ $pattern ]] || return 1
|
||||
TS_DATA="${BASH_REMATCH[1]}" TS_MARKED="${BASH_REMATCH[2]}"
|
||||
}
|
||||
tester_location() {
|
||||
local LC_ALL=C data="$1" home="$2" uid="$3" prefix="$4" part at="" st
|
||||
TS_REASON="" TS_SOCKET=""
|
||||
case "$data" in /*) ;; *) TS_REASON=not-absolute; return 1 ;; esac
|
||||
case "$data" in *[!A-Za-z0-9/._@+-]*) TS_REASON=not-ascii; return 1 ;; esac
|
||||
case "$data/" in */./*|*/../*|*//* ) TS_REASON=not-normalized; return 1 ;; esac
|
||||
[ "${data%/}" = "$data" ] || { TS_REASON=not-normalized; return 1; }
|
||||
case "$data" in "$home"/*) ;; *) TS_REASON=outside-home; return 1 ;; esac
|
||||
case "$home/" in /home/*|/root/*|/run/user/*|/tmp/*|/var/tmp/*) TS_REASON=protected-private-home; return 1 ;; esac
|
||||
TS_SOCKET="$data/console/tester.sock"
|
||||
[ "${#TS_SOCKET}" -le 107 ] || { TS_REASON=too-long; return 1; }
|
||||
local -a parts
|
||||
IFS=/ read -r -a parts <<< "$data"
|
||||
for part in "${parts[@]}"; do
|
||||
[ -n "$part" ] || continue
|
||||
at="$at/$part"
|
||||
[ ! -L "$prefix$at" ] || { TS_REASON=symlink-component; return 1; }
|
||||
if [ -e "$prefix$at" ]; then
|
||||
[ -d "$prefix$at" ] || { TS_REASON=component-type; return 1; }
|
||||
fi
|
||||
done
|
||||
[ "${5:-}" != observe ] || return 0
|
||||
local parent="$prefix$data/console"
|
||||
[ ! -L "$parent" ] || { TS_REASON=symlink-parent; return 1; }
|
||||
if [ -e "$parent" ]; then
|
||||
[ -d "$parent" ] || { TS_REASON=parent-type; return 1; }
|
||||
st="$(stat -c '%u %a' -- "$parent")" || { TS_REASON=parent-stat; return 1; }
|
||||
[ "${st% *}" = "$uid" ] || { TS_REASON=parent-owner; return 1; }
|
||||
[[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#077) == 0 )) || { TS_REASON=parent-mode; return 1; }
|
||||
fi
|
||||
}
|
||||
# tester-location-end
|
||||
|
||||
|
||||
# --- Output ---
|
||||
|
||||
MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" "accepted mos-1101" unnamed)
|
||||
MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" unnamed)
|
||||
declare -A COUNT=()
|
||||
F_FAILED=0
|
||||
# Text the stack's user wrote, made safe to print: printable ASCII, one line, at most 200 bytes.
|
||||
@@ -130,6 +318,13 @@ groups_of() {
|
||||
}
|
||||
names_of_groups() { local g; for g in "$@"; do db group | awk -F: -v g="$g" '$3 == g { print $1 }'; done; }
|
||||
MOSAIC_UID="$(uid_of "$S_USER")"
|
||||
[[ "$MOSAIC_UID" =~ ^[1-9][0-9]*$ ]] || refuse "stack account has no non-root uid"
|
||||
[ "$(db passwd "$S_USER" | cut -d: -f6)" = "$S_HOME" ] || refuse "saved home differs from account home"
|
||||
QUERY="$(tester_capture 5 7 4096 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || refuse "tester config query $(tester_capture_reason "$?")"
|
||||
tester_facts "$QUERY" || refuse "tester config query has invalid facts"
|
||||
[ "$TS_MARKED" = true ] || refuse "target does not support --tester-socket"
|
||||
tester_location "$TS_DATA" "$S_HOME" "$MOSAIC_UID" "$ROOT" observe || refuse "tester path refuses $TS_REASON"
|
||||
TESTER_PATH="$TS_SOCKET"
|
||||
|
||||
# --- systemd, /proc and listeners ---
|
||||
|
||||
@@ -397,11 +592,11 @@ check_p7() {
|
||||
|
||||
check_p8() {
|
||||
local other
|
||||
other="$(listeners 7331)"
|
||||
if loopback_by "$PORT" "$CON_PID" && loopback_by "$TESTER_PORT" "$CON_PID" && [ -z "$other" ]; then
|
||||
line P8 lan-listeners closed "$PORT and $TESTER_PORT are on 127.0.0.1 only, by MainPID $CON_PID; nothing on 7331"
|
||||
other="$(listeners 7331)$(listeners 7333)"
|
||||
if loopback_by "$PORT" "$CON_PID" && [ -z "$other" ]; then
|
||||
line P8 lan-listeners closed "$PORT is on 127.0.0.1 only by MainPID $CON_PID; no TCP listener on 7331 or 7333; tester uses $TESTER_PATH"
|
||||
else
|
||||
line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); $TESTER_PORT: $(listener_text "$TESTER_PORT"); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}"
|
||||
line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); 7333: $(listener_text 7333); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -423,13 +618,60 @@ check_p9() {
|
||||
fi
|
||||
}
|
||||
|
||||
# P10 is an observation of mutable same-uid state, never a closure ruling.
|
||||
TESTER_PEER='import socket,struct,sys
|
||||
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
try:
|
||||
s.settimeout(1)
|
||||
s.connect(sys.argv[1])
|
||||
p=struct.unpack("3i",s.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
||||
print(*p)
|
||||
finally:
|
||||
s.close()'
|
||||
tester_snapshot() {
|
||||
local parent="${TESTER_PATH%/*}" p s pid
|
||||
[ ! -L "$ROOT$parent" ] && [ ! -L "$ROOT$TESTER_PATH" ] || return 1
|
||||
p="$(stat -c '%u %a %F %d %i' -- "$ROOT$parent" 2>/dev/null)" || return 1
|
||||
s="$(stat -c '%u %a %F %d %i' -- "$ROOT$TESTER_PATH" 2>/dev/null)" || return 1
|
||||
[[ "$p" =~ ^$MOSAIC_UID\ 700\ directory\ [0-9]+\ [0-9]+$ ]] || return 1
|
||||
[[ "$s" =~ ^$MOSAIC_UID\ 600\ socket\ [0-9]+\ [0-9]+$ ]] || return 1
|
||||
pid="$(prop "$(unit_show "$CON_UNIT" MainPID)" MainPID)"
|
||||
[[ "$pid" =~ ^[1-9][0-9]*$ ]] && [ "$pid" = "$CON_PID" ] || return 1
|
||||
[ "$(proc_uids "$pid")" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] || return 1
|
||||
printf '%s|%s|%s' "$pid" "$p" "$s"
|
||||
}
|
||||
tester_python() {
|
||||
local real bad magic
|
||||
# Resolve the system entry, then validate the target and every ancestor.
|
||||
[ -f "$ROOT/usr/bin/python3" ] && [ -x "$ROOT/usr/bin/python3" ] || return 1
|
||||
real="$(realpath -e -- "$ROOT/usr/bin/python3" 2>/dev/null)" || return 1
|
||||
[ -z "$ROOT" ] || real="${real#"$ROOT"}"
|
||||
case "$real" in /home/*|/root/*|/run/user/*|"$S_HOME"/*) return 1 ;; esac
|
||||
bad="$(not_root_only "$real")"; [ -z "$bad" ] || return 1
|
||||
bad="$(not_root_only /usr/bin)"; [ -z "$bad" ] || return 1
|
||||
magic="$(od -An -tx1 -N4 -- "$ROOT$real" 2>/dev/null | tr -d ' \n')"
|
||||
[ "$magic" = 7f454c46 ] || return 1
|
||||
printf '%s' "$real"
|
||||
}
|
||||
check_p10() {
|
||||
local floor
|
||||
local floor before after python peer pid uid gid why="" pattern='^([1-9][0-9]{0,9}) ([0-9]{1,10}) ([0-9]{1,10})$'
|
||||
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
|
||||
if [[ "$floor" =~ ^[0-9]+$ ]] && [ "$floor" -gt "$PORT" ] && loopback_by "$TESTER_PORT" "$CON_PID"; then
|
||||
line P10 tester-port-squat "accepted mos-1101" "the port floor $floor is above $PORT, and MainPID $CON_PID holds $TESTER_PORT"
|
||||
[[ "$floor" =~ ^[0-9]{1,5}$ ]] && [ "$floor" -gt "$PORT" ] || why=floor
|
||||
before="$(tester_snapshot)" || why=metadata
|
||||
python="$(tester_python)" || why=interpreter
|
||||
if [ -z "$why" ]; then
|
||||
peer="$(tester_capture 2 4 128 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$python" -I -S -c "$TESTER_PEER" "$TESTER_PATH")" || why="probe-$(tester_capture_reason "$?")"
|
||||
if [[ "$peer" =~ $pattern ]]; then
|
||||
pid="${BASH_REMATCH[1]}" uid="${BASH_REMATCH[2]}" gid="${BASH_REMATCH[3]}"
|
||||
[ "$pid" = "$CON_PID" ] && [ "$uid" = "$MOSAIC_UID" ] && [ "$gid" -le 2147483647 ] || why=peer
|
||||
else [ -n "$why" ] || why=probe; fi
|
||||
after="$(tester_snapshot)" || why=metadata
|
||||
[ "$before" = "$after" ] || why=changed
|
||||
fi
|
||||
if [ -z "$why" ]; then
|
||||
line P10 tester-socket-squat unnamed "boundary=observed path=$TESTER_PATH parent=0700 owner=$MOSAIC_UID socket=0600 peer_pid=$pid peer_uid=$uid floor=$floor port=$PORT; hostile same-uid substitution remains"
|
||||
else
|
||||
line P10 tester-port-squat unnamed "the port floor is '${floor:-unreadable}' against $PORT; $TESTER_PORT: $(listener_text "$TESTER_PORT"); Console MainPID ${CON_PID:-none}"
|
||||
line P10 tester-socket-squat unnamed "boundary=failed reason=$why path=$TESTER_PATH MainPID=${CON_PID:-none}; hostile same-uid substitution remains"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+197
-12
@@ -13,16 +13,20 @@
|
||||
# writes both units under /etc/systemd/system as root:root 0644, runs
|
||||
# `systemctl daemon-reload`, enables both, then starts the bus and then the
|
||||
# Console (Q189). Both come back after a boot on their own. It never links
|
||||
# a unit file into place, and it opens no file under <home>: it only stats the
|
||||
# copy, its scripts/mosaic and the proof file. It renders the default
|
||||
# instance only.
|
||||
# a unit file into place. Root only stats files under <home> and reads bounded
|
||||
# stdout from a config/target query run as the stack user through sudo. Root
|
||||
# never imports copy code. This adds sudo and GNU timeout to the installer
|
||||
# dependencies. The inner query supervisor runs after the uid drop: 5 s inside,
|
||||
# 7 s on root's wait. Fixed system Bash, setsid, sleep, timeout and head are
|
||||
# resolved and root-validated. Only the config query uses this supervisor.
|
||||
# It renders the default instance only.
|
||||
#
|
||||
# The library profile takes exactly these serve flags, each once:
|
||||
# --port N (1..1023) --business ID (the unit's) --public-host NAME
|
||||
# --auth-header HEADER --allow-user ID --send
|
||||
# --proof-header NAME --proof-file PATH
|
||||
# The factory profile takes --port N (1..1023) and --business ID, and an
|
||||
# optional --tester-port N (1024..65535, never --port). Only factory takes
|
||||
# optional bare --tester-socket marker. Retired tester TCP refuses. Only factory takes
|
||||
# --pm and --claude.
|
||||
#
|
||||
# On a re-install the installed units must equal the render in everything
|
||||
@@ -149,18 +153,193 @@ if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
|
||||
root_only "$WEBUI_TEMPLATE" 'Console template'
|
||||
fi
|
||||
|
||||
# Row253: bounded unprivileged metadata, parsed by root without running copy code.
|
||||
# row253-supervisor-begin
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw rest
|
||||
if ! raw="$(<"/proc/$pid/stat")" 2>/dev/null; then
|
||||
[ -e "/proc/$pid/stat" ] && return 1
|
||||
return 3
|
||||
fi
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until
|
||||
clock_cs || return 1
|
||||
until=$((CLOCK_CS + 25))
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
trap - EXIT HUP INT TERM
|
||||
if [ "$verified" = 1 ]; then
|
||||
clean_session || rc=86
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
p="$target"
|
||||
while :; do
|
||||
st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" || return 1
|
||||
[ "${st% *}" = 0 ] && [[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#022) == 0 )) || return 1
|
||||
[ "$p" != / ] || break
|
||||
p="${p%/*}"; [ -n "$p" ] || p=/
|
||||
done
|
||||
magic="$(/usr/bin/od -An -tx1 -N4 -- "$target" 2>/dev/null | tr -d ' \n')"
|
||||
[ "$magic" = 7f454c46 ] || return 1
|
||||
case "$input" in
|
||||
/bin/bash) TS_BASH="$target" ;; /usr/bin/setsid) TS_SETSID="$target" ;;
|
||||
/usr/bin/sleep) TS_SLEEP="$target" ;; /usr/bin/timeout) TS_TIMEOUT="$target" ;;
|
||||
/usr/bin/head) TS_HEAD="$target" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
tester_capture_reason() {
|
||||
case "$1" in
|
||||
81) echo inner-timeout ;; 82) echo outer-timeout ;; 83) echo output-limit ;;
|
||||
84) echo supervisor-tools ;; 85) echo inner-session ;; 86) echo inner-cleanup ;;
|
||||
*) echo inner-command ;;
|
||||
esac
|
||||
}
|
||||
tester_capture() (
|
||||
local inner="$1" outer="$2" maximum="$3" user="$4" home="$5" path="$6" file notice child="" rc=0; shift 6
|
||||
tester_tools || exit 84
|
||||
file="$(mktemp)" || exit 88
|
||||
notice="$(mktemp)" || { rm -f -- "$file"; exit 88; }
|
||||
trap 'if [ -n "$child" ]; then kill -KILL -- "-$child" 2>/dev/null || :; wait "$child" 2>/dev/null || :; fi; rm -f -- "$file" "$notice"' EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
LC_ALL=C "$TS_TIMEOUT" --verbose --signal=KILL "${outer}s" "$TS_BASH" -c '
|
||||
set -o pipefail
|
||||
maximum="$1"; head_bin="$2"; shift 2
|
||||
"$@" 2>/dev/null | "$head_bin" -c "$((maximum + 1))"
|
||||
' -- "$maximum" "$TS_HEAD" sudo -u "$user" -H env -i "HOME=$home" "PATH=$path" "$TS_BASH" -c "$TESTER_SUPERVISOR" -- "$inner" "$TS_SETSID" "$TS_SLEEP" "$@" > "$file" 2> "$notice" &
|
||||
child=$!
|
||||
wait "$child" 2>/dev/null || rc=$?
|
||||
kill -KILL -- "-$child" 2>/dev/null || :
|
||||
# Only GNU timeout and the trusted head can write this root-side channel.
|
||||
if [ "$rc" = 137 ] && [[ "$(<"$notice")" == *': sending signal KILL to command '* ]]; then exit 82; fi
|
||||
[ "$(wc -c < "$file")" -le "$maximum" ] || exit 83
|
||||
[ "$rc" = 0 ] || { case "$rc" in 81|85|86|87|88) exit "$rc" ;; *) exit 87 ;; esac; }
|
||||
cat -- "$file"
|
||||
)
|
||||
|
||||
TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));'
|
||||
tester_facts() {
|
||||
local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$'
|
||||
[[ "$1" =~ $pattern ]] || return 1
|
||||
TS_DATA="${BASH_REMATCH[1]}" TS_MARKED="${BASH_REMATCH[2]}"
|
||||
}
|
||||
tester_location() {
|
||||
local LC_ALL=C data="$1" home="$2" uid="$3" prefix="$4" part at="" st
|
||||
TS_REASON="" TS_SOCKET=""
|
||||
case "$data" in /*) ;; *) TS_REASON=not-absolute; return 1 ;; esac
|
||||
case "$data" in *[!A-Za-z0-9/._@+-]*) TS_REASON=not-ascii; return 1 ;; esac
|
||||
case "$data/" in */./*|*/../*|*//* ) TS_REASON=not-normalized; return 1 ;; esac
|
||||
[ "${data%/}" = "$data" ] || { TS_REASON=not-normalized; return 1; }
|
||||
case "$data" in "$home"/*) ;; *) TS_REASON=outside-home; return 1 ;; esac
|
||||
case "$home/" in /home/*|/root/*|/run/user/*|/tmp/*|/var/tmp/*) TS_REASON=protected-private-home; return 1 ;; esac
|
||||
TS_SOCKET="$data/console/tester.sock"
|
||||
[ "${#TS_SOCKET}" -le 107 ] || { TS_REASON=too-long; return 1; }
|
||||
local -a parts
|
||||
IFS=/ read -r -a parts <<< "$data"
|
||||
for part in "${parts[@]}"; do
|
||||
[ -n "$part" ] || continue
|
||||
at="$at/$part"
|
||||
[ ! -L "$prefix$at" ] || { TS_REASON=symlink-component; return 1; }
|
||||
if [ -e "$prefix$at" ]; then
|
||||
[ -d "$prefix$at" ] || { TS_REASON=component-type; return 1; }
|
||||
fi
|
||||
done
|
||||
local parent="$prefix$data/console"
|
||||
[ ! -L "$parent" ] || { TS_REASON=symlink-parent; return 1; }
|
||||
if [ -e "$parent" ]; then
|
||||
[ -d "$parent" ] || { TS_REASON=parent-type; return 1; }
|
||||
st="$(stat -c '%u %a' -- "$parent")" || { TS_REASON=parent-stat; return 1; }
|
||||
[ "${st% *}" = "$uid" ] || { TS_REASON=parent-owner; return 1; }
|
||||
[[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#077) == 0 )) || { TS_REASON=parent-mode; return 1; }
|
||||
fi
|
||||
}
|
||||
# tester-location-end
|
||||
|
||||
# The serve flags, checked as scripts/webui-service.sh serve_flags checks
|
||||
# them (:201-264), and kept in the order given. That script compares the
|
||||
# proof file's owner to `id -u`, which is root here, so this copy has its own.
|
||||
serve_flags() {
|
||||
local -A seen=()
|
||||
local flags='' name value
|
||||
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_port=''
|
||||
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0
|
||||
local i=0 n=${#SERVE[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
name="${SERVE[$i]}"
|
||||
case "$name" in
|
||||
--tester-port) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-port: the tester listener takes writes from loopback with no sign-in" ;;
|
||||
--tester-port) die "case2-unit.sh retires tester TCP; use --tester-socket" ;;
|
||||
--tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;;
|
||||
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
|
||||
--board) die "refusing --board: Case 2 runs no control board" ;;
|
||||
--sso) die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
@@ -171,6 +350,7 @@ serve_flags() {
|
||||
esac
|
||||
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
|
||||
seen[$name]=1
|
||||
if [ "$name" = --tester-socket ]; then tester_socket=1; flags="$flags --tester-socket"; i=$((i + 1)); continue; fi
|
||||
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
|
||||
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
|
||||
value="${SERVE[$((i + 1))]}"
|
||||
@@ -190,8 +370,6 @@ serve_flags() {
|
||||
--proof-header) proof_header="$value"
|
||||
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header" ;;
|
||||
--proof-file) proof_file="$value" ;;
|
||||
--tester-port) tester_port="$value"
|
||||
[[ "$value" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$value" -ge 1024 ] && [ "$value" -le 65535 ] || die "--tester-port must be 1024..65535: $value" ;;
|
||||
esac
|
||||
flags="$flags $name $value"
|
||||
i=$((i + 2))
|
||||
@@ -201,8 +379,16 @@ serve_flags() {
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
|
||||
done
|
||||
[ -z "$tester_port" ] || [ "$tester_port" -ne "$port" ] || die "--tester-port must differ from --port"
|
||||
if [ "$PROFILE" = factory ]; then echo "${flags# }"; return 0; fi
|
||||
if [ "$PROFILE" = factory ]; then
|
||||
if [ "$tester_socket" = 1 ]; then
|
||||
local query
|
||||
query="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "tester config query $(tester_capture_reason "$?")"
|
||||
tester_facts "$query" || die "tester config query has invalid facts"
|
||||
[ "$TS_MARKED" = true ] || die "the target does not support --tester-socket"
|
||||
tester_location "$TS_DATA" "$HOME_DIR" "$USER_UID" "" || die "tester path refuses $TS_REASON"
|
||||
fi
|
||||
echo "${flags# }"; return 0
|
||||
fi
|
||||
# Lead decision 101: the uid header carries the Authentik uid.
|
||||
if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
|
||||
@@ -222,14 +408,13 @@ serve_flags() {
|
||||
[ "$mode" = 600 ] || [ "$mode" = 400 ] || die "proxy proof file must be mode 0600 or 0400, not $mode: $proof_file"
|
||||
echo "${flags# }"
|
||||
}
|
||||
FLAGS="$(serve_flags)" || exit 1
|
||||
|
||||
# PATH: the node's directory first, then the default list without it. Unlike
|
||||
# scripts/webui-service.sh:303-305 this always puts node's directory first,
|
||||
# so the bus's `exec node` (scripts/mosaic) finds the same node.
|
||||
NODE_DIR="$(dirname -- "$NODE")"
|
||||
UNIT_PATH="$NODE_DIR"
|
||||
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
|
||||
FLAGS="$(serve_flags)" || exit 1
|
||||
|
||||
HARDENING="ProtectSystem=strict
|
||||
ReadWritePaths=$HOME_DIR
|
||||
|
||||
Reference in New Issue
Block a user