fix(row253): bound cleanup retries after initial inventory
This commit is contained in:
1 parent
aa81ca390b
commit
761893dac7
20 files changed
+5008
-39
No files matched your search
@@ -183,3 +183,13 @@ F5 now names row 276 / #1781 in docs/plans/DEFERRED.md (sage-2331). That path wi
|
||||
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.
|
||||
|
||||
## duckworth-0112 delta, no runs
|
||||
|
||||
B1: both supervisor copies use fork-free IFS= read -r -d '' with content-based EOF/error handling. The 250 ms confirmation budget starts after the first full inventory pass. A deterministic stand-in clock test pins this placement without wall timing or real signals; M40 moves the budget back before pass one. All 40 proposed targets statically occur once. No test or syntax execution ran.
|
||||
|
||||
M1: BUILD-LOG.md is removed from MANIFEST.sha256. Its row253 phase lines land as a separate doc-only append after code, preserving newer upstream lines, under sage-2342. The manifest now has seven entries. The initial eight-entry manifest is superseded.
|
||||
|
||||
N4: SID confirmation now waits within the existing inner watchdog budget rather than only 100 sleeps. It still refuses a vanished/unreadable child or unconfirmed session, and the independent outer bound remains. N1/N2 are explicitly deferred in the existing F5 entry to row276 / #1781, as Sage rules. No case1 analogue checks were added in this delta.
|
||||
|
||||
The required gate base is the refactor tip Sage names at CLEAR, 378a76b85 or later. This delta is prepared on the current seat branch for static review only; no old-base gate is authorized. The final integrated gate candidate will get fresh hashes and integration records before execution. c4/delta-0112/ freezes the seven source/test/doc paths, the delta from aa81ca390, and static uniqueness. Prior frozen packets remain unchanged.
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"scripts/case2-unit.sh": "3ec41265b47770f70d60640a786b4f547ffb0157512fd8477ea7712e91a724fe",
|
||||
"scripts/case1-accept.sh": "f93eac4cc9dd9fc95a67be19d9fa7c7f6e7d7bfc84e662360b4aed05763850c7",
|
||||
"scripts/case2-unit.sh": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"scripts/case1-accept.sh": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"packages/cli/tests/case-sudo-fixture.mjs": "b259b8c7926e49829c8a956674021cb0cc85ddd65aebdc786d05cc08d7790008",
|
||||
"packages/cli/tests/case2-unit.test.mjs": "72a07e9ad9d5c8bb01851eef18f792f2fe9b95d56803e313fad6aa1f1d6c3e8c",
|
||||
"packages/cli/tests/case2-unit.test.mjs": "1e0696eb196e83a0766b433ef71f27df0a3f8fcb6399787ab8c1c0f05c54dc44",
|
||||
"packages/cli/tests/case1-accept.test.mjs": "6cfd2963303315aa6911c4d4f6d75d3ab87d9fcba8e128b056216e7f85fd8901",
|
||||
"packages/webui/README.md": "2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9",
|
||||
"docs/plans/DEFERRED.md": "67bbf7bf57aa70dcade36ca4642f01cccb71ce01c130a88b84e1edb7ea674377"
|
||||
"docs/plans/DEFERRED.md": "3ea465cf7ed664ceb1ea4076b829f706f8480f80c4a8aaa6a7487e4eceaf318f"
|
||||
}
|
||||
@@ -1,7 +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.
|
||||
MANIFEST.sha256 names seven source/test/doc landing paths and pins their bytes. Its digest is the review identifier. BUILD-LOG.md is excluded: land the row253 phase lines as a doc-only append after the code, preserving upstream appends (duckworth-0112 M1, sage-2342).
|
||||
|
||||
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.
|
||||
Base before the required gate integration: c3d88840c. Initial static repair packet: b64aa316a; initial runnable candidate: aa81ca390. The delta packet is c4/delta-0112/. Full records live under agents/gander/work/queue-253/ as review evidence. DEFERRED.md preserves upstream entries and names row276 / #1781 for F5 and N1/N2.
|
||||
|
||||
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.
|
||||
Sage-2335 authorizes the runnable commit before the whole gate. No repaired-source syntax check, test, probe or host experiment has run. No results from 8a8df4171 carry. After Duckworth's delta static verdict, the gate must use the candidate integrated onto the refactor tip named by Sage at CLEAR (378a76b85 or later under sage-2342), never this old base. Fresh integration records and hashes will identify that final gate candidate.
|
||||
@@ -1,8 +1,7 @@
|
||||
43d3f1664f195839ff303dbb0bd0f040c177ac1923d0760a23c09cc7130ec1b5 BUILD-LOG.md
|
||||
67bbf7bf57aa70dcade36ca4642f01cccb71ce01c130a88b84e1edb7ea674377 docs/plans/DEFERRED.md
|
||||
3ea465cf7ed664ceb1ea4076b829f706f8480f80c4a8aaa6a7487e4eceaf318f 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
|
||||
1e0696eb196e83a0766b433ef71f27df0a3f8fcb6399787ab8c1c0f05c54dc44 packages/cli/tests/case2-unit.test.mjs
|
||||
2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9 packages/webui/README.md
|
||||
f93eac4cc9dd9fc95a67be19d9fa7c7f6e7d7bfc84e662360b4aed05763850c7 scripts/case1-accept.sh
|
||||
3ec41265b47770f70d60640a786b4f547ffb0157512fd8477ea7712e91a724fe scripts/case2-unit.sh
|
||||
c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd scripts/case1-accept.sh
|
||||
d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b scripts/case2-unit.sh
|
||||
@@ -0,0 +1,463 @@
|
||||
# Deferred list
|
||||
|
||||
One line per item, written from evidence, never from inertia. Add a line
|
||||
when a gap is found and not fixed in the current piece; strike it (move to
|
||||
Done at the bottom with the commit) when a piece closes it. Nothing here
|
||||
starts on its own; a line becomes a piece only when Jason calls it. Check it
|
||||
at every gate. Started 2026-09-12 during the control board MVP.
|
||||
|
||||
## Open
|
||||
|
||||
- **Inherited Discord engine test intermittency during #1512 review.** Frozen
|
||||
serialized run recorded a tool-turn engine assertion failure and then timed
|
||||
out at 120s. A later full TAP run with a 15s test deadline passed 351/351.
|
||||
Rocko isolated the busy/settled-event test race and missing failure cleanup;
|
||||
Darkwing independently reproduced the event-split assertion. Rocko also reported
|
||||
a timeout-log test race and a separate engine followUp-after-timeout defect.
|
||||
No live incident established. Row21 owner orch-01 notified; no Discord source
|
||||
takeover. Not assumed identical to earlier concurrent hangs.
|
||||
Integration held pending owner disposition; logs and scope in
|
||||
`agents/darkwing/work/relaunch-activity/r1-review.md`. (2026-09-15)
|
||||
Outcome 2026-09-26: 1685deb4 fixed the busy/settled race and the engine no
|
||||
longer sends a follow-up (held prompts). A re-run of the frozen candidate
|
||||
against 21e3e908 passed the serial acceptance command 397/397 three times.
|
||||
Two engine failures remain when tests run concurrently, and they reproduce on
|
||||
clean HEAD without the #1512 files, so they are #1509's (entry below). Sage
|
||||
ruled that #1512 integrates on the serial evidence; it landed in af4203ca.
|
||||
Record: `agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md`.
|
||||
|
||||
|
||||
|
||||
- **Registration has no "set by" field.** `mosaic seat task` records the
|
||||
task and `updatedAt` but not who set it, so a human session and the
|
||||
orchestrator look the same on the board. One-line record change; rides
|
||||
with the orchestrator assignment piece. (2026-09-12, #1504)
|
||||
- **Usage-limit notice was missed.** Jason reports Rocko exhausted usage without
|
||||
Jason seeing the notice, and is now available again. Investigate where the
|
||||
notice appeared and what operator notification is missing as part of Claude
|
||||
console integration. User-reported, not independently reproduced; no root
|
||||
cause established. Keep outside #1512's relaunch-display scope. (2026-09-15)
|
||||
- **Claude Code seats register but get no board row.** Rocko has no Pi
|
||||
session log, and rows come only from Pi logs. Needs a second row source
|
||||
or a Claude-side log reader. (2026-09-12, darkwing's #1504 report)
|
||||
2026-09-15 #1511 live-test preflight reconfirmed no Rocko row: expected
|
||||
`.pi/state/rocko/sessions` discovery directory absent. Its existing repo
|
||||
registration names dead PID 185602, while the verified native lock owner is
|
||||
3707667. No registration repair, discovery expansion or relaunch performed;
|
||||
test write refused before mutation. Evidence:
|
||||
`agents/darkwing/work/task-attribution/backend-live-verification.json`.
|
||||
- **Fleet seats (`~/.mosaic`) stay on the old launchers.** No registration,
|
||||
no board reply, no `seat task`. Moving them is the stress test and comes
|
||||
after the WebUI and fleet launch work. (Jason, 2026-09-12)
|
||||
- **Comms skill has no rule for senders without a pane.** The board trailer
|
||||
covers it today; if another pane-less sender appears, put the rule in
|
||||
`skills/ms-communications` instead of a second trailer. (2026-09-12, #1505)
|
||||
- **Board: multiple sessions per seat, stopping seats, auth beyond
|
||||
loopback.** Out of scope for the MVP by the plan; listed so they are not
|
||||
forgotten. (plan page, Boundaries)
|
||||
- **`MOSAIC-STACK-D-001` is Answered, not Completed.** Mark `--completed` in
|
||||
jarvis-brain once Jason says the board answers "who is waiting on me".
|
||||
(2026-09-12)
|
||||
- **Uncommitted working tree.** `AGENTS.md` autonomy section,
|
||||
`agents/README.md` Sage row, Dewey's `work/wui` docs and evidence,
|
||||
`docs/plans/reviews/2026-09-08..10_*`, `skills/ms-{arena,blast-radius,
|
||||
board,executive-update,git-operations,technical-writing}`. Jason's edits
|
||||
from before 2026-09-12; they ride along in the next commit that touches
|
||||
them unless staged on purpose. (2026-09-12)
|
||||
- **Fleet debt (orch-01, `~/.mosaic`):** `sweep-commit.sh` deny-list regex
|
||||
skips `launch.env`; needs the fix plus a regression test. (2026-09-12)
|
||||
- **Parked, not cancelled:** Dewey D05 dashboard mockup work beyond design 3,
|
||||
registry increment 3 (headless identity-env leak), new seat directories.
|
||||
(plan page, 2026-09-12 parking note)
|
||||
|
||||
- **Gitea helper response-file hardening.** Predictable `/tmp/gitea-api-response.$$`
|
||||
has no explicit 0600 mode; use `mktemp` with cleanup in a separately reviewed
|
||||
pass. Credential validation and later reread also have a race window. Other
|
||||
non-blocking ledger review notes remain in
|
||||
`reviews/2026-09-12_ledger-verdict.md`. No change beyond the reviewed GET
|
||||
cleanup fix in this piece. Filbert, LEDGER-1506-R1, 2026-09-12, #1506.
|
||||
|
||||
- **One shared checkout for every seat and the coordinator.** Any `git add`
|
||||
of a shared file (CURRENT.md, SESSIONS.md, the plan page) commits another
|
||||
seat's in-progress edit under the wrong commit; it happened at f80404f5
|
||||
(dewey's CURRENT.md status note went in with the piece 5 brief, harmless
|
||||
but unowned). Fix candidates: per-seat worktrees, or shared files edited
|
||||
only by the seat named in CURRENT.md. (2026-09-12)
|
||||
|
||||
- **Console mockup fields absent from the board API.** Controller, generation,
|
||||
config fingerprint, turn count, goal/report and activity stream are omitted.
|
||||
Workspace paths are shown as board data, not invented registered workspace
|
||||
nodes. Launch, observe, stop, full transcript navigation, command palette and
|
||||
other screens are absent. Existing palette/appearance switches remain on the
|
||||
single page. Revisit only with a new brief. (2026-09-13 UTC, #1507)
|
||||
|
||||
- **Board send can turn into a Pi slash command.** `tools/tmux`
|
||||
`send-message.sh` pastes onto whatever is already in Pi's composer without
|
||||
clearing it. On Jason's 2026-09-26 20:10:57Z send, a leftover "/" landed in
|
||||
front of the agent-send header. Pi read it as plain text that time, but the
|
||||
same path could run a board reply as a slash command. Belongs to CHAT-03I/B3.
|
||||
No tools/tmux change until that piece. Found by Dewey in the CHAT-02 brief
|
||||
evidence. (2026-09-26, #1507)
|
||||
|
||||
- **Discord wedge restarts have no cumulative stop.** Since 6b R2, a wedged
|
||||
pi ends the connector with exit 1, and the unit restarts it. With
|
||||
turnTimeoutSeconds 600, one cycle takes at least 645 s, longer than the
|
||||
600 s StartLimit window. So a pi that wedges on every prompt restarts once
|
||||
per prompt and never hits the limit. It only happens when someone sends a
|
||||
message, and that person gets an error reply. Sage accepted it for now. The
|
||||
fix is a separate policy (a wedge count kept across restarts). (2026-09-26,
|
||||
#1509, Rocko 6b R2 review)
|
||||
- **Connector does not journal the source it started from.** The unit runs
|
||||
from the live checkout, so an automatic restart loads whatever Discord
|
||||
source is in the tree, even uncommitted source. Rocko recommends a startup
|
||||
journal line with HEAD, the dirty state of `packages/discord` and a digest
|
||||
of the runtime files. Until then the rule is that unreviewed Discord source
|
||||
stays uncommitted in the tree only as long as a review needs it.
|
||||
(2026-09-26, #1509)
|
||||
- **Index-export suite runs leave Docker networks behind.** Each export
|
||||
directory becomes a compose project, and its `<dir>_default` network
|
||||
stays after the run. On 2026-09-26 the host ran out of address pools. The
|
||||
task and release suites then failed 31 and 3 tests with "all predefined
|
||||
address pools have been fully subnetted". Sage removed its own five stale
|
||||
networks. The dw-* ones are still there. Fix: the export recipe, or the
|
||||
suites, removes the project network on exit. (2026-09-26)
|
||||
2026-09-27: it happened again on the Piece D candidate (task 31 and
|
||||
release 3 failures). Sage removed 12 leftover export networks from
|
||||
other seats, each checked for zero attached containers first, and both
|
||||
suites then passed. Still open.
|
||||
|
||||
- **Ledger T3 read: snapshot isolation has no test.** `readT3` runs every
|
||||
query in one read transaction, but no test proves a write landing during
|
||||
the read stays invisible. Filbert's note 4 on the Gate F build (e47ec6da).
|
||||
Notes 1 to 3 go to Darkwing's follow-up. (2026-09-26, #1506)
|
||||
- **A T3 sender role with spaces counts as human.** The SetSpark lead's
|
||||
reply to Sage (21:41:24Z) used the role "main content-engine agent". The
|
||||
6a rule requires a one-word role, so the ledger counts that message as
|
||||
human in Sage's row. Gate F reads only Filbert's row, so it is not
|
||||
affected. Filbert found it through the live diagnostic (14 went to 15).
|
||||
Sage asked the SetSpark lead to use a one-word role. The rule stays as
|
||||
it is. (2026-09-26, #1506)
|
||||
|
||||
- **Queue tests: the cold-start failure came back (reopened).** Row 33
|
||||
closed this as not reproduced in 20 idle cold runs. On 2026-10-04 Sage's
|
||||
index export of the row 33 candidate ran the nine suites, then
|
||||
`node --test packages/queue/tests/`, and got 147 passed, 1 failed. Sage's
|
||||
recipe threw the output away, so again nobody knows which test failed.
|
||||
Ten more runs all passed 148/148: three reruns in the same export, four
|
||||
under parallel suite load, and three fresh exports run straight after
|
||||
`test-queue.sh`. It needs a first run on a fresh tree after a full
|
||||
suite pass, and even then it's rare. Darkwing's n2 on row 33 (the cold
|
||||
runs were idle) was right. Next time: Sage's export recipe keeps the
|
||||
node test output in a file, so the first failure has a name. Then fix it
|
||||
as its own row. (2026-10-04, #1508)
|
||||
- **Queue: `add --issue N` also sets closes to N.** Follow-up rows under
|
||||
an umbrella issue, like rows 31 and 33 under #1508, would close the issue
|
||||
when they finish. Sage narrowed both by hand (revs 36 and 43). Either
|
||||
`add` takes `--closes` or the lead checks closes on every add. Not
|
||||
urgent while Sage adds the rows. (2026-10-04, #1508)
|
||||
|
||||
- **Ledger metric call can orphan curl.** The metric call in
|
||||
`packages/ledger/src/ledger.mjs` still relies on execFileSync's
|
||||
timeout, which kills the helper but can leave curl running. E's queue
|
||||
calls already use `timeout -s KILL`; the metric call should match.
|
||||
(2026-09-27, #1508)
|
||||
- **Ledger: a missing `timeout` command reads as a Gitea failure.** With
|
||||
no `timeout` on PATH the queue issue calls report "credential or Gitea
|
||||
request failure". Optional test and clearer message. Filbert, E r2.
|
||||
(2026-09-27, #1508)
|
||||
- **`review record` doesn't check which issue the verdict comment is on.**
|
||||
Row 5 round 1's request (26671) and Filbert's verdict (26683) are on
|
||||
#1507. Darkwing's verdict 26681 went to #1508, and `review record`
|
||||
accepted it, because only `review resolve` reads the comment back
|
||||
(`checkComment` in `packages/queue/src/review.mjs`). The receipt
|
||||
points at a real comment on the wrong issue. Sage posted a pointer on
|
||||
#1507 (comment 26685). Fix: have `record` check the comment's issue and author the way
|
||||
`resolve` does, or refuse a comment id it can't verify. Found by
|
||||
Filbert. (2026-10-04, #1508)
|
||||
- **Host seats launch Pi with `--approve`.** A future `.pi/SYSTEM.md` or
|
||||
`.pi/settings.json` in the checkout would load in every host seat. This
|
||||
is latent, since neither file exists today. Fix in
|
||||
`scripts/agent-host-dev.sh`: pass `--no-approve` and an explicit system
|
||||
prompt. Filbert, meta-harness survey section 8.4. (2026-10-04)
|
||||
- **Workers can read provider keys.** The worker container passes
|
||||
`ZAI_API_KEY` and `ANTHROPIC_API_KEY` in the environment, which Pi's
|
||||
`bash` inherits, and Pi's `read` can open the mounted `auth.json`.
|
||||
Slice 1 role tokens stay in the broker (lead decision 47). The workers'
|
||||
own exposure needs a separate piece. Filbert, survey section 8.6.
|
||||
(2026-10-04)
|
||||
- **CHAT-03 I1 round 2 follow-ups, for the I3 brief.** Neither reviewer
|
||||
held any of these as blocking (Darkwing comment 26690, Filbert comment
|
||||
26694 on #1507). Four of the five are closed by slice 1 S5 (see Done);
|
||||
this one stays open.
|
||||
- No test proves that the processes are listed only after the cgroup
|
||||
is frozen. The code waits for `frozen 1` in `cgroup.events`, and
|
||||
both Filbert and Dewey confirmed it by reading it. Mutants r2-B5b and
|
||||
C4 survive. Proposed: a test-only hold after `members`, or a FUSE or
|
||||
privileged fixture. Filbert F1.
|
||||
(2026-10-04, #1507)
|
||||
- **CHAT-03 `engine.envKeys` accepts founder credential names.**
|
||||
`CREDENTIAL_NAME` in `packages/conversation/src/pi-pin.mjs` matches any
|
||||
`*_API_KEY` or `*_TOKEN`, including `GITEA_TOKEN` and `GH_TOKEN`, which
|
||||
the S6 runner refuses as founder credentials (REQ-CRED-2). Nothing
|
||||
builds a Controller outside the tests yet. Whoever wires the entry point
|
||||
narrows the pattern to provider names or checks the founder list.
|
||||
Filbert N2 on #1522. (2026-10-10)
|
||||
- **`test-task.sh` makes a live Pi call without the Docker guard.** The
|
||||
"user recall" block near line 478 runs `run-task.sh` with no
|
||||
availability check, so a run with Docker unavailable fails "user recall
|
||||
run succeeds" and "recalled user name" (26 pass, 2 fail). The other
|
||||
live groups skip. Rocko hit it during the S2 suites. Wrap the block in
|
||||
the same guard as the other live groups, so a seat without provider
|
||||
access gets clean skips. (2026-10-04, #1519)
|
||||
- **`node --test <dir>/` fails on Node 24.** Node 24 reads a directory
|
||||
argument as a module, so every package's test command in that form
|
||||
fails there, while Node 26 accepts it. The glob form
|
||||
(`node --test 'packages/x/tests/*.test.mjs'`) works on both. Package
|
||||
READMEs, `docs/TOOLS.md` and Sage's suite recipe should use the glob
|
||||
form wherever Node 24 is a target. Darkwing, S1 packet. (2026-10-04,
|
||||
#1518)
|
||||
- **`stack-upgrade.sh` doesn't check `$REPO`.** Nothing confirms the
|
||||
source checkout is root-owned or that its tree is clean before the
|
||||
upgrade builds from it. Filbert N2 on row 233 (29922, #1735).
|
||||
(2026-10-21)
|
||||
- **`stack-upgrade.sh` has no signal handling between A2 and A5.** A HUP,
|
||||
INT or TERM in that stretch leaves the run failed closed, but recovery
|
||||
is by hand. A trap that writes the stop point and the recovery step to
|
||||
the log would make it a sheet step. Filbert N3 on row 233 (29922,
|
||||
#1735). (2026-10-21)
|
||||
- **`checkCreative` doesn't refuse `credentials`.** Row 213's
|
||||
`checkCreative` (packages/business/src/role.mjs:143 at 2d2ad2760)
|
||||
limits a creative role's tools, authority and messaging, but a role
|
||||
file that gave companion or research a credential would still load.
|
||||
The business file could then map a credential to that instance
|
||||
(business.mjs:157). Nobody has traced whether a mapped credential
|
||||
reaches the run's environment. The decision paths stay closed (M6),
|
||||
but a creative role with `web` holding a live token is a wider channel
|
||||
than design 4 means. The fix: refuse any `credentials` in
|
||||
`checkCreative`, with a role.test.mjs case. It goes after row 213
|
||||
lands, as a follow-up row or in the next row that edits role.mjs.
|
||||
Webby N1 on row 213 r3 (REVIEW-r3.md, 3ab3c010b), mos-1160.
|
||||
(2026-10-21)
|
||||
- **The boot host check orders only one business's bus.**
|
||||
`mosaic-hostcheck.service` (row 240) has
|
||||
`[email protected]`. `case2-unit.sh` enables the
|
||||
bus as `mosaic-bus@<business>.service`, so on a host whose business id
|
||||
isn't `mosaic-stack` nothing orders the two. The bus can start before
|
||||
the boot stop runs, and after a boot-time drift role sessions can use
|
||||
bwrap until it does. The fix: `After=mosaic-hostcheck.service` in
|
||||
`packages/cli/systemd/mosaic-bus.service.in`, which orders every
|
||||
instance and does nothing where the unit isn't installed. It goes in
|
||||
before the first install whose business id isn't `mosaic-stack`.
|
||||
Filbert N1 on row 240 (29945, #1746). (2026-10-21)
|
||||
- **The run sheet writes the apt hook before the first `--check`.**
|
||||
Section 4 installs `80mosaic-hostcheck`, then runs
|
||||
`hostcheck.sh --check`. If that check fails, the next package run
|
||||
closes bwrap before anyone has read the failure. It fails closed, but
|
||||
it surprises an installer. The fix: run `--check` first, or say what a
|
||||
failure at that step means. Doc-only, for the next row that edits
|
||||
`docs/guides/host-run-sheet.md`. Filbert N2 on row 240 (29945, #1746).
|
||||
Filbert's N3, that a closed bwrap appends to `DRIFT` on every later
|
||||
apt run and boot, is by design; ops-01's STATE read should expect the
|
||||
repeats. (2026-10-21)
|
||||
- **The sso-config first-step hint has no business.** When the target
|
||||
copy has no `console.sso`, `stack-release.sh` (`:938`) refuses with
|
||||
"activate $TO without --sso first, then ... activate $TO --sso". The
|
||||
usage line is `activate ID BUSINESS` (`:47`), so pasting either command
|
||||
as printed gives a usage error, not the first step. It fails safe,
|
||||
since the usage error changes nothing. The fix: add `$BUSINESS`
|
||||
(set at `:2138`) to both commands, and the business (`acme`)
|
||||
to the three expected strings in `stack-release.test.mjs`
|
||||
(`: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)
|
||||
- **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
|
||||
`docker image inspect node:24-bookworm-slim` to put the base digest
|
||||
in the receipt. BuildKit resolves the base from the registry
|
||||
without tagging it in the local store, so on a host without the tag
|
||||
the inspect throws "No such image" after a good build. That
|
||||
happened in Sage's proof try 1 (2026-10-21 09:03Z, run cf48efccf083).
|
||||
The throw fails closed: the run's terminal record is unclean, both
|
||||
lanes quarantine, and `gate-lane.sh recover` clears them. Pulling the
|
||||
base first fixes it, and the receipt digest then matches what the
|
||||
build used (91acba91 both ways). The fix: before the build, inspect
|
||||
the base and refuse with a named reason if it's missing, or pull it.
|
||||
After the build, refuse if the base digest changed. Add a test with a
|
||||
fake Docker that has no base tag. Kept out of row 197 r6 by Sage
|
||||
(sage-1883). Mos asked for this record in mos-1162. (2026-10-21)
|
||||
- **Case 1 acceptance script: root parses with the node it's checking, and the header says less than root does.**
|
||||
(Duckworth N1-N3 on the row 195 manifest, REVIEW-m1 6afcee2a5.)
|
||||
N1: F9 runs `$ROOT$NODE` as root (`scripts/case1-accept.sh:298`)
|
||||
before F10 checks that `--node` is the `/opt/mosaic-stack/node-<pin>`
|
||||
path (:303-304). On a host installed as designed, `case2-unit.sh:128-133`
|
||||
refused a non-root-owned `--node` and the args file is root's, so no
|
||||
escalation path exists today. Run F9's parse through `AS_USER`, or
|
||||
apply F10's path check first. N2: the header (:10) and the TOOLS.md
|
||||
entry say root only reads, but root also runs `curl` (F2), `sudo -l`
|
||||
(P1, P3), the jarvis probe (P9), `hostcheck.sh --check` and
|
||||
`systemctl`. Doc-only. N3: F6's prefix match (:261) accepts a
|
||||
`--claude` with `..` after `claude-<pin>/`. That claude runs only as
|
||||
`mosaic`, and F10 uses equality. Darkwing has a follow-up on
|
||||
`darkwing/row195-n1` 1e3c4767c, not yet run; it gets its own row now
|
||||
that 195 has landed (b65014647), Duckworth reviews. (2026-10-21)
|
||||
- **Morgana's row 213 mutation set: the B3 pattern no longer matches `bundle.mjs`.**
|
||||
(Webby N3 on row 221 round 1, REVIEW-r1 a0a00630e.) Row 221's
|
||||
candidate changes `packages/harness/src/bundle.mjs`, and the B3
|
||||
anchor at Morgana's 213 `mutants.py:48` stops matching on that
|
||||
base. This matters only if someone reruns that set. Fix the anchor
|
||||
in that tooling before any rerun, and check that a stale anchor
|
||||
refuses instead of skipping the mutant (sage-1941). (2026-10-21)
|
||||
- **Grant executor test X5 starts a `sleep 30` that can outlive its test.**
|
||||
(Webby N1 on row 248 r1, webby-0080; deferred by Duckworth in the row
|
||||
249 review, REVIEW-r1 28174fe08, #1751 comment 30070.) The test at
|
||||
`packages/bus/tests/grant-executor.test.mjs:989` (:934 before row
|
||||
249) runs `sleep 30` on the sshd side, where an abort doesn't reach it. Fix it with a stub
|
||||
that exits once its test dir is gone, as row 248 did for the hold
|
||||
stubs, or with a shorter sleep. This binds the next row that touches
|
||||
that test file. Until then, a lane cleanup that runs after this file
|
||||
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
|
||||
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
|
||||
can't see it (mos-1169). (2026-10-21)
|
||||
|
||||
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
|
||||
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
|
||||
harness with a prompt that draws a known tell. Mos changed that gate. The
|
||||
Claude Code session ran before landing on a scratch root from the
|
||||
candidate tree (run r4ab24630d323, landed 2c38325ec). Pi ran launch only there (run r1dd94171bede):
|
||||
this host has no credential pi reads until P19 #383 settles. Two live
|
||||
checks remain. Claude Code: in the first re-pin whose tree includes row 66,
|
||||
backup first (Q173). Pi: one full session with a tell after #383. Each
|
||||
attaches the operator note on the RESULT, the next-turn correction and the
|
||||
`unslop.jsonl` counts. (2026-10-21)
|
||||
|
||||
- **Console tester listener: `testerNoSignIn` is a dead parameter.**
|
||||
(Duckworth N2 on the row 196 manifest, REVIEW-m1; carried as C1 in
|
||||
REVIEW-m2 cdbf7b64e, #1694 comment 30156.) Row 196 landed 7247d80c7
|
||||
with the parameter and its comment still at `serve.mjs:472-475` and
|
||||
passed from `webui/src/cli.mjs:109` and `sso.test.mjs:392`. Nothing
|
||||
in `serve.mjs` reads it after the destructure. Remove it, or wire it
|
||||
with a test. This binds the next row that touches
|
||||
`packages/webui/src/serve.mjs`. (2026-10-21)
|
||||
- **Console library routes: the tester listener and `library.sync`.**
|
||||
(Duckworth N5 on row 196 round 2, REVIEW-m2 cdbf7b64e.) At 7247d80c7
|
||||
`startServer` (`serve.mjs:475`) takes none of `libraryFile`,
|
||||
`libraryStatus` or `librarySync`, so the callbacks `cli.mjs` passes
|
||||
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
|
||||
|
||||
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
|
||||
## Done
|
||||
|
||||
- Ledger (piece 3, #1506): shipped cd0aa5fb, Gate D written 2026-09-12 (18.9 human messages per closed issue).
|
||||
- Relaunched seat shows the old last message (darkwing, Step 3 log, 2026-09-12): closed by #1512 relaunch activity, af4203ca. Filbert authored it; Darkwing and Dewey reviewed. The live board restart and Gate F remain separate.
|
||||
- Sage launcher hook uncommitted (2026-09-12): closed by the agents/sage launch files commit, Dewey review R1 revise, R2 approve (`agents/dewey/work/sage-launch-review-2026-09-26.md`), launcher test now covers sage.
|
||||
- Ledger counts T3 agent messages as human (2026-09-26, #1506): fixed in ef0020ad (Darkwing 6a, Filbert reviewed). `messageKind` now counts a T3 header as agent and the control-board sender as board. It changes no current count, because no source the ledger reads has a T3 header yet. That waits for the T3 thread source (Gate F).
|
||||
- Control board checked the bind address but not Host or Origin (2026-09-26, #1507, found by Dewey): closed by the board guard commit. `foreignRequest` refuses a non-loopback Host, a wrong port, userinfo or a path in Host, and any foreign Origin on every route. Dewey authored it, and Rocko approved it (`agents/rocko/work/board-guard-review-2026-09-26.md`, 5a12f08e). Rocko's one low finding: URL normalization accepts a root slash, empty userinfo and an empty query in Host, so the notes' wording overstates what the raw-syntax check refuses. No bypass was found.
|
||||
- Discord engine: leaked fake pi and the timeout gap in `busy` (2026-09-26, #1509): fixed in 3edb15eb (Darkwing 6b R2, Rocko approved, review ed5510a0). Tests stop the engine in `finally`. A turn pi never started now stops pi with exit 1 instead of guessing, and the unit restarts it. Known limit: see the open entry on wedge restarts.
|
||||
- Combined concurrent test hangs (2026-09-14, #1509): closed on evidence from 3edb15eb. At default concurrency the union passed 406/406 three times on the R2 snapshot and 408/408 on the commit's index, where clean HEAD had hung four runs out of four. The inherited-intermittency entry from 2026-09-15 stays open until a later run shows it is the same cause.
|
||||
- A nested `node --test` passes even when its tests fail (2026-09-26, #1508): closed. Queue A1 (34a72af9) clears NODE_TEST_CONTEXT on its own path. N13 fixes `test-foundation.sh` and `test-discord.sh`, each with a check that fails the suite if the clearing is lost. Darkwing wrote it (n13.patch 00868b2f) and Filbert approved it (e464be6c). Sage's grep on 2026-09-27 finds no other nested `node --test`: in scripts, only the two suites, `test-queue.sh` and `queue-commit.sh`, and in JS, only the queue test that covers the fix.
|
||||
- SetSpark service accepted any string as a decision approver (2026-09-26, #1509): closed. Fixed in shared-signals cc74d92 (Rocko approved R2, lead decision 24). The operations seat (T3 12fe8cda) deployed it to VM 1022 at 00:23Z on 2026-09-27. The running `service.py` hash matches cc74d92, and `/healthz` returns 200 inside the network and through the public tunnel. The read-only README survey found one row breaking the rule (DEC-009, Proposed, version 1), one open request that isn't ready (DEC-009's), and no Accepted decision with more than 16 approvers. No rows changed. DEC-009 stays as stored (lead decisions 21 and 28). The service now refuses approvals on it, so nothing can go wrong silently. Side effect: `up -d` also recreated caddy, about 20 s of edge downtime for all hosts. That is for the shared-signals deploy procedure to fix, not Mosaic.
|
||||
- Queue suite failed its live checks outside the canonical checkout
|
||||
(2026-09-27, #1508): closed by Piece D, f539466f. `test-queue.sh` now
|
||||
skips `verify` and `render --check` with a printed reason when the
|
||||
toplevel isn't the recorded `canonicalRoot`. An index export passed
|
||||
27/0, and the canonical checkout 29/0.
|
||||
- Queue: an owner listed as a reviewer blocked the row (2026-09-27,
|
||||
#1508): closed by row 31, bb2fa970. `add`, `set reviewers` and `assign`
|
||||
now refuse the owner as a reviewer with exit 2 and write nothing. Rocko
|
||||
built it in the Gate G run, and Filbert approved round 1 (#1508 comment
|
||||
26629, 8 of 8 mutants caught).
|
||||
- Queue follow-ups from rows 12, 13 and 31 (2026-10-04, #1508): closed by
|
||||
row 33, 6fb50cc0. Filbert built it, and Darkwing approved round 1
|
||||
(#1508 comment 26651).
|
||||
- `genesis` refuses a map row whose owner is one of its reviewers (exit
|
||||
2, nothing written). The check is on the map, not in replay, so a log
|
||||
from before the rule still loads.
|
||||
- The `assign` refusal now reads "can't assign row N to SEAT: SEAT is
|
||||
one of its reviewers".
|
||||
- `queue-commit.sh` compares HEAD with H before each canary and again if
|
||||
the clean run fails. A moved HEAD exits 2 with "HEAD moved since H was
|
||||
recorded"; the guard message is kept for a real guard failure.
|
||||
- Every time and date the validator accepts must format back to itself,
|
||||
so month 13, day 32, 2026-02-30, 2027-02-29 and 24:00 are refused.
|
||||
The live log at rev 45 still replays.
|
||||
- The cold-start failure (2026-09-27): not reproduced in 20 cold runs.
|
||||
Each ran `node --test packages/queue/tests/` in a fresh tree, 10
|
||||
shared clones and 10 exports, one after another. All 20 passed
|
||||
148/148 (2960 of 2960). The 300 ms deadline test is unchanged. Counts:
|
||||
`agents/filbert/work/queue-33/cold-runs.txt`.
|
||||
- CHAT-03 I1 round 2 follow-ups (2026-10-04, #1507): four of five closed
|
||||
by slice 1 S5 (row 40, #1522; Dewey authored, Darkwing and Filbert
|
||||
reviewing). The commit is named in the row's review record when it
|
||||
lands.
|
||||
- Engine command and `preArgs`: `engine` takes only `extraArgs`, `cwd`
|
||||
and `envKeys`. The controller always launches the pinned Pi and
|
||||
checks that at construction and at bind. The fake engine comes in
|
||||
through a symbol key that JSON config can't carry (N24b).
|
||||
- Engine environment: an explicit list (`ENGINE_ENV` in `pi-pin.mjs`)
|
||||
plus `*_API_KEY` and `*_TOKEN` names from `engine.envKeys`. Nothing
|
||||
else is inherited. `HOME` passes, so Pi reads `~/.pi/agent` unless
|
||||
`PI_CODING_AGENT_DIR` is set (Darkwing F3).
|
||||
- `escalating` is cleared when the force-stop fence throws (Darkwing F2,
|
||||
races.test.mjs).
|
||||
- Takeover then Enter in one chunk: input after Ctrl-T or Ctrl-O waits
|
||||
for that action, in the same chunk or a later one, and also while an
|
||||
earlier action is still pending, so it is judged as if typed one key
|
||||
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.
|
||||
|
||||
Row 276 / #1781 also owns the case1 root-side capture/tools mutant counterparts (duckworth-0112 N1) and documenting the setuid-member cleanup refusal in the headers (N2). A member that changes uid and cannot be killed causes inner-cleanup refusal, never successful observation. N4 is addressed in row253 by allowing SID confirmation until the existing inner watchdog expires, instead of a separate 200 ms scheduling margin.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
--- a/scripts/case2-unit.sh
|
||||
+++ b/scripts/case2-unit.sh
|
||||
@@ -163,5 +163,3 @@
|
||||
- local pid="$1" raw rest
|
||||
- if ! raw="$(<"/proc/$pid/stat")" 2>/dev/null; then
|
||||
- [ -e "/proc/$pid/stat" ] && return 1
|
||||
- return 3
|
||||
- fi
|
||||
+ local pid="$1" raw="" rest
|
||||
+ # EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
+ IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
@@ -181,3 +179 @@
|
||||
- local entry pid rc live until
|
||||
- clock_cs || return 1
|
||||
- until=$((CLOCK_CS + 25))
|
||||
+ local entry pid rc live until=""
|
||||
@@ -200,0 +197,2 @@
|
||||
+ # Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
+ [ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
@@ -221 +219,2 @@
|
||||
-for ((attempt=0; attempt<100; attempt++)); do
|
||||
+# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
+while :; do
|
||||
--- a/scripts/case1-accept.sh
|
||||
+++ b/scripts/case1-accept.sh
|
||||
@@ -121,5 +121,3 @@
|
||||
- local pid="$1" raw rest
|
||||
- if ! raw="$(<"/proc/$pid/stat")" 2>/dev/null; then
|
||||
- [ -e "/proc/$pid/stat" ] && return 1
|
||||
- return 3
|
||||
- fi
|
||||
+ local pid="$1" raw="" rest
|
||||
+ # EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
+ IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
@@ -139,3 +137 @@
|
||||
- local entry pid rc live until
|
||||
- clock_cs || return 1
|
||||
- until=$((CLOCK_CS + 25))
|
||||
+ local entry pid rc live until=""
|
||||
@@ -158,0 +155,2 @@
|
||||
+ # Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
+ [ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
@@ -179 +177,2 @@
|
||||
-for ((attempt=0; attempt<100; attempt++)); do
|
||||
+# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
+while :; do
|
||||
--- a/packages/cli/tests/case2-unit.test.mjs
|
||||
+++ b/packages/cli/tests/case2-unit.test.mjs
|
||||
@@ -236,0 +237,22 @@
|
||||
+});
|
||||
+
|
||||
+test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
+ // A deterministic clock models a slow inventory without host-load timing.
|
||||
+ // All process reads and signals are stand-ins; no real PID is signalled.
|
||||
+ const source=read(UNIT);
|
||||
+ const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
+ const standins=`
|
||||
+child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
+stat_fields() {
|
||||
+ seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
+ [ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
+ return 0
|
||||
+}
|
||||
+clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
+kill() { kills=$((kills+1)); return 0; }
|
||||
+clean_session; rc=$?
|
||||
+printf '%s' "$kills"
|
||||
+exit "$rc"
|
||||
+`;
|
||||
+ const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
+ assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
--- a/docs/plans/DEFERRED.md
|
||||
+++ b/docs/plans/DEFERRED.md
|
||||
@@ -461,0 +462,2 @@
|
||||
+
|
||||
+Row 276 / #1781 also owns the case1 root-side capture/tools mutant counterparts (duckworth-0112 N1) and documenting the setuid-member cleanup refusal in the headers (N2). A member that changes uid and cannot be killed causes inner-cleanup refusal, never successful observation. N4 is addressed in row253 by allowing SID confirmation until the existing inner watchdog expires, instead of a separate 200 ms scheduling margin.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"scripts/case2-unit.sh": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"scripts/case1-accept.sh": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"packages/cli/tests/case-sudo-fixture.mjs": "b259b8c7926e49829c8a956674021cb0cc85ddd65aebdc786d05cc08d7790008",
|
||||
"packages/cli/tests/case2-unit.test.mjs": "1e0696eb196e83a0766b433ef71f27df0a3f8fcb6399787ab8c1c0f05c54dc44",
|
||||
"packages/cli/tests/case1-accept.test.mjs": "6cfd2963303315aa6911c4d4f6d75d3ab87d9fcba8e128b056216e7f85fd8901",
|
||||
"packages/webui/README.md": "2eeb1f79324f64463a9b3427e67492d672b8d1c14db6c0349c7de17d89e52fc9",
|
||||
"docs/plans/DEFERRED.md": "3ea465cf7ed664ceb1ea4076b829f706f8480f80c4a8aaa6a7487e4eceaf318f"
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
[
|
||||
{
|
||||
"id": "M1",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "4de5294507816ddc4114c418a6bb7c197b3fefe92f19b3b18cb95384e23de594"
|
||||
},
|
||||
{
|
||||
"id": "M2",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "caf88ee7ad50b89169a0f57e12b696fe3828238af2ef1008ba6b6016ea7b2528"
|
||||
},
|
||||
{
|
||||
"id": "M3",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "4401886e41fdb4c27610198b66f12d0b3103c5422e0862e9cea30a6f67c93fde"
|
||||
},
|
||||
{
|
||||
"id": "M4",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "ef9a92bb24c72832bdba4d782bf4662394ee05e06a12b750a23d488d9181cd1d"
|
||||
},
|
||||
{
|
||||
"id": "M5",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "4ab80b9db782a97c935f732bec87ac3bb45b58bf9076bc9f9abb4970fef1cada"
|
||||
},
|
||||
{
|
||||
"id": "M6",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "7064e1926c77aedb5453c7771cd42091a5e90fc546821ddbb75d39e6a87c5a31"
|
||||
},
|
||||
{
|
||||
"id": "M7",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "aece2a211aefa1dc4cfb6a3540f922ee5e7f23de26ce974b7655f8fe1722ee16"
|
||||
},
|
||||
{
|
||||
"id": "M8",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "076d4f8f73570a21ad512b12eb5f6f494cb246b2a9d071fea27cc3c126efca2b"
|
||||
},
|
||||
{
|
||||
"id": "M9",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "244cee7128d3b6e380ff24ab9e31f6c456d5153dddf88bb4719028277f523276"
|
||||
},
|
||||
{
|
||||
"id": "M10",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "0e7d369f34cd41708d7b5104113093ebac0296d08902193ced03a1a2bbb83567"
|
||||
},
|
||||
{
|
||||
"id": "M11",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "84181be89249975dd48e1a5daa51eab4bae7b483ea8edf5d6c0cdf373459c046"
|
||||
},
|
||||
{
|
||||
"id": "M12",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "7f8b45e467b197bc00fb740a6507ee85cae332e404e8ffdd8c749b4264239e97"
|
||||
},
|
||||
{
|
||||
"id": "M13",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "85ec11381f4e6662ec1729d84c57022e2f573a2f4c2682250fa005803cdfd63c"
|
||||
},
|
||||
{
|
||||
"id": "M14",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "131f78c490b5ec26142c0703be0b5341551dd9d154c13e519604215fe1240b67"
|
||||
},
|
||||
{
|
||||
"id": "M15",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "077b2eb210c853a03152733f4aa99e450ef0fa17d28f97f8adb78229b02ef147"
|
||||
},
|
||||
{
|
||||
"id": "M16",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "2439d201b902c13fe3903a2d936c8700f0124a430b7d9b2877d485e1f381d3fb"
|
||||
},
|
||||
{
|
||||
"id": "M17",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "1be36e62095e97c64c0bdc5d50b4a3a2e6dd18f1c45cfe1da0fe66cbb8aebd61"
|
||||
},
|
||||
{
|
||||
"id": "M18",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "f5e61c286c51e5a2b26acab22fc5a32c35add0cb8126dc25b5ce747677e34888"
|
||||
},
|
||||
{
|
||||
"id": "M19",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "2f337c03acc60978f1850275a2c8ba6d1b2c74befeb8d73f94d2b477e43b8c56"
|
||||
},
|
||||
{
|
||||
"id": "M20",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "99689c22c6eb73ff9d599ba7305da552357baa99145305ca400f083017bbe3b1"
|
||||
},
|
||||
{
|
||||
"id": "M21",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "bff90f2b1f59086575f98ff96dbd51bacb0265342f595b4bee96cb228d67c030"
|
||||
},
|
||||
{
|
||||
"id": "M22",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "7a8cb7ce47774ab80a55331fc831b7458487be7187b10128746321b375dc83ff"
|
||||
},
|
||||
{
|
||||
"id": "M23",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "e6814801188a6eb98f705d93ac123e988e4daf43afbe96464451f925b1c98d1c"
|
||||
},
|
||||
{
|
||||
"id": "M24",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "ea8d8875f0b4503a6cf4fb96bd88184af65a7f61d1236b9b49baacc748b0c741"
|
||||
},
|
||||
{
|
||||
"id": "M25",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "cfed9261a03eafcdcfb903226b0ddb3fba779d1105e3b4c411b66c10e1672d11"
|
||||
},
|
||||
{
|
||||
"id": "M26",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "1a245b5403b1b9b5f25dfb8cc8ee24acf99121ff063277d52c928ba18543f997"
|
||||
},
|
||||
{
|
||||
"id": "M27",
|
||||
"file": "scripts/case1-accept.sh",
|
||||
"occurrences": 1,
|
||||
"before": "c02bef6b3495e5a4548d7b4e89675ed52be26a0c9ccce4d9d5cfc5fe2ce437bd",
|
||||
"after": "efaa027cf7ec3252a675be80219e40fa7b5a3317b28c21fe02363d23ec6e5323"
|
||||
},
|
||||
{
|
||||
"id": "M28",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "bb734cc1a42c0853068f77154dddaaee8eeef5696f67de2e27d1a898409f3b44"
|
||||
},
|
||||
{
|
||||
"id": "M29",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "a6f353aa419862a473eee28e41c5208b464a378ae23e14a73b62ed92bd36cd1f"
|
||||
},
|
||||
{
|
||||
"id": "M30",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "d22cf0f492178b3071604abd550355573c28bf4c26f4a3582f6895710ad3d788"
|
||||
},
|
||||
{
|
||||
"id": "M31",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "3e7450a19cf152653bf6e73be39ef9fb552ec7a656564ca35e44bc12f1586ef5"
|
||||
},
|
||||
{
|
||||
"id": "M32",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "ff11fc1013d70d861643cfd70aa964422cb6a82fb8b4b959bae04703803838b9"
|
||||
},
|
||||
{
|
||||
"id": "M33",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "1c2a58bef2369d8af90b65f9473dd7a112f484db983d8460e2524cb8a673f18b"
|
||||
},
|
||||
{
|
||||
"id": "M34",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "13c1ab861017f3c3c65f50d34a64d7028482e3bc11a8e5908632ec6685416025"
|
||||
},
|
||||
{
|
||||
"id": "M35",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "8f05c1a3a18c5294c63a5853317ce5a5066303145410039629697b5d15d99bfe"
|
||||
},
|
||||
{
|
||||
"id": "M36",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "c1247a7cab2c24e0ab017c49f66797ed16149d3f52cfa41c0968f154261d0d00"
|
||||
},
|
||||
{
|
||||
"id": "M37",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "f210b9c796322333849bc4897b9db3e44bd573fef24c4ba757151981b891f11b"
|
||||
},
|
||||
{
|
||||
"id": "M38",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "736418014b96a09ebe82d996ff6f45b9eb1f3c1e0ada274c505f89fbbd732783"
|
||||
},
|
||||
{
|
||||
"id": "M39",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "1486b8907be4f327bc17b64ddf615cd8b3db1193c185f7934bcba1c7ccc5ec31"
|
||||
},
|
||||
{
|
||||
"id": "M40",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"occurrences": 1,
|
||||
"before": "d7670bdb049ae41ba3774fc83de633636859b6723be858cfb31ea106b0e8812b",
|
||||
"after": "2f654a76d887ca62525e01c307a2a8f828ee426087dc63a2d4df6a07dc525d53"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 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
|
||||
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
|
||||
wait "$child"; rc=$?
|
||||
kill -0 "$child" 2>/dev/null || break
|
||||
done
|
||||
return "$rc"
|
||||
}
|
||||
`;
|
||||
|
||||
export function liveRecordedSessions(recordFile, readFile, procDirs) {
|
||||
// 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;
|
||||
try{
|
||||
const stat=readFile(`/proc/${entry}/stat`), fields=stat.slice(stat.lastIndexOf(') ')+2).split(' ');
|
||||
if(sessions.has(Number(fields[3])) && fields[0]!=='Z') live.push(Number(entry));
|
||||
}catch{}
|
||||
}
|
||||
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);
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 195 (#1693): the Case 1 acceptance on 10.1.1.53, design r7 section 5
|
||||
# (agents/darkwing/work/queue-195/DESIGN.md). Deviations and decisions: agents/darkwing/work/queue-195/BUILD.md.
|
||||
#
|
||||
# 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, 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,
|
||||
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
|
||||
# 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. 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
|
||||
# `unnamed`, which means no Q182, Q183 or Mos ruling names the path; each unnamed line goes to Mos.
|
||||
#
|
||||
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
|
||||
# --root misused). A P check never changes the exit.
|
||||
#
|
||||
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
|
||||
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
|
||||
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
|
||||
set -uo pipefail
|
||||
|
||||
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
|
||||
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
|
||||
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
|
||||
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
|
||||
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
|
||||
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
|
||||
--root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$BUSINESS" ] && [ -n "$ARGS_FILE" ] && [ -n "$CLAUDE_PIN" ] && [ -n "$NODE_PIN" ] || usage
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
[ -z "$ROOT" ] || refuse "--root is a test seam, and root takes none"
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
else
|
||||
[ -n "$ROOT" ] || refuse "run it as root"
|
||||
fi
|
||||
cd / || refuse "cannot cd to /"
|
||||
|
||||
# --- The args file, by stack-upgrade.sh's read_args rules, plus the serve ports ---
|
||||
|
||||
f="$ROOT$ARGS_FILE"
|
||||
[ -f "$f" ] && [ ! -L "$f" ] || refuse "no regular file $ARGS_FILE"
|
||||
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_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)) ;;
|
||||
--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
|
||||
--) past=1 ;;
|
||||
--pm) ;;
|
||||
--user) S_USER="$v"; i=$((i + 1)) ;;
|
||||
--home) S_HOME="$v"; i=$((i + 1)) ;;
|
||||
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
|
||||
--node) NODE="$v"; i=$((i + 1)) ;;
|
||||
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
|
||||
--profile) PROFILE="$v"; i=$((i + 1)) ;;
|
||||
--copy) COPY_ID="$v"; copies=$((copies + 1)); i=$((i + 1)) ;;
|
||||
*) refuse "$ARGS_FILE has $a before --, which case2-unit.sh does not take" ;;
|
||||
esac
|
||||
done
|
||||
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
|
||||
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
|
||||
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
|
||||
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,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"
|
||||
CRED="$S_HOME/.config/mosaic-dev/secrets/claude-session.env"
|
||||
BUSINESS_FILE="$S_HOME/.config/mosaic-dev/businesses/$BUSINESS.json"
|
||||
BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service
|
||||
HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh
|
||||
SETTINGS=/etc/claude-code/managed-settings.json
|
||||
LABEL_OUTER='bwrap (enforce)' LABEL_CHILD='bwrap//&unpriv_bwrap (enforce)'
|
||||
AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin")
|
||||
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
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
[ -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=""
|
||||
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
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$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=$!
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; 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" 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.
|
||||
clean() { tr -c '[:print:]' ' ' <<< "$1" | cut -c1-200 | sed 's/ *$//'; }
|
||||
line() { local id="$1" path="$2" mark="$3"; shift 3; echo "$id $path $mark $*"; COUNT[$mark]=$(( ${COUNT[$mark]:-0} + 1 )); }
|
||||
fpass() { line "$1" "$2" pass "${@:3}"; }
|
||||
ffail() { line "$1" "$2" FAIL "${@:3}"; F_FAILED=1; }
|
||||
|
||||
# --- The account database: getent on the host, the seam's files under --root ---
|
||||
|
||||
db() {
|
||||
if [ -n "$ROOT" ]; then
|
||||
if [ $# -gt 1 ]; then awk -F: -v k="$2" '$1 == k' "$ROOT/etc/$1"; else cat "$ROOT/etc/$1"; fi
|
||||
else
|
||||
getent "$@"
|
||||
fi
|
||||
}
|
||||
uid_of() { db passwd "$1" | cut -d: -f3; }
|
||||
gid_of() { db passwd "$1" | cut -d: -f4; }
|
||||
# The group ids USER belongs to, as `id -G USER` prints them: the primary gid, then each group
|
||||
# whose member list names USER.
|
||||
groups_of() {
|
||||
local g; g="$(gid_of "$1")"
|
||||
[ -n "$g" ] || return 0
|
||||
{ echo "$g"; db group | awk -F: -v u="$1" '{ n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) print $3 }'; } | awk '!seen[$0]++'
|
||||
}
|
||||
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 ---
|
||||
|
||||
# KEY=VALUE lines for UNIT's properties.
|
||||
unit_show() { local u="$1"; shift; systemctl show "$u" "${@/#/-p}" 2>/dev/null; }
|
||||
prop() { sed -n "s/^$2=//p" <<< "$1" | tail -n 1; }
|
||||
# The real, effective, saved and filesystem uids of PID, space-separated.
|
||||
proc_uids() { awk '$1 == "Uid:" { print $2, $3, $4, $5 }' "$ROOT/proc/$1/status" 2>/dev/null; }
|
||||
proc_label() { local l; l="$(cat "$ROOT/proc/$1/attr/current" 2>/dev/null)" || return 1; printf '%s' "${l%$'\n'}"; }
|
||||
# One `ADDRESS PIDS` line per TCP listener on port $1, PIDS comma-separated.
|
||||
listeners() {
|
||||
ss -Hltnp 2>/dev/null | awk -v p="$1" '{
|
||||
n = split($4, a, ":"); if (a[n] != p) next
|
||||
addr = substr($4, 1, length($4) - length(p) - 1); pids = ""; s = $0
|
||||
while (match(s, /pid=[0-9]+/)) { pids = pids (pids == "" ? "" : ",") substr(s, RSTART + 4, RLENGTH - 4); s = substr(s, RSTART + RLENGTH) }
|
||||
print addr, pids
|
||||
}'
|
||||
}
|
||||
# 0 when port $1 has a listener and every one is on 127.0.0.1, held by pid $2 alone.
|
||||
loopback_by() {
|
||||
local addr pids p any=0
|
||||
[ -n "$2" ] && [ "$2" != 0 ] || return 1
|
||||
while read -r addr pids; do
|
||||
[ -n "$addr" ] || continue
|
||||
any=1
|
||||
[ "$addr" = 127.0.0.1 ] || return 1
|
||||
for p in ${pids//,/ }; do [ "$p" = "$2" ] || return 1; done
|
||||
[ -n "$pids" ] || return 1
|
||||
done <<< "$(listeners "$1")"
|
||||
[ "$any" = 1 ]
|
||||
}
|
||||
listener_text() { local t; t="$(listeners "$1" | tr '\n' ';')"; echo "${t:-none}"; }
|
||||
|
||||
BUS_SHOW="$(unit_show "$BUS_UNIT" ActiveState User MainPID)"
|
||||
CON_SHOW="$(unit_show "$CON_UNIT" ActiveState User MainPID)"
|
||||
CON_PID="$(prop "$CON_SHOW" MainPID)"
|
||||
|
||||
# --- F checks ---
|
||||
|
||||
check_f1() {
|
||||
local u show state user pid uids why=""
|
||||
for u in "$BUS_UNIT" "$CON_UNIT"; do
|
||||
if [ "$u" = "$BUS_UNIT" ]; then show="$BUS_SHOW"; else show="$CON_SHOW"; fi
|
||||
state="$(prop "$show" ActiveState)" user="$(prop "$show" User)" pid="$(prop "$show" MainPID)"
|
||||
uids="$(proc_uids "${pid:-0}")"
|
||||
[ "$state" = active ] || { why+="$u is ${state:-unknown}; "; continue; }
|
||||
[ "$user" = "$S_USER" ] || { why+="$u has User=${user:-unset}; "; continue; }
|
||||
[ -n "$MOSAIC_UID" ] && [ "$uids" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] ||
|
||||
why+="$u MainPID ${pid:-none} has uids '${uids:-unreadable}', not $S_USER's ${MOSAIC_UID:-unknown}; "
|
||||
done
|
||||
if [ -z "$why" ]; then fpass F1 units-user "$BUS_UNIT and $CON_UNIT are active as $S_USER (uid $MOSAIC_UID)"
|
||||
else ffail F1 units-user "${why%; }"; fi
|
||||
}
|
||||
|
||||
check_f2() {
|
||||
local rc=0
|
||||
curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null || rc=$?
|
||||
if [ "$rc" != 0 ]; then ffail F2 console-loopback "curl http://127.0.0.1:$PORT/healthz exited $rc"
|
||||
elif ! loopback_by "$PORT" "$CON_PID"; then ffail F2 console-loopback "port $PORT listeners are '$(listener_text "$PORT")', not 127.0.0.1 by the Console's MainPID ${CON_PID:-none}"
|
||||
else fpass F2 console-loopback "healthz answers, and $PORT is bound to 127.0.0.1 only, by MainPID $CON_PID"; fi
|
||||
}
|
||||
|
||||
# F5's scan: an outer /usr/bin/bwrap owned by the stack's user with a claude descendant. Sets F5_OK
|
||||
# when one pair has both labels exactly, F5_WHY to the last mismatch otherwise.
|
||||
F5_OK="" F5_WHY="no /usr/bin/bwrap process of $S_USER with a claude descendant was seen while the reply was pending"
|
||||
scan_f5() {
|
||||
local pid ppid uids kids q c argv0 comm lo lc
|
||||
local -A parent=() children=() bw=()
|
||||
# One read of every PPid line and one of every exe link, so a scan stays short on a busy host.
|
||||
while read -r pid ppid; do
|
||||
parent[$pid]="$ppid" children[$ppid]+=" $pid"
|
||||
done < <(grep -H '^PPid:' "$ROOT"/proc/[0-9]*/status 2>/dev/null | sed -n 's#^.*/proc/\([0-9]*\)/status:PPid:[[:space:]]*\([0-9]*\)$#\1 \2#p')
|
||||
while read -r pid; do
|
||||
[[ "$pid" =~ ^[0-9]+$ ]] && [ -n "${parent[$pid]:-}" ] && bw[$pid]=1
|
||||
done < <(find "$ROOT/proc" -mindepth 2 -maxdepth 2 -name exe -lname /usr/bin/bwrap -printf '%h\n' 2>/dev/null | sed 's#.*/##')
|
||||
for pid in "${!bw[@]}"; do
|
||||
[ -z "${bw[${parent[$pid]}]:-}" ] || continue
|
||||
uids="$(proc_uids "$pid")"
|
||||
[ -n "$MOSAIC_UID" ] && [ "${uids%% *}" = "$MOSAIC_UID" ] || continue
|
||||
# Every descendant, breadth first.
|
||||
kids=() q=("$pid")
|
||||
while [ ${#q[@]} -gt 0 ]; do
|
||||
for c in ${children[${q[0]}]:-}; do kids+=("$c") q+=("$c"); done
|
||||
q=("${q[@]:1}")
|
||||
done
|
||||
for c in "${kids[@]}"; do
|
||||
argv0="$(tr '\0' '\n' < "$ROOT/proc/$c/cmdline" 2>/dev/null | sed -n 1p)"
|
||||
comm="$(cat "$ROOT/proc/$c/comm" 2>/dev/null)"
|
||||
[[ "${argv0##*/}" == claude* ]] || [[ "$comm" == claude* ]] || continue
|
||||
lo="$(proc_label "$pid")" lc="$(proc_label "$c")"
|
||||
if [ "$lo" = "$LABEL_OUTER" ] && [ "$lc" = "$LABEL_CHILD" ]; then
|
||||
F5_OK="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc'"; return 0
|
||||
fi
|
||||
F5_WHY="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc', not '$LABEL_OUTER' and '$LABEL_CHILD'"
|
||||
done
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# F3 and F5 together: the message goes out in the background and the scan runs until it is answered.
|
||||
check_f3_f5() {
|
||||
local marker text rc=0 out err n tpid
|
||||
marker="case1-accept-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
text="$marker: this is the Case 1 acceptance; reply with one short line."
|
||||
timeout 330 "${AS_USER[@]}" "$COPY/scripts/mosaic" talk chief "$text" --business "$BUSINESS" --tester --wait 300 > "$WORK/talk.out" 2> "$WORK/talk.err" &
|
||||
tpid=$!
|
||||
while :; do
|
||||
scan_f5 && break
|
||||
kill -0 "$tpid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
wait "$tpid" || rc=$?
|
||||
# Both the sent line and the reply are on stdout; an error is on stderr.
|
||||
out="$(cat "$WORK/talk.out")" err="$(cat "$WORK/talk.err")"
|
||||
n="$(grep -c . <<< "$out")"
|
||||
if [ "$rc" = 0 ] && grep -q "^sent [^ ]* to chief" <<< "$out" && [ "$n" -ge 2 ]; then
|
||||
fpass F3 tester-reply "chief replied to $marker"
|
||||
else
|
||||
ffail F3 tester-reply "talk exited $rc with $n lines on stdout; last: $(clean "$(tail -n 1 <<< "${err:-$out}")")"
|
||||
fi
|
||||
if [ -n "$F5_OK" ]; then F5_LINE=(fpass "$F5_OK"); else F5_LINE=(ffail "$F5_WHY"); fi
|
||||
}
|
||||
|
||||
check_f4() {
|
||||
local rc=0 out
|
||||
out="$("${AS_USER[@]}" "$COPY/scripts/case2-precheck.sh" --business "$BUSINESS" --profile factory 2>&1)" || rc=$?
|
||||
if [ "$rc" = 0 ]; then fpass F4 precheck "case2-precheck.sh --profile factory exits 0"
|
||||
else ffail F4 precheck "case2-precheck.sh --profile factory exited $rc; last: $(clean "$(tail -n 1 <<< "$out")")"; fi
|
||||
}
|
||||
|
||||
check_f6() {
|
||||
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
|
||||
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
|
||||
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
|
||||
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
|
||||
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
|
||||
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
|
||||
}
|
||||
|
||||
# The PM check confirm runs (stack-release.sh pm_external): exactly one live launches list entry for
|
||||
# launch.by, for this business. The stack's user runs the whole check and prints one result line.
|
||||
F7_JS='
|
||||
const {readFileSync}=require("node:fs");const {spawnSync}=require("node:child_process");
|
||||
const [file,cli,b]=process.argv.slice(1);
|
||||
let by;
|
||||
try{by=JSON.parse(readFileSync(file,"utf8")).launch?.by}catch(e){console.log(`fail cannot read ${file}: ${e.code??"not JSON"}`);process.exit(0)}
|
||||
if(typeof by!=="string"||!by){console.log("fail the business file has no launch.by");process.exit(0)}
|
||||
const r=spawnSync(cli,["launches","list","--json"],{encoding:"utf8"});
|
||||
if(r.status!==0){console.log(`fail launches list exited ${r.status}`);process.exit(0)}
|
||||
let l;try{l=JSON.parse(r.stdout)}catch{console.log("fail launches list printed no JSON");process.exit(0)}
|
||||
const live=Array.isArray(l)?l.filter((x)=>x&&x.instance===by&&x.live===true):[];
|
||||
console.log(live.length!==1?`fail ${live.length} live launches of ${by}`:live[0].business!==b?`fail the live launch of ${by} is for ${live[0].business}`:`ok ${by} run ${live[0].run}`);'
|
||||
check_f7() {
|
||||
local out
|
||||
out="$("${AS_USER[@]}" "$NODE" -e "$F7_JS" -- "$BUSINESS_FILE" "$COPY/scripts/mosaic" "$BUSINESS" 2>/dev/null | sed -n 1p)"
|
||||
if [[ "$out" =~ ^ok\ [a-z0-9][a-z0-9-]*\ run\ [A-Za-z0-9._-]+$ ]]; then fpass F7 pm-live "one live launch: ${out#ok }"
|
||||
else ffail F7 pm-live "$(clean "${out#fail }")"; fi
|
||||
}
|
||||
|
||||
check_f8() {
|
||||
local s
|
||||
s="$(stat -c '%U %a %F' -- "$ROOT$CRED" 2>/dev/null)" || s="missing"
|
||||
if [ ! -L "$ROOT$CRED" ] && [ "$s" = "$S_USER 600 regular file" ]; then fpass F8 credential-file "$CRED is '$s'"
|
||||
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
|
||||
}
|
||||
|
||||
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
|
||||
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
|
||||
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
|
||||
not_root_only() {
|
||||
local p="$1" st
|
||||
while :; do
|
||||
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
|
||||
[ "$p" = / ] && return
|
||||
p="$(dirname -- "$p")"
|
||||
done
|
||||
}
|
||||
|
||||
check_f9() {
|
||||
local s v bad
|
||||
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
|
||||
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
|
||||
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
|
||||
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
|
||||
bad="$(not_root_only "$NODE")"
|
||||
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
|
||||
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
|
||||
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
|
||||
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
|
||||
}
|
||||
|
||||
check_f10() {
|
||||
local want="/opt/mosaic-stack/node-$NODE_PIN/bin/node" v
|
||||
[ "$NODE" = "$want" ] || { ffail F10 node-pin "--node $NODE is not $want"; return; }
|
||||
v="$("${AS_USER[@]}" "$NODE" --version 2>/dev/null | sed -n 1p)"
|
||||
if [ "$v" = "$NODE_PIN" ]; then fpass F10 node-pin "$NODE prints $v"
|
||||
else ffail F10 node-pin "$NODE --version prints '$(clean "$v")', not $NODE_PIN"; fi
|
||||
}
|
||||
|
||||
check_f11() {
|
||||
local d="$ROOT$DATA_ROOT/discord" found
|
||||
if [ -L "$d" ]; then ffail F11 no-discord "$DATA_ROOT/discord is a symlink"; return; fi
|
||||
[ -d "$d" ] || { fpass F11 no-discord "$DATA_ROOT/discord is absent"; return; }
|
||||
found="$(find "$d" -maxdepth 1 -name '*.json' -printf '%f ' 2>/dev/null)"
|
||||
if [ -z "$found" ]; then fpass F11 no-discord "$DATA_ROOT/discord holds no binding"
|
||||
else ffail F11 no-discord "$DATA_ROOT/discord holds bindings: ${found% }"; fi
|
||||
}
|
||||
|
||||
# --- P checks ---
|
||||
|
||||
check_p1() {
|
||||
local out
|
||||
out="$(sudo -l -U "$S_USER" 2>&1)"
|
||||
if grep -q "is not allowed to run sudo" <<< "$out"; then line P1 "$S_USER-sudo" closed "sudo -l: $(clean "$(sed -n 1p <<< "$out")")"
|
||||
else line P1 "$S_USER-sudo" unnamed "sudo -l: $(clean "$(tr '\n' ' ' <<< "$out")")"; fi
|
||||
}
|
||||
|
||||
check_p2() {
|
||||
local gs keys="$ROOT$S_HOME/.ssh/authorized_keys" kstate n
|
||||
gs="$(groups_of "$S_USER" | tr '\n' ' ')"; gs="${gs% }"
|
||||
n="$(wc -w <<< "$gs")"
|
||||
if [ ! -e "$keys" ] && [ ! -L "$keys" ]; then kstate=absent
|
||||
elif [ -f "$keys" ] && [ ! -L "$keys" ] && [ ! -s "$keys" ]; then kstate=empty
|
||||
else kstate=present; fi
|
||||
if [ "$n" = 1 ] && [ "$kstate" != present ]; then line P2 "$S_USER-groups-keys" closed "id -G is '$gs'; authorized_keys is $kstate"
|
||||
else line P2 "$S_USER-groups-keys" unnamed "id -G is '$gs'; authorized_keys is $kstate"; fi
|
||||
}
|
||||
|
||||
check_p3() {
|
||||
local n
|
||||
n="$(sudo -l -U localadmin 2>&1 | grep -c NOPASSWD)"
|
||||
line P3 localadmin-sudo "accepted Q183" "sudo -l -U localadmin lists $n NOPASSWD lines"
|
||||
}
|
||||
|
||||
check_p4() { line P4 pve-root-pbs "accepted Q182+Q183" "root on pve over VM 1008, and PBS backups that hold $S_HOME with the credential; this script can't see pve"; }
|
||||
|
||||
check_p5() {
|
||||
local gs reach
|
||||
if [ -z "$(uid_of jarvis)" ]; then line P5 jarvis-reach closed "no jarvis account"; return; fi
|
||||
gs="$(names_of_groups $(groups_of jarvis) | tr '\n' ' ')"
|
||||
reach="$(tr ' ' '\n' <<< "$gs" | grep -xE 'sudo|docker|lxd' | tr '\n' ' ')"
|
||||
if [ -z "$reach" ]; then line P5 jarvis-reach closed "jarvis is in none of sudo, docker and lxd (groups: ${gs% })"
|
||||
else line P5 jarvis-reach unnamed "jarvis is in ${reach% }"; fi
|
||||
}
|
||||
|
||||
check_p6() {
|
||||
local g m members bad=0
|
||||
for g in sudo docker lxd; do
|
||||
members="$(db group "$g" | cut -d: -f4)"
|
||||
for m in ${members//,/ }; do
|
||||
[ "$g" = sudo ] && [ "$m" = localadmin ] && continue
|
||||
line P6 "group-$g" unnamed "$m is a member of $g"; bad=1
|
||||
done
|
||||
done
|
||||
[ "$bad" = 1 ] || line P6 group-members closed "sudo holds only localadmin, and docker and lxd have no members"
|
||||
}
|
||||
|
||||
check_p7() {
|
||||
local found
|
||||
found="$(grep -rlw -- "$S_USER" "$ROOT/etc/sudoers.d" 2>/dev/null | sed "s#^$ROOT##" | tr '\n' ' ')"
|
||||
if [ -z "$found" ]; then line P7 sudoers-d closed "no file under /etc/sudoers.d names $S_USER"
|
||||
else line P7 sudoers-d unnamed "files naming $S_USER: ${found% }"; fi
|
||||
}
|
||||
|
||||
check_p8() {
|
||||
local other
|
||||
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"); 7333: $(listener_text 7333); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_p9() {
|
||||
local out n missing=() rc=0 err mg others
|
||||
out="$("$ROOT$HOSTCHECK" --check 2>/dev/null)"
|
||||
for n in 1 2 3 4 5 6; do grep -q "^ok $n " <<< "$out" || missing+=("hostcheck item $n has no ok line")
|
||||
done
|
||||
err="$(sudo -u jarvis /usr/bin/bwrap --version 2>&1 >/dev/null)" || rc=$?
|
||||
[ "$rc" != 0 ] && grep -q "Permission denied" <<< "$err" || missing+=("sudo -u jarvis /usr/bin/bwrap --version exited $rc without Permission denied")
|
||||
mg="$(gid_of "$S_USER")"
|
||||
[ -n "$mg" ] && [ -z "$(db group "$S_USER" | cut -d: -f4)" ] || missing+=("group $S_USER lists members, or $S_USER has no group")
|
||||
others="$(db passwd | awk -F: -v g="$mg" -v u="$S_USER" '$4 == g && $1 != u { print $1 }' | tr '\n' ' ')"
|
||||
[ -z "$others" ] || missing+=("other accounts have $S_USER's gid $mg: ${others% }")
|
||||
if [ ${#missing[@]} = 0 ]; then
|
||||
line P9 bwrap-userns "accepted mos-1096" "hostcheck items 1-6 ok, jarvis is denied bwrap, and group $S_USER has no other member"
|
||||
else
|
||||
for n in "${missing[@]}"; do line P9 bwrap-userns unnamed "$n"; done
|
||||
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 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)"
|
||||
[[ "$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-socket-squat unnamed "boundary=failed reason=$why path=$TESTER_PATH MainPID=${CON_PID:-none}; hostile same-uid substitution remains"
|
||||
fi
|
||||
}
|
||||
|
||||
check_p11() {
|
||||
local en before
|
||||
en="$(systemctl is-enabled mosaic-hostcheck.service 2>/dev/null)"
|
||||
before="$(prop "$(unit_show mosaic-hostcheck.service Before)" Before)"
|
||||
if [ "$en" = enabled ] && grep -qxF -- "$BUS_UNIT" <<< "${before// /$'\n'}"; then
|
||||
line P11 hostcheck-before-bus closed "mosaic-hostcheck.service is enabled, and its Before= names $BUS_UNIT"
|
||||
else
|
||||
line P11 hostcheck-before-bus unnamed "mosaic-hostcheck.service is '${en:-unknown}', Before='$before'; it needs enabled and $BUS_UNIT"
|
||||
fi
|
||||
}
|
||||
|
||||
check_f1
|
||||
check_f2
|
||||
F5_LINE=()
|
||||
check_f3_f5
|
||||
check_f4
|
||||
"${F5_LINE[0]}" F5 bwrap-label "${F5_LINE[@]:1}"
|
||||
check_f6
|
||||
check_f7
|
||||
check_f8
|
||||
check_f9
|
||||
check_f10
|
||||
check_f11
|
||||
check_p1; check_p2; check_p3; check_p4; check_p5; check_p6
|
||||
check_p7; check_p8; check_p9; check_p10; check_p11
|
||||
|
||||
for m in "${MARKS[@]}"; do echo "count $m ${COUNT[$m]:-0}"; done
|
||||
if [ "$F_FAILED" = 0 ]; then echo "case1-accept: every F check passes; send the whole output to Duckworth, and each unnamed line to Mos"; exit 0; fi
|
||||
echo "case1-accept: an F check failed, so the row fails"
|
||||
exit 1
|
||||
@@ -0,0 +1,717 @@
|
||||
// Row 195 (#1693): scripts/case1-accept.sh, root's acceptance run on the Case 1 host. Design r7 section 5
|
||||
// (agents/darkwing/work/queue-195/DESIGN.md); deviations in agents/darkwing/work/queue-195/BUILD.md.
|
||||
// Every case runs the script with --root on a scratch host tree: /etc, /proc, /srv/mosaic, /opt and the
|
||||
// host check live under it. Fake systemctl, curl, ss, sudo and stat on PATH record their argv; the fake
|
||||
// sudo runs the stack's user's commands from the tree. Nothing here reads or writes the real /etc,
|
||||
// /proc or /srv, and the stand-in credential is not a credential.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.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");
|
||||
const COPY = "0123456789abcdef0123456789abcdef01234567";
|
||||
const NODE_PIN = "v24.1.0", CLAUDE_PIN = "2.1.0";
|
||||
const NODE = `/opt/mosaic-stack/node-${NODE_PIN}/bin/node`;
|
||||
const CLAUDE = `/opt/mosaic-stack/claude-${CLAUDE_PIN}/node_modules/@anthropic-ai/claude-code/bin/claude.exe`;
|
||||
const HOME = "/srv/mosaic";
|
||||
const CLI = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/mosaic`;
|
||||
const PRECHECK = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/case2-precheck.sh`;
|
||||
const CRED = `${HOME}/.config/mosaic-dev/secrets/claude-session.env`;
|
||||
const CRED_VALUE = "stand-in-not-a-credential-7f3e2a";
|
||||
const AS_MOSAIC = `-u mosaic -H env PATH=/opt/mosaic-stack/node-${NODE_PIN}/bin:/usr/local/bin:/usr/bin:/bin`;
|
||||
const ARGS = ["--profile", "factory", "--user", "mosaic", "--home", HOME, "--business", "mosaic-stack", "--copy", COPY,
|
||||
"--node", NODE, "--claude", CLAUDE, "--pm", "--", "--port", "732", "--business", "mosaic-stack", "--tester-socket"];
|
||||
const OUTER = "bwrap (enforce)", CHILD = "bwrap//&unpriv_bwrap (enforce)";
|
||||
const MARKS = ["pass", "FAIL", "closed", "accepted Q182+Q183", "accepted Q183", "accepted mos-1096", "unnamed"];
|
||||
const P_PASS = { P1: "closed", P2: "closed", P3: "accepted Q183", P4: "accepted Q182+Q183", P5: "closed", P6: "closed",
|
||||
P7: "closed", P8: "closed", P9: "accepted mos-1096", P10: "unnamed", P11: "closed" };
|
||||
const PASSWD = "root:x:0:0:root:/root:/bin/bash\nmosaic:x:997:997::/srv/mosaic:/usr/sbin/nologin\nlocaladmin:x:1000:1000::/home/localadmin:/bin/bash\njarvis:x:1001:1001::/home/jarvis:/bin/bash\n";
|
||||
const GROUP = "root:x:0:\nsudo:x:27:localadmin\nmosaic:x:997:\nlxd:x:998:\ndocker:x:999:\nlocaladmin:x:1000:\njarvis:x:1001:\n";
|
||||
const SS = "LISTEN 0 511 127.0.0.1:732 0.0.0.0:* users:((\"node\",pid=200,fd=21))\n"
|
||||
+ "LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=900,fd=3))\n";
|
||||
const HOSTCHECK_OK = [1, 2, 3, 4, 5, 6, 7].map((n) => `ok ${n} item ${n} holds`).join("\n") + "\nnote bubblewrap 0.9.0 is the install pin\n";
|
||||
|
||||
function put(file, text, mode) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
if (mode) chmodSync(file, mode);
|
||||
}
|
||||
const edit = (file, f) => writeFileSync(file, f(readFileSync(file, "utf8")));
|
||||
const log = (name) => `printf '%s' ${JSON.stringify(name)} >> "$HC/calls.log"; printf ' %s' "$@" >> "$HC/calls.log"; echo >> "$HC/calls.log"`;
|
||||
|
||||
// Fakes on PATH. Each records its argv and answers from a file in the state directory.
|
||||
const FAKES = {
|
||||
systemctl: `case "$1" in
|
||||
show) [ -f "$HC/show-$2" ] && cat "$HC/show-$2" ;;
|
||||
is-enabled) cat "$HC/enabled-$2" 2>/dev/null || { echo disabled; exit 1; } ;;
|
||||
esac`,
|
||||
curl: `exit "$(cat "$HC/curl-rc")"`,
|
||||
ss: `cat "$HC/ss"`,
|
||||
od: `if [ "$1" = -An ] && [ "$2" = -tx1 ] && [ "$3" = -N4 ]; then
|
||||
[ -f "$HC/elf" ] && cat "$HC/elf" || echo 7f454c46
|
||||
else exec /usr/bin/od "$@"; fi`,
|
||||
// The credential and the settings file answer from the state directory, as root would see them, and
|
||||
// only those calls are recorded; everything else is the real stat, which reads the args file.
|
||||
stat: `case "\${@: -1}" in
|
||||
*claude-session.env) ${log("stat")}; cat "$HC/stat-cred" ;;
|
||||
*console/tester.sock) cat "$HC/stat-socket" ;;
|
||||
*console) [ "$2" = '%u %a %F %d %i' ] && cat "$HC/stat-parent" || echo "997 700" ;;
|
||||
*managed-settings.json) ${log("stat")}; cat "$HC/stat-settings" ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac`,
|
||||
// -l -U USER prints the state file; -u jarvis is the bwrap probe; -u mosaic -H env PATH=... CMD runs
|
||||
// CMD from the tree, with the PATH entries and each absolute argument that exists there moved under it.
|
||||
sudo: `${CASE_SUDO_SESSION}
|
||||
case "$1" in
|
||||
-l) [ -f "$HC/sudo-l-$3" ] && cat "$HC/sudo-l-$3"; exit 0 ;;
|
||||
-u) ;;
|
||||
*) exit 97 ;;
|
||||
esac
|
||||
user="$2"; shift 2
|
||||
if [ "$user" = jarvis ]; then cat "$HC/jarvis-bwrap" >&2; exit "$(cat "$HC/jarvis-bwrap-rc")"; fi
|
||||
[ "$user" = mosaic ] && [ "$1" = -H ] && [ "$2" = env ] || exit 98
|
||||
shift 2
|
||||
if [ "$1" = -i ]; then
|
||||
[ "$2" = HOME=/srv/mosaic ] || exit 98
|
||||
shift 2
|
||||
fi
|
||||
[[ "$1" == PATH=* ]] || exit 98
|
||||
p="\${1#PATH=}"; shift
|
||||
np=""; IFS=: read -ra parts <<< "$p"; for x in "\${parts[@]}"; do np+="$ROOT_DIR$x:"; done
|
||||
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"
|
||||
[ ! -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
|
||||
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
|
||||
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
|
||||
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
|
||||
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" ] && {
|
||||
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 '
|
||||
import os,socket,struct,sys
|
||||
class Fake:
|
||||
def __init__(self,*a): assert a==(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
def settimeout(self,n): assert n==1
|
||||
def connect(self,p): assert p.endswith("/console/tester.sock")
|
||||
def getsockopt(self,*a):
|
||||
assert a==(socket.SOL_SOCKET,socket.SO_PEERCRED,12)
|
||||
return struct.pack("3i",*[int(x) for x in open(os.environ["HC"]+"/peer").read().split()])
|
||||
def close(self): pass
|
||||
socket.socket=Fake
|
||||
payload=sys.argv[4]
|
||||
sys.argv=["probe",sys.argv[5]]
|
||||
exec(payload)
|
||||
' "$@"`;
|
||||
const TREE = {
|
||||
"/usr/bin/python3": PYTHON_STUB,
|
||||
[NODE]: NODE_STUB,
|
||||
[CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`,
|
||||
[CLI]: `${log("mosaic")}
|
||||
case "$1" in
|
||||
talk) case "$(cat "$HC/talk-mode")" in
|
||||
reply) echo "sent ab12cd34 to chief"; echo "[chief -> tester] seen" ;;
|
||||
noreply) echo "sent ab12cd34 to chief"; echo "no reply from chief within 300 s" >&2; exit 1 ;;
|
||||
sentonly) echo "sent ab12cd34 to chief" ;;
|
||||
esac ;;
|
||||
launches) cat "$HC/launches.json" ;;
|
||||
*) exit 2 ;;
|
||||
esac`,
|
||||
[PRECHECK]: `${log("precheck")}
|
||||
echo "precheck: done"; exit "$(cat "$HC/precheck-rc")"`,
|
||||
"/usr/local/lib/mosaic-stack/hostcheck.sh": `${log("hostcheck")}
|
||||
cat "$HC/hostcheck-out"`,
|
||||
};
|
||||
|
||||
function proc(root, pid, { ppid, uid = 997, exe, argv0 = "x", comm = "x", label }) {
|
||||
const d = join(root, "proc", String(pid));
|
||||
put(join(d, "status"), `Name:\t${comm}\nState:\tS (sleeping)\nPPid:\t${ppid}\nUid:\t${uid}\t${uid}\t${uid}\t${uid}\n`);
|
||||
put(join(d, "cmdline"), `${argv0}\0--x\0`);
|
||||
put(join(d, "comm"), `${comm}\n`);
|
||||
rmSync(join(d, "exe"), { force: true });
|
||||
if (exe) symlinkSync(exe, join(d, "exe"));
|
||||
if (label !== undefined) put(join(d, "attr", "current"), label);
|
||||
}
|
||||
|
||||
// A scratch Case 1 host where every F check passes and every P check is closed or accepted.
|
||||
function host(t) {
|
||||
const dir = tmp(t, "c1accept-");
|
||||
const root = join(dir, "root"), hc = join(dir, "hc"), bin = join(dir, "bin");
|
||||
const args = join(root, "etc", "mosaic-stack", "dev.args");
|
||||
put(args, ARGS.join("\n") + "\n", 0o644);
|
||||
put(join(root, "etc", "passwd"), PASSWD);
|
||||
put(join(root, "etc", "group"), GROUP);
|
||||
put(join(root, "etc", "sudoers.d", "90-localadmin"), "localadmin ALL=(ALL) NOPASSWD: ALL\n");
|
||||
put(join(root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "1" } }));
|
||||
put(join(root, CRED), `CLAUDE_CODE_OAUTH_TOKEN=${CRED_VALUE}\n`, 0o600);
|
||||
put(join(root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack", launch: { by: "pm" } }));
|
||||
put(join(root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
proc(root, 1, { ppid: 0, uid: 0, exe: "/usr/lib/systemd/systemd", comm: "systemd" });
|
||||
proc(root, 100, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" });
|
||||
proc(root, 200, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" });
|
||||
proc(root, 300, { ppid: 100, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` });
|
||||
proc(root, 301, { ppid: 300, exe: NODE, argv0: NODE, comm: "node", label: `${CHILD}\n` });
|
||||
proc(root, 302, { ppid: 301, exe: CLAUDE, argv0: CLAUDE, comm: "claude.exe", label: `${CHILD}\n` });
|
||||
// A root bwrap with a mislabelled claude child, which F5 must pass over.
|
||||
proc(root, 400, { ppid: 1, uid: 0, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: "unconfined\n" });
|
||||
proc(root, 401, { ppid: 400, uid: 0, argv0: "claude", comm: "claude", label: "unconfined\n" });
|
||||
const state = {
|
||||
"[email protected]": "ActiveState=active\nUser=mosaic\nMainPID=100\n",
|
||||
"show-mosaic-webui.service": "ActiveState=active\nUser=mosaic\nMainPID=200\n",
|
||||
"show-mosaic-hostcheck.service": "[email protected] mosaic-webui.service multi-user.target\n",
|
||||
"enabled-mosaic-hostcheck.service": "enabled\n",
|
||||
"curl-rc": "0", ss: SS,
|
||||
"stat-parent": "997 700 directory 9 10\n", "stat-socket": "997 600 socket 9 11\n", peer: "200 997 997\n",
|
||||
"stat-cred": "mosaic 600 regular file\n", "stat-settings": "root:root 644 regular file\n",
|
||||
"sudo-l-mosaic": "User mosaic is not allowed to run sudo on dev-53.\n",
|
||||
"sudo-l-localadmin": "User localadmin may run the following commands on dev-53:\n (ALL : ALL) ALL\n (ALL) NOPASSWD: ALL\n",
|
||||
"jarvis-bwrap": "sudo: unable to execute /usr/bin/bwrap: Permission denied\n", "jarvis-bwrap-rc": "1",
|
||||
"node-version": `${NODE_PIN}\n`, "claude-version": `${CLAUDE_PIN} (Claude Code)\n`,
|
||||
"talk-mode": "reply", "precheck-rc": "0", "hostcheck-out": HOSTCHECK_OK,
|
||||
"launches.json": JSON.stringify([
|
||||
{ instance: "pm", live: true, business: "mosaic-stack", run: "r-0002" },
|
||||
{ instance: "pm", live: false, business: "mosaic-stack", run: "r-0001" },
|
||||
{ instance: "dev-1", live: true, business: "mosaic-stack", run: "r-0003" },
|
||||
]),
|
||||
};
|
||||
for (const [name, text] of Object.entries(state)) put(join(hc, name), text);
|
||||
for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755);
|
||||
for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755);
|
||||
const copyRoot = join(root, HOME, '.local/share/mosaic-stack/copies', COPY);
|
||||
put(join(copyRoot, 'packages/cli/src/config.mjs'), `import fs from 'node:fs';export const loadSystem=()=>({dataRoot:fs.readFileSync(${JSON.stringify(join(hc,'data-root'))},'utf8').trim()});`);
|
||||
put(join(copyRoot, 'packages/webui/src/cli.mjs'), '// MOSAIC_TESTER_SOCKET_V1\n');
|
||||
put(join(hc, 'data-root'), `${HOME}/.mosaic-dev\n`);
|
||||
put(join(root, HOME, '.mosaic-dev/console/tester.sock'), 'stand-in socket metadata only');
|
||||
chmodSync(join(root, HOME, '.mosaic-dev/console'), 0o700);
|
||||
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
|
||||
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
|
||||
chmodSync(root, 0o755);
|
||||
return { dir, root, hc, bin, args };
|
||||
}
|
||||
|
||||
function run(h, extra = {}, argv = ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root]) {
|
||||
const r = spawnSync(SCRIPT, argv, {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc, ROOT_DIR: h.root, REAL_NODE: process.execPath, TMPDIR: h.dir, ...extra },
|
||||
});
|
||||
const lines = r.stdout.split("\n").filter(Boolean);
|
||||
// Each check line as { id, path, mark, evidence }.
|
||||
const checks = lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => {
|
||||
const [id, path, ...rest] = l.split(" ");
|
||||
const tail = rest.join(" ");
|
||||
const mark = MARKS.find((m) => tail === m || tail.startsWith(`${m} `));
|
||||
return { id, path, mark, evidence: tail.slice(mark?.length ?? 0).trim(), line: l };
|
||||
});
|
||||
return { status: r.status, out: r.stdout, err: r.stderr, lines, checks };
|
||||
}
|
||||
const calls = (h, name) => (existsSync(join(h.hc, "calls.log")) ? readFileSync(join(h.hc, "calls.log"), "utf8") : "")
|
||||
.split("\n").filter((l) => l === name || l.startsWith(`${name} `)).map((l) => l.slice(name.length + 1));
|
||||
const failed = (r) => r.checks.filter((c) => c.mark === "FAIL").map((c) => c.id);
|
||||
const marksOf = (r, id) => r.checks.filter((c) => c.id === id).map((c) => c.mark);
|
||||
const mustRun = (h, extra) => { const r = run(h, extra); assert.ok(r.status === 0 || r.status === 1, r.out + r.err); return r; };
|
||||
|
||||
test('row253 socket observation uses configured root, metadata and the Console peer without sending bytes', t=>{
|
||||
const h=host(t), data='/srv/mosaic/custom-data';
|
||||
put(join(h.hc,'data-root'),data+'\n');
|
||||
put(join(h.root,data,'console/tester.sock'),'metadata stand-in');
|
||||
const r=run(h);
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
assert.match(r.out,/P10 tester-socket-squat unnamed boundary=observed path=\/srv\/mosaic\/custom-data\/console\/tester.sock/);
|
||||
assert.match(r.out,/peer_pid=200 peer_uid=997/);
|
||||
assert.match(r.out,/hostile same-uid substitution remains/);
|
||||
assert.doesNotMatch(r.out,/accepted mos-1101/);
|
||||
assert.deepEqual(marksOf(r,'P8'),['closed']);
|
||||
});
|
||||
|
||||
test('row253 P10 refuses unsafe metadata, wrong peers and an unsafe Python without claiming closure',t=>{
|
||||
const faults={
|
||||
'parent-mode': h=>put(join(h.hc,'stat-parent'),'997 755 directory 9 10\n'),
|
||||
'parent-owner': h=>put(join(h.hc,'stat-parent'),'998 700 directory 9 10\n'),
|
||||
'socket-mode': h=>put(join(h.hc,'stat-socket'),'997 660 socket 9 11\n'),
|
||||
'socket-owner': h=>put(join(h.hc,'stat-socket'),'998 600 socket 9 11\n'),
|
||||
'socket-type': h=>put(join(h.hc,'stat-socket'),'997 600 regular file 9 11\n'),
|
||||
symlink:h=>{const p=join(h.root,HOME,'.mosaic-dev/console/tester.sock');rmSync(p);symlinkSync('/absent',p);},
|
||||
peer:h=>put(join(h.hc,'peer'),'200 998 997\n'),
|
||||
'peer-pid':h=>put(join(h.hc,'peer'),'555 997 997\n'),
|
||||
'python-mode':h=>chmodSync(join(h.root,'usr/bin/python3'),0o777),
|
||||
'python-missing':h=>rmSync(join(h.root,'usr/bin/python3')),
|
||||
'python-not-elf':h=>put(join(h.hc,'elf'),'23212f62\n'),
|
||||
'peer-malformed':h=>{put(join(h.hc,'peer-output'),'200 997 997 extra\n');put(join(h.hc,'peer-status'),'0');},
|
||||
'peer-error':h=>{put(join(h.hc,'peer-output'),'200 997 997\n');put(join(h.hc,'peer-status'),'1');},
|
||||
'peer-uid-fields':h=>proc(h.root,200,{ppid:1,uid:998,exe:NODE,comm:'node'}),
|
||||
'changed-inode':h=>put(join(h.hc,'peer-change'),'997 600 socket 9 99\n'),
|
||||
'changed-pid':h=>put(join(h.hc,'peer-pid-change'),'ActiveState=active\nUser=mosaic\nMainPID=201\n'),
|
||||
floor:h=>put(join(h.root,'proc/sys/net/ipv4/ip_unprivileged_port_start'),'700\n'),
|
||||
};
|
||||
for(const [name,fault] of Object.entries(faults)){
|
||||
const h=host(t);fault(h);const r=run(h);
|
||||
assert.equal(r.status,name==='peer-uid-fields'?1:0,`${name}: ${r.out}${r.err}`);
|
||||
assert.match(r.out,/P10 tester-socket-squat unnamed boundary=failed/,name);
|
||||
assert.doesNotMatch(r.out,/boundary=observed/,name);
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 acceptance rejects bad config and unsupported copies before talk',t=>{
|
||||
const faults={
|
||||
outside:h=>put(join(h.hc,'data-root'),'/srv/mosaic2/data\n'),
|
||||
dot:h=>put(join(h.hc,'data-root'),'/srv/mosaic/./data\n'),
|
||||
absent:h=>rmSync(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/cli/src/config.mjs')),
|
||||
unmarked:h=>put(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/webui/src/cli.mjs'),'// old copy'),
|
||||
schema:h=>put(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/cli/src/config.mjs'),'console.log(JSON.stringify({dataRoot:"/srv/mosaic/.mosaic-dev",socket:true,extra:1}));process.exit(0);'),
|
||||
failed:h=>put(join(h.root,HOME,'.local/share/mosaic-stack/copies',COPY,'packages/cli/src/config.mjs'),'console.log(JSON.stringify({dataRoot:"/srv/mosaic/.mosaic-dev",socket:true}));process.exit(1);'),
|
||||
};
|
||||
for(const [name,fault] of Object.entries(faults)){
|
||||
const h=host(t);fault(h);const r=run(h);
|
||||
assert.equal(r.status,2,`${name}: ${r.out}${r.err}`);assert.equal(r.out,'');
|
||||
assert.match(r.err,/tester (config query|path refuses)|support/);
|
||||
assert.deepEqual(calls(h,'mosaic'),[]);
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 stalled peer observation ends within its bound without leaving a reader',async t=>{
|
||||
const h=host(t);put(join(h.hc,'peer-hang'),'');
|
||||
const r=run(h);assert.equal(r.status,0,r.out+r.err);
|
||||
assert.match(r.out,/P10 tester-socket-squat unnamed boundary=failed reason=probe/);
|
||||
const pid=Number(readFileSync(join(h.hc,'peer-pid'),'utf8'));
|
||||
let state='gone';
|
||||
for(let n=0;n<50;n++){
|
||||
try{state=readFileSync(`/proc/${pid}/stat`,'utf8').split(') ')[1].split(' ')[0];}catch{state='gone';}
|
||||
if(state==='gone'||state==='Z')break;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
assert.ok(state==='gone'||state==='Z',`peer helper ${pid} still ${state}`);
|
||||
});
|
||||
|
||||
test('row253 P8 refuses both retired TCP and board listeners',t=>{
|
||||
for(const port of [7333,7331]){
|
||||
const h=host(t);put(join(h.hc,'ss'),SS+`LISTEN 0 511 127.0.0.1:${port} 0.0.0.0:* users:(("node",pid=200,fd=22))\n`);
|
||||
assert.deepEqual(marksOf(run(h),'P8'),['unnamed']);
|
||||
}
|
||||
});
|
||||
|
||||
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" }),
|
||||
F2: (h) => put(join(h.hc, "ss"), SS.replace("127.0.0.1:732 ", "0.0.0.0:732 ")),
|
||||
F3: (h) => put(join(h.hc, "talk-mode"), "noreply"),
|
||||
F4: (h) => put(join(h.hc, "precheck-rc"), "1"),
|
||||
F5: (h) => put(join(h.root, "proc", "302", "attr", "current"), "unconfined\n"),
|
||||
F6: (h) => put(join(h.hc, "claude-version"), "2.0.9 (Claude Code)\n"),
|
||||
F7: (h) => put(join(h.hc, "launches.json"), JSON.stringify([{ instance: "pm", live: false, business: "mosaic-stack", run: "r-1" }])),
|
||||
F8: (h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"),
|
||||
F9: (h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "0" } })),
|
||||
F10: (h) => put(join(h.hc, "node-version"), "v24.0.0\n"),
|
||||
F11: (h) => put(join(h.root, HOME, ".mosaic-dev", "discord", "binding.json"), "{}"),
|
||||
};
|
||||
|
||||
test("row 195 accept: every check passing exits 0, with each P mark and counts that match the lines", (t) => {
|
||||
const h = host(t);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 0, r.out + r.err);
|
||||
assert.deepEqual(r.checks.map((c) => c.id), ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11",
|
||||
"P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]);
|
||||
for (const c of r.checks.filter((x) => x.id.startsWith("F"))) assert.equal(c.mark, "pass", c.line);
|
||||
for (const [id, mark] of Object.entries(P_PASS)) assert.deepEqual(marksOf(r, id), [mark], id);
|
||||
const counts = r.lines.filter((l) => l.startsWith("count ")).map((l) => l.slice(6));
|
||||
assert.deepEqual(counts, MARKS.map((m) => `${m} ${r.checks.filter((c) => c.mark === m).length}`));
|
||||
assert.match(r.lines.at(-1), /^case1-accept: every F check passes/);
|
||||
assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m);
|
||||
assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m);
|
||||
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
|
||||
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
|
||||
});
|
||||
|
||||
test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => {
|
||||
for (const [id, fault] of Object.entries(F_FAULTS)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = mustRun(h);
|
||||
assert.equal(r.status, 1, `${id}: ${r.out}`);
|
||||
assert.deepEqual(failed(r), [id], `${id}: ${r.out}`);
|
||||
assert.equal(r.lines.at(-1), "case1-accept: an F check failed, so the row fails");
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F3 fails on exit 0 with no reply line, and its evidence takes talk's error", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "talk-mode"), "sentonly");
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F3"]);
|
||||
assert.match(r.out, /^F3 tester-reply FAIL talk exited 0 with 1 lines on stdout; last: sent ab12cd34 to chief$/m);
|
||||
h = host(t);
|
||||
put(join(h.hc, "talk-mode"), "noreply");
|
||||
r = mustRun(h);
|
||||
assert.match(r.out, /^F3 tester-reply FAIL talk exited 1 with 1 lines on stdout; last: no reply from chief within 300 s$/m);
|
||||
});
|
||||
|
||||
test("row 195 accept: F1 fails on a unit that isn't active or isn't User=mosaic", (t) => {
|
||||
for (const show of ["ActiveState=failed\nUser=mosaic\nMainPID=0\n", "ActiveState=active\nUser=root\nMainPID=100\n"]) {
|
||||
const h = host(t);
|
||||
put(join(h.hc, "show-mosaic-webui.service"), show);
|
||||
const r = mustRun(h);
|
||||
assert.equal(r.status, 1);
|
||||
assert.ok(failed(r).includes("F1"), r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F2 fails when healthz doesn't answer and when another pid holds the port", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "curl-rc"), "7");
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F2"]);
|
||||
assert.match(r.out, /^F2 console-loopback FAIL curl http:\/\/127\.0\.0\.1:732\/healthz exited 7$/m);
|
||||
h = host(t);
|
||||
put(join(h.hc, "ss"), SS.replace("pid=200,fd=21", "pid=555,fd=4"));
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F2"]);
|
||||
});
|
||||
|
||||
test("row 195 accept: F5 fails on unpriv_bwrap (enforce), on complain and on a trailing space", (t) => {
|
||||
for (const label of ["unpriv_bwrap (enforce)\n", "bwrap//&unpriv_bwrap (complain)\n", `${CHILD} \n`]) {
|
||||
const h = host(t);
|
||||
put(join(h.root, "proc", "302", "attr", "current"), label);
|
||||
const r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F5"], `${JSON.stringify(label)}: ${r.out}`);
|
||||
}
|
||||
const h = host(t);
|
||||
put(join(h.root, "proc", "300", "attr", "current"), `${OUTER} \n`);
|
||||
assert.deepEqual(failed(mustRun(h)), ["F5"]);
|
||||
});
|
||||
|
||||
test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alone", (t) => {
|
||||
let h = host(t);
|
||||
proc(h.root, 300, { ppid: 100, uid: 1001, exe: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` });
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F5"]);
|
||||
assert.match(r.out, /^F5 bwrap-label FAIL no \/usr\/bin\/bwrap process of mosaic/m);
|
||||
h = host(t);
|
||||
proc(h.root, 302, { ppid: 301, argv0: "/proc/self/fd/3", comm: "claude", label: `${CHILD}\n` });
|
||||
assert.deepEqual(failed(mustRun(h)), []);
|
||||
});
|
||||
|
||||
test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "claude-version"), "");
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F6"]);
|
||||
h = host(t);
|
||||
const other = CLAUDE.replace(`claude-${CLAUDE_PIN}`, "claude-2.0.9");
|
||||
put(h.args, ARGS.map((a) => (a === CLAUDE ? other : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F6"]);
|
||||
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m);
|
||||
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
|
||||
h = host(t);
|
||||
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
|
||||
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F6"], r.out);
|
||||
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/m);
|
||||
});
|
||||
|
||||
test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => {
|
||||
const two = [{ instance: "pm", live: true, business: "mosaic-stack", run: "a" }, { instance: "pm", live: true, business: "mosaic-stack", run: "b" }];
|
||||
for (const [file, text] of [["launches.json", JSON.stringify(two)], ["launches.json", JSON.stringify([{ instance: "pm", live: true, business: "other", run: "a" }])]]) {
|
||||
const h = host(t);
|
||||
put(join(h.hc, file), text);
|
||||
assert.deepEqual(failed(mustRun(h)), ["F7"]);
|
||||
}
|
||||
const h = host(t);
|
||||
put(join(h.root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack" }));
|
||||
const r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F7"]);
|
||||
assert.match(r.out, /^F7 pm-live FAIL the business file has no launch\.by$/m);
|
||||
});
|
||||
|
||||
test("row 195 accept: F8 fails on 0640, on another owner and on a symlink, and never prints the credential", (t) => {
|
||||
for (const fault of [
|
||||
(h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"),
|
||||
(h) => put(join(h.hc, "stat-cred"), "root 600 regular file\n"),
|
||||
(h) => { const f = join(h.root, CRED); rmSync(f); put(`${f}.real`, `X=${CRED_VALUE}\n`, 0o600); symlinkSync(`${f}.real`, f); },
|
||||
]) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F8"], r.out);
|
||||
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F9 fails on another owner, a group-writable mode and a number for the flag", (t) => {
|
||||
for (const fault of [
|
||||
(h) => put(join(h.hc, "stat-settings"), "root:root 664 regular file\n"),
|
||||
(h) => put(join(h.hc, "stat-settings"), "mosaic:root 644 regular file\n"),
|
||||
(h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: 1 } })),
|
||||
]) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
assert.deepEqual(failed(mustRun(h)), ["F9"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
|
||||
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
|
||||
let h = host(t);
|
||||
const other = NODE.replace(NODE_PIN, "v24.0.9");
|
||||
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
|
||||
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
|
||||
// root doesn't run it.
|
||||
for (const [fault, bad] of [
|
||||
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
|
||||
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
|
||||
]) {
|
||||
h = host(t);
|
||||
fault(h);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9"], r.out);
|
||||
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}");
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F11"]);
|
||||
assert.match(r.out, /^F11 no-discord FAIL \/srv\/mosaic\/\.mosaic-dev\/discord holds bindings: chief\.json$/m);
|
||||
h = host(t);
|
||||
mkdirSync(join(h.root, HOME, ".mosaic-dev", "discord"), { recursive: true });
|
||||
assert.deepEqual(failed(mustRun(h)), []);
|
||||
});
|
||||
|
||||
// Each P check in its unnamed mark, with the line count each fault makes; none changes the exit.
|
||||
const P_FAULTS = {
|
||||
P1: [1, (h) => put(join(h.hc, "sudo-l-mosaic"), "User mosaic may run the following commands on dev-53:\n")],
|
||||
P2: [1, (h) => put(join(h.root, HOME, ".ssh", "authorized_keys"), "ssh-ed25519 AAAA test\n")],
|
||||
P5: [1, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("docker:x:999:", "docker:x:999:jarvis"))],
|
||||
P6: [2, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("sudo:x:27:localadmin", "sudo:x:27:localadmin,bob").replace("lxd:x:998:", "lxd:x:998:carol"))],
|
||||
P7: [1, (h) => put(join(h.root, "etc", "sudoers.d", "50-mosaic"), "mosaic ALL=(root) NOPASSWD: /usr/bin/systemctl\n")],
|
||||
P8: [1, (h) => put(join(h.hc, "ss"), `${SS}LISTEN 0 511 0.0.0.0:7331 0.0.0.0:* users:(("node",pid=200,fd=23))\n`)],
|
||||
P9: [3, (h) => { put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 2 ", "FAIL 2 ").replace("ok 5 ", "FAIL 5 ")); put(join(h.hc, "jarvis-bwrap-rc"), "0"); }],
|
||||
P10: [1, (h) => put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "700\n")],
|
||||
P11: [1, (h) => put(join(h.hc, "show-mosaic-hostcheck.service"), "[email protected] multi-user.target\n")],
|
||||
};
|
||||
|
||||
test("row 195 accept: each P check reports unnamed on its fault, and the exit stays 0", (t) => {
|
||||
for (const [id, [n, fault]] of Object.entries(P_FAULTS)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 0, `${id}: ${r.out}`);
|
||||
assert.deepEqual(marksOf(r, id), Array(n).fill("unnamed"), `${id}: ${r.out}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: P2 is unnamed with a supplementary group, and P5 names docker", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.root, "etc", "group"), GROUP.replace("lxd:x:998:", "lxd:x:998:mosaic"));
|
||||
let r = run(h);
|
||||
assert.equal(r.status, 0);
|
||||
assert.deepEqual(marksOf(r, "P2"), ["unnamed"]);
|
||||
assert.match(r.out, /^P2 mosaic-groups-keys unnamed id -G is '997 998'; authorized_keys is absent$/m);
|
||||
h = host(t);
|
||||
P_FAULTS.P5[1](h);
|
||||
r = run(h);
|
||||
assert.match(r.out, /^P5 jarvis-reach unnamed jarvis is in docker$/m);
|
||||
assert.match(r.out, /^P6 group-docker unnamed jarvis is a member of docker$/m);
|
||||
});
|
||||
|
||||
test("row 195 accept: P5 is closed with no jarvis account, and P2 with an empty authorized_keys", (t) => {
|
||||
const h = host(t);
|
||||
put(join(h.root, "etc", "passwd"), PASSWD.replace(/^jarvis:.*\n/m, ""));
|
||||
put(join(h.root, HOME, ".ssh", "authorized_keys"), "");
|
||||
const r = run(h);
|
||||
assert.deepEqual(marksOf(r, "P5"), ["closed"]);
|
||||
assert.deepEqual(marksOf(r, "P2"), ["closed"]);
|
||||
});
|
||||
|
||||
test("row 195 accept: P9 is unnamed once for each item missing", (t) => {
|
||||
const items = {
|
||||
hostcheck: (h) => put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 3 ", "FAIL 3 ")),
|
||||
jarvis: (h) => { put(join(h.hc, "jarvis-bwrap-rc"), "0"); put(join(h.hc, "jarvis-bwrap"), ""); },
|
||||
member: (h) => put(join(h.root, "etc", "group"), GROUP.replace("mosaic:x:997:", "mosaic:x:997:jarvis")),
|
||||
gid: (h) => put(join(h.root, "etc", "passwd"), `${PASSWD}hermes:x:1002:997::/home/hermes:/bin/bash\n`),
|
||||
};
|
||||
for (const [name, fault] of Object.entries(items)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 0);
|
||||
assert.deepEqual(marksOf(r, "P9"), ["unnamed"], `${name}: ${r.out}`);
|
||||
}
|
||||
const h = host(t);
|
||||
put(join(h.hc, "hostcheck-out"), "");
|
||||
assert.deepEqual(marksOf(run(h), "P9"), Array(6).fill("unnamed"));
|
||||
});
|
||||
|
||||
test("row253 accept: P10 fails its observation with another peer; P11 with the unit disabled", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "peer"), "555 997 997\n");
|
||||
let r = run(h);
|
||||
assert.equal(r.status, 0);
|
||||
assert.deepEqual(marksOf(r, "P10"), ["unnamed"]);
|
||||
assert.match(r.out, /P10 tester-socket-squat unnamed boundary=failed reason=peer/);
|
||||
h = host(t);
|
||||
put(join(h.hc, "enabled-mosaic-hostcheck.service"), "disabled\n");
|
||||
r = run(h);
|
||||
assert.deepEqual(marksOf(r, "P11"), ["unnamed"]);
|
||||
});
|
||||
|
||||
test("row 195 accept: every P check unnamed together still exits 0", (t) => {
|
||||
const h = host(t);
|
||||
for (const [, fault] of Object.values(P_FAULTS)) fault(h);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 0, r.out + r.err);
|
||||
assert.deepEqual(failed(r), []);
|
||||
for (const id of ["P1", "P2", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]) assert.ok(marksOf(r, id).every((m) => m === "unnamed"), id);
|
||||
assert.match(r.lines.at(-1), /^case1-accept: every F check passes/);
|
||||
});
|
||||
|
||||
test("row 195 accept: a bad args file refuses with exit 2 before any check, each for its own reason", (t) => {
|
||||
const bad = {
|
||||
"group write": [(h) => chmodSync(h.args, 0o664), /dev\.args must be \S+'s with no group or other write \(it is uid \d+, mode 664\)$/],
|
||||
symlink: [(h) => { put(`${h.args}.real`, ARGS.join("\n") + "\n", 0o644); rmSync(h.args); symlinkSync(`${h.args}.real`, h.args); }, /no regular file \/etc\/mosaic-stack\/dev\.args$/],
|
||||
"unknown flag": [(h) => put(h.args, ["--send", ...ARGS].join("\n") + "\n", 0o644), /has --send before --, which case2-unit\.sh does not take$/],
|
||||
"two copies": [(h) => put(h.args, ["--copy", COPY, ...ARGS].join("\n") + "\n", 0o644), /needs exactly one --copy before --, not 2$/],
|
||||
"library profile": [(h) => put(h.args, ARGS.map((a) => (a === "factory" ? "library" : a)).join("\n") + "\n", 0o644), /has --profile 'library', not factory$/],
|
||||
"another business": [(h) => put(h.args, ARGS.map((a, i) => (a === "mosaic-stack" && i < 14 ? "acme" : a)).join("\n") + "\n", 0o644), /is for business acme, not mosaic-stack$/],
|
||||
"no tester socket": [(h) => put(h.args, ARGS.slice(0, -1).join("\n") + "\n", 0o644), /needs one bare --tester-socket after --$/],
|
||||
"retired TCP": [(h) => put(h.args, [...ARGS.slice(0,-1),'--tester-port','7333'].join('\n')+'\n',0o644), /case1-accept.sh retires tester TCP; use --tester-socket$/],
|
||||
"socket value": [(h) => put(h.args, [...ARGS,'7333'].join('\n')+'\n',0o644), /unknown serve flag 7333$/],
|
||||
"duplicate socket": [(h) => put(h.args, [...ARGS,'--tester-socket'].join('\n')+'\n',0o644), /needs one bare --tester-socket after --$/],
|
||||
"short copy": [(h) => put(h.args, ARGS.map((a) => (a === COPY ? "abc123" : a)).join("\n") + "\n", 0o644), /lacks a plain --user, --home, --business, --node, --claude or --copy$/],
|
||||
"no file": [(h) => rmSync(h.args), /no regular file \/etc\/mosaic-stack\/dev\.args$/],
|
||||
};
|
||||
for (const [name, [fault, why]] of Object.entries(bad)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`);
|
||||
assert.match(r.err.trimEnd(), /^case1-accept: refusing: /, name);
|
||||
assert.match(r.err.trimEnd(), why, name);
|
||||
assert.equal(r.out, "", name);
|
||||
assert.ok(!existsSync(join(h.hc, "calls.log")), name);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: usage errors and a run without --root as a user exit 2", (t) => {
|
||||
const h = host(t);
|
||||
let r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--node-pin", NODE_PIN, "--root", h.root]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.err, /^usage: /);
|
||||
r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", "24.1.0", "--root", h.root]);
|
||||
assert.equal(r.status, 2);
|
||||
r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.err, "case1-accept: refusing: run it as root\n");
|
||||
assert.ok(!existsSync(join(h.hc, "calls.log")));
|
||||
});
|
||||
|
||||
test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=<node dir>:...", (t) => {
|
||||
const h = host(t);
|
||||
const r = run(h);
|
||||
assert.equal(r.status, 0, r.out + r.err);
|
||||
const sudo = calls(h, "sudo");
|
||||
const asMosaic = sudo.filter((a) => a.startsWith("-u mosaic"));
|
||||
const isolated = asMosaic.filter(a=>a.startsWith('-u mosaic -H env -i '));
|
||||
assert.equal(isolated.length, 2);
|
||||
for(const a of isolated) assert.ok(a.startsWith(`-u mosaic -H env -i HOME=${HOME} PATH=/opt/mosaic-stack/node-${NODE_PIN}/bin:/usr/local/bin:/usr/bin:/bin `),a);
|
||||
for (const a of asMosaic.filter(a=>!isolated.includes(a))) assert.ok(a.startsWith(`${AS_MOSAIC} /`), a);
|
||||
const cmds = asMosaic.filter(a=>!isolated.includes(a)).map((a) => a.slice(AS_MOSAIC.length + 1).split(" ")[0]);
|
||||
assert.deepEqual([...new Set(cmds)].sort(), [CLI, PRECHECK, CLAUDE, NODE].sort());
|
||||
assert.deepEqual(sudo.filter((a) => !a.startsWith("-u mosaic")).sort(),
|
||||
["-l -U localadmin", "-l -U mosaic", "-u jarvis /usr/bin/bwrap --version"]);
|
||||
const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk "));
|
||||
assert.equal(talk.length, 1);
|
||||
assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/);
|
||||
assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"]);
|
||||
});
|
||||
|
||||
test("row 195 accept: the host check runs with --check and never --enforce", (t) => {
|
||||
const h = host(t);
|
||||
run(h);
|
||||
assert.deepEqual(calls(h, "hostcheck"), ["--check"]);
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
#!/usr/bin/env bash
|
||||
# Case 2 row C2-13a (design 9.3, 10 step 2c): render and install the two
|
||||
# system units a hub runs as, [email protected] (enabled as
|
||||
# mosaic-bus@<business>.service) and mosaic-webui.service, from the existing
|
||||
# templates, which it doesn't change.
|
||||
#
|
||||
# case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
|
||||
# [--profile factory|library] [--pm] [--claude PATH]
|
||||
# -- SERVE FLAGS
|
||||
#
|
||||
# Root runs it from a root-owned checkout of the reviewed commit outside
|
||||
# <home>, never from the release, because <user> can write the release. It
|
||||
# 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. 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 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
|
||||
# but the copy id, and both must name one copy, so a re-pin or rollback
|
||||
# re-renders the pair together. A unit that was already running stays on its
|
||||
# old copy, since `systemctl start` leaves it alone; the script says so and
|
||||
# that the bus and Console must restart together, and restarts nothing.
|
||||
#
|
||||
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
|
||||
# CASE2_UNIT_DIR the unit directory, default /etc/systemd/system;
|
||||
# the root and root-owned checks apply only there
|
||||
# CASE2_SYSTEMCTL the systemctl command, default systemctl
|
||||
# CASE2_GETENT the getent command, default getent
|
||||
# CASE2_BUS_TEMPLATE default packages/cli/systemd/mosaic-bus.service.in
|
||||
# CASE2_WEBUI_TEMPLATE default packages/webui/systemd/mosaic-webui.service.in
|
||||
#
|
||||
# Exit 0 done, 1 refused, 4 usage.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEFAULT_UNIT_DIR=/etc/systemd/system
|
||||
UNIT_DIR="${CASE2_UNIT_DIR:-$DEFAULT_UNIT_DIR}"
|
||||
SYSTEMCTL="${CASE2_SYSTEMCTL:-systemctl}"
|
||||
GETENT="${CASE2_GETENT:-getent}"
|
||||
BUS_TEMPLATE="${CASE2_BUS_TEMPLATE:-$REPO/packages/cli/systemd/mosaic-bus.service.in}"
|
||||
WEBUI_TEMPLATE="${CASE2_WEBUI_TEMPLATE:-$REPO/packages/webui/systemd/mosaic-webui.service.in}"
|
||||
[email protected]
|
||||
WEBUI_UNIT=mosaic-webui.service
|
||||
|
||||
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
die() { echo "case2-unit: $*" >&2; exit 1; }
|
||||
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
|
||||
plain() { case "$1" in *[!A-Za-z0-9/._+-]*) return 1 ;; esac; }
|
||||
abs_plain() {
|
||||
case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac
|
||||
plain "$1" || die "$2 contains a character the unit cannot carry: $1"
|
||||
case "$1/" in */./*|*/../*|?*//*) die "$2 must be a normalized path: $1" ;; esac
|
||||
[ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
|
||||
}
|
||||
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PM=0 CLAUDE='' CLAUDE_SET=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--profile|--user|--home|--business|--copy|--node|--claude)
|
||||
[ $# -ge 2 ] || usage
|
||||
case "$1" in
|
||||
--profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
|
||||
--business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
|
||||
--claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--pm) PM=1; shift ;;
|
||||
--) shift; break ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
SERVE=("$@")
|
||||
[ -n "$USER_NAME" ] && [ -n "$HOME_DIR" ] && [ -n "$BUSINESS" ] && [ -n "$COPY_ID" ] && [ -n "$NODE" ] || usage
|
||||
case "$PROFILE" in factory|library) ;; *) die "--profile must be factory or library: $PROFILE" ;; esac
|
||||
if [ "$PROFILE" = library ]; then
|
||||
[ "$PM" = 0 ] || die "the library profile refuses --pm"
|
||||
[ "$CLAUDE_SET" = 0 ] || die "the library profile refuses --claude"
|
||||
fi
|
||||
|
||||
if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
|
||||
[ "$(id -u)" = 0 ] || die "writing $DEFAULT_UNIT_DIR needs root"
|
||||
fi
|
||||
|
||||
# The user, its uid, primary group and home, from the passwd database.
|
||||
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
|
||||
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
|
||||
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
|
||||
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
|
||||
abs_plain "$HOME_DIR" '--home'
|
||||
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
|
||||
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
|
||||
GROUP="${GR%%:*}"
|
||||
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"
|
||||
|
||||
case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
case "$BUSINESS" in *[!a-z0-9._-]*) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
[ "${#BUSINESS}" -le 64 ] || die "--business must be a business id: $BUSINESS"
|
||||
|
||||
# The copy: copy_root's rule (scripts/stack-copy.sh:8, :22), under <home>.
|
||||
COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
|
||||
[[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character lower-case commit id: $COPY_ID"
|
||||
COPY="$COPIES/$COPY_ID"
|
||||
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
|
||||
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"
|
||||
|
||||
# Owned by root and writable by no one else, the file and every directory above it.
|
||||
root_only() {
|
||||
local p="$1" what="$2" owner mode
|
||||
while :; do
|
||||
owner="$(stat -c '%u' -- "$p")" || die "cannot stat $p"
|
||||
mode="$(stat -c '%a' -- "$p")" || die "cannot stat $p"
|
||||
[ "$owner" = 0 ] || die "$what: $p is not owned by root"
|
||||
(( (8#$mode & 8#022) == 0 )) || die "$what: $p is writable by its group or others"
|
||||
[ "$p" = / ] && break
|
||||
p="$(dirname -- "$p")"
|
||||
done
|
||||
}
|
||||
|
||||
# --node: an absolute path to a root-owned regular file outside <home>.
|
||||
abs_plain "$NODE" '--node'
|
||||
case "$NODE/" in "$HOME_DIR"/*) die "--node must be outside $HOME_DIR: $NODE" ;; esac
|
||||
[ ! -L "$NODE" ] || die "--node must not be a symlink; pass its target: $NODE"
|
||||
[ -f "$NODE" ] && [ -x "$NODE" ] || die "--node must be an executable regular file: $NODE"
|
||||
root_only "$NODE" '--node'
|
||||
|
||||
# --claude follows the --node rule, so nothing running as <user> can replace
|
||||
# the binary the bus runs.
|
||||
if [ "$CLAUDE_SET" = 1 ]; then
|
||||
abs_plain "$CLAUDE" '--claude'
|
||||
case "$CLAUDE/" in "$HOME_DIR"/*) die "--claude must be outside $HOME_DIR: $CLAUDE" ;; esac
|
||||
[ ! -L "$CLAUDE" ] || die "--claude must not be a symlink; pass its target: $CLAUDE"
|
||||
[ -f "$CLAUDE" ] && [ -x "$CLAUDE" ] || die "--claude must be an executable regular file: $CLAUDE"
|
||||
root_only "$CLAUDE" '--claude'
|
||||
fi
|
||||
|
||||
# The checkout the templates come from, when installing for real.
|
||||
if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
|
||||
case "$REPO/" in "$HOME_DIR"/*) die "run this from a root-owned checkout outside $HOME_DIR, not $REPO" ;; esac
|
||||
root_only "$BUS_TEMPLATE" 'bus template'
|
||||
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
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
[ -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=""
|
||||
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
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$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=$!
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; 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_socket=0
|
||||
local i=0 n=${#SERVE[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
name="${SERVE[$i]}"
|
||||
case "$name" 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)" ;;
|
||||
--port|--business) ;;
|
||||
--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send)
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
|
||||
*) die "unknown serve flag: $name" ;;
|
||||
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))]}"
|
||||
case "$name" in
|
||||
--port) port="$value"
|
||||
[[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
|
||||
--business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
|
||||
--public-host) public_host="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
|
||||
--auth-header) auth_header="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $value" ;; esac ;;
|
||||
--allow-user) allow_user="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $value" ;; esac ;;
|
||||
--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" ;;
|
||||
esac
|
||||
flags="$flags $name $value"
|
||||
i=$((i + 2))
|
||||
done
|
||||
local required=(--port --business)
|
||||
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --send --proof-header --proof-file)
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
|
||||
done
|
||||
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"
|
||||
fi
|
||||
case "${proof_header,,}" in host|origin|x-mosaic-tester|"${auth_header,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
|
||||
# The proof file: metadata only, never opened (design 9.3).
|
||||
local secrets="$HOME_DIR/.config/mosaic-dev/secrets" real owner mode
|
||||
abs_plain "$proof_file" 'proxy proof file'
|
||||
[ ! -L "$proof_file" ] || die "proxy proof file must not be a symlink: $proof_file"
|
||||
[ -f "$proof_file" ] || die "proxy proof file must be a regular file: $proof_file"
|
||||
real="$(realpath -e -- "$proof_file")" || die "cannot resolve proxy proof file: $proof_file"
|
||||
[ "$real" = "$proof_file" ] || die "proxy proof file path must have no symlink in it: $proof_file"
|
||||
case "$proof_file" in "$secrets"/*) ;; *) die "proxy proof file must be under $secrets, outside the data root: $proof_file" ;; esac
|
||||
owner="$(stat -c '%u' -- "$proof_file")" || die "cannot stat proxy proof file"
|
||||
mode="$(stat -c '%a' -- "$proof_file")" || die "cannot stat proxy proof file"
|
||||
[ "$owner" = "$USER_UID" ] || die "proxy proof file must be owned by $USER_NAME (uid $USER_UID): $proof_file"
|
||||
[ "$mode" = 600 ] || [ "$mode" = 400 ] || die "proxy proof file must be mode 0600 or 0400, not $mode: $proof_file"
|
||||
echo "${flags# }"
|
||||
}
|
||||
# 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
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes"
|
||||
CAPS="AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE"
|
||||
# The default instance, as scripts/instance.sh:85-107 renders it: no
|
||||
# MOSAIC_CONFIG line, and the session env under %h, which becomes <home>.
|
||||
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
|
||||
ENV_FILE="-$HOME_DIR/.config/mosaic-dev/secrets/claude-session.env"
|
||||
PM_FLAGS=''
|
||||
[ "$PM" = 0 ] || PM_FLAGS=' --pm'
|
||||
CLAUDE_ENV=''
|
||||
[ "$CLAUDE_SET" = 0 ] || CLAUDE_ENV="Environment=MOSAIC_CLAUDE_BIN=$CLAUDE"
|
||||
|
||||
# One unit from its template. kind is bus or webui.
|
||||
render() {
|
||||
local kind="$1" template="$2" text tokens
|
||||
[ -f "$template" ] && [ ! -L "$template" ] || die "template missing or not a regular file: $template"
|
||||
text="$(sed -e "s|@REPO@|$COPY|g" -e "s|@PATH@|$UNIT_PATH|g" -e "s|@NODE@|$NODE|g" \
|
||||
-e "s|@PM_FLAGS@|$PM_FLAGS|g" -e "s|^@CLAUDE_ENV@\$|$CLAUDE_ENV|" \
|
||||
-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g" \
|
||||
-e "s|%h|$HOME_DIR|g" -- "$template")"
|
||||
# Checked before @FLAGS@ goes in, since an allowed user may hold @.
|
||||
tokens="$(printf '%s\n' "$text" | grep -oE '@[A-Z_]+@' || true)"
|
||||
if [ "$kind" = webui ]; then
|
||||
[ "$tokens" = @FLAGS@ ] || die "$template: a token other than one @FLAGS@ is left after the render: $(echo $tokens)"
|
||||
text="${text//@FLAGS@/$FLAGS}"
|
||||
else
|
||||
[ -z "$tokens" ] || die "$template: a token is left after the render: $(echo $tokens)"
|
||||
fi
|
||||
if printf '%s\n' "$text" | grep -q '%h'; then die "$template: %h is left after the render"; fi
|
||||
local add="User=$USER_NAME\nGroup=$GROUP\n${HARDENING//$'\n'/\\n}\n"
|
||||
[ "$kind" = bus ] || add="$add${CAPS//$'\n'/\\n}\n"
|
||||
# The added lines go at the end of [Service], before its trailing blank
|
||||
# lines. In [Install], WantedBy=multi-user.target replaces
|
||||
# WantedBy=default.target in both units, so both start at boot (Q189).
|
||||
printf '%s\n' "$text" | awk -v add="$add" '
|
||||
function blanks_out() { for (; blanks > 0; blanks--) print "" }
|
||||
/^\[.*\]$/ {
|
||||
if (sec == "[Service]") printf "%s", add
|
||||
sec = $0
|
||||
blanks_out(); print; next
|
||||
}
|
||||
$0 == "" { blanks++; next }
|
||||
{ blanks_out() }
|
||||
sec == "[Install]" && $0 == "WantedBy=default.target" { $0 = "WantedBy=multi-user.target" }
|
||||
{ print }
|
||||
END { if (sec == "[Service]") printf "%s", add; blanks_out() }'
|
||||
}
|
||||
|
||||
# The unit's settings as section<TAB>key<TAB>value, one per line, keys
|
||||
# trimmed as systemd trims them. A continued line refuses, so no setting
|
||||
# hides in one.
|
||||
settings() {
|
||||
awk '
|
||||
{ sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }
|
||||
$0 == "" || /^[#;]/ { next }
|
||||
/\\$/ { print "CONTINUED"; exit }
|
||||
/^\[.*\]$/ { sec = $0; next }
|
||||
{
|
||||
i = index($0, "="); if (i == 0) { print "NOKEY\t" $0; next }
|
||||
k = substr($0, 1, i - 1); v = substr($0, i + 1)
|
||||
sub(/[ \t]+$/, "", k); sub(/^[ \t]+/, "", v)
|
||||
print sec "\t" k "\t" v
|
||||
}'
|
||||
}
|
||||
|
||||
# Refuses unless one unit's fixed lines hold. what names the unit in messages,
|
||||
# copy is the copy directory it should run from.
|
||||
check_unit() {
|
||||
local kind="$1" text="$2" what="$3" copy="$4" s
|
||||
s="$(printf '%s\n' "$text" | settings)"
|
||||
! printf '%s\n' "$s" | grep -qx CONTINUED || die "$what: a line continues onto the next"
|
||||
! printf '%s\n' "$s" | grep -q '^NOKEY' || die "$what: a line is neither a section, a comment nor a setting"
|
||||
# %h in a system unit is root's home, not <user>'s.
|
||||
! printf '%s\n' "$text" | grep -q '%h' || die "$what: %h is left; a system unit reads it as root's home"
|
||||
local key
|
||||
for key in RestrictNamespaces PrivateUsers SystemCallFilter PermissionsStartOnly; do
|
||||
! printf '%s\n' "$s" | awk -F'\t' -v k="$key" '$2 == k { found = 1 } END { exit !found }' \
|
||||
|| die "$what: refusing $key=, which the hub's units must not hold"
|
||||
done
|
||||
# An Exec*= value whose leading prefix characters hold + or ! runs with
|
||||
# full privileges, whatever User= says.
|
||||
local bad
|
||||
bad="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^Exec/ { p = $3; sub(/[^-@:+!].*$/, "", p); if (p ~ /[+!]/) print $2 "=" $3 }')"
|
||||
[ -z "$bad" ] || die "$what: refusing an Exec line that runs with full privileges: $bad"
|
||||
# The fixed lines, each exactly once, in [Service].
|
||||
one() {
|
||||
local k="$1" v="$2" got
|
||||
got="$(printf '%s\n' "$s" | awk -F'\t' -v k="$k" '$2 == k { print $1 "\t" $3 }')"
|
||||
[ "$got" = "[Service] $v" ] || die "$what: $k= must be exactly once in [Service], as $k=$v"
|
||||
}
|
||||
none() {
|
||||
! printf '%s\n' "$s" | awk -F'\t' -v k="$1" '$2 == k { found = 1 } END { exit !found }' || die "$what: refusing $1=, which this unit must not hold"
|
||||
}
|
||||
one User "$USER_NAME"
|
||||
one Group "$GROUP"
|
||||
one WorkingDirectory "$copy"
|
||||
# Environment= lines: PATH, and MOSAIC_CLAUDE_BIN only with --claude.
|
||||
local envs want_envs="[Service] PATH=$UNIT_PATH"
|
||||
[ "$kind" = webui ] || [ -z "$CLAUDE_ENV" ] || want_envs="$want_envs
|
||||
[Service] MOSAIC_CLAUDE_BIN=$CLAUDE"
|
||||
envs="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "Environment" { print $1 "\t" $3 }')"
|
||||
[ "$envs" = "$want_envs" ] || die "$what: the Environment= lines must be exactly: $(printf '%s' "$want_envs" | cut -f2 | tr '\n' ' ')"
|
||||
one NoNewPrivileges yes
|
||||
one ProtectSystem strict
|
||||
one ReadWritePaths "$HOME_DIR"
|
||||
one ProtectHome yes
|
||||
one PrivateTmp yes
|
||||
local order
|
||||
order="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)$/ { printf "%s ", $2 }')"
|
||||
[ "$order" = "ProtectSystem ReadWritePaths ProtectHome PrivateTmp " ] || die "$what: the hardening lines must be in the order ProtectSystem, ReadWritePaths, ProtectHome, PrivateTmp"
|
||||
local wanted
|
||||
wanted="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "WantedBy" { print $1 "\t" $3 }')"
|
||||
[ "$wanted" = "[Install] multi-user.target" ] || die "$what: WantedBy= must be exactly once, as WantedBy=multi-user.target"
|
||||
if [ "$kind" = bus ]; then
|
||||
one ExecStart "$copy/scripts/mosaic bus start %i$PM_FLAGS"
|
||||
one EnvironmentFile "$ENV_FILE"
|
||||
none AmbientCapabilities
|
||||
none CapabilityBoundingSet
|
||||
else
|
||||
one ExecStart "$NODE $copy/packages/webui/src/cli.mjs serve $FLAGS"
|
||||
none EnvironmentFile
|
||||
one AmbientCapabilities CAP_NET_BIND_SERVICE
|
||||
one CapabilityBoundingSet CAP_NET_BIND_SERVICE
|
||||
fi
|
||||
}
|
||||
|
||||
BUS_TEXT="$(render bus "$BUS_TEMPLATE")" || exit 1
|
||||
WEBUI_TEXT="$(render webui "$WEBUI_TEMPLATE")" || exit 1
|
||||
check_unit bus "$BUS_TEXT" "rendered $BUS_UNIT" "$COPY"
|
||||
check_unit webui "$WEBUI_TEXT" "rendered $WEBUI_UNIT" "$COPY"
|
||||
|
||||
# A re-install: each installed unit passes the same checks, both name one
|
||||
# copy, and each equals the render once its copy id is the new one.
|
||||
[ -d "$UNIT_DIR" ] && [ ! -L "$UNIT_DIR" ] || die "no unit directory $UNIT_DIR"
|
||||
installed_ids=''
|
||||
for pair in "bus:$BUS_UNIT:BUS_TEXT" "webui:$WEBUI_UNIT:WEBUI_TEXT"; do
|
||||
IFS=: read -r kind name var <<<"$pair"
|
||||
path="$UNIT_DIR/$name"
|
||||
[ ! -L "$path" ] || die "refusing: $path is a symlink"
|
||||
[ -e "$path" ] || continue
|
||||
[ -f "$path" ] || die "refusing: $path is not a regular file"
|
||||
text="$(cat -- "$path")"
|
||||
old="$(printf '%s\n' "$text" | settings | awk -F'\t' -v c="$COPIES/" '$2 == "WorkingDirectory" && index($3, c) == 1 { print substr($3, length(c) + 1) }')"
|
||||
[[ "$old" =~ ^[0-9a-f]{40}$ ]] || die "installed $path names no copy under $COPIES"
|
||||
check_unit "$kind" "$text" "installed $path" "$COPIES/$old"
|
||||
[ "$text" = "$(printf '%s\n' "${!var}" | sed "s|$COPY|$COPIES/$old|g")" ] \
|
||||
|| die "installed $path differs from the render in more than the copy id; the node path, serve flags, --pm and --claude must equal the installed ones"
|
||||
installed_ids="$installed_ids $old"
|
||||
done
|
||||
read -r -a ids <<<"$installed_ids"
|
||||
[ "${#ids[@]}" -lt 2 ] || [ "${ids[0]}" = "${ids[1]}" ] || die "the installed units name different copies, ${ids[0]} and ${ids[1]}"
|
||||
|
||||
# `systemctl start` leaves a running unit on the copy it started from, so
|
||||
# note which units run before the write (row 195 owns the restart).
|
||||
BUS_NAME="mosaic-bus@$BUSINESS.service"
|
||||
running=''
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
if "$SYSTEMCTL" is-active --quiet "$name"; then running="$running $name"; fi
|
||||
done
|
||||
|
||||
# Write each unit to a temp file in the unit directory, then rename.
|
||||
write_unit() {
|
||||
local name="$1" text="$2" tmp
|
||||
tmp="$(mktemp "$UNIT_DIR/.case2-unit.XXXXXX")"
|
||||
printf '%s\n' "$text" >"$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
[ "$(id -u)" != 0 ] || chown root:root "$tmp"
|
||||
mv -fT -- "$tmp" "$UNIT_DIR/$name"
|
||||
}
|
||||
write_unit "$BUS_UNIT" "$BUS_TEXT"
|
||||
write_unit "$WEBUI_UNIT" "$WEBUI_TEXT"
|
||||
# Enable both, then start the bus, then the Console (design 10 step 2c).
|
||||
# serve calls the bus per request, so the units carry no After= or Wants=.
|
||||
"$SYSTEMCTL" daemon-reload || die "systemctl daemon-reload failed; the units are written, nothing is enabled or started"
|
||||
"$SYSTEMCTL" enable "$BUS_NAME" "$WEBUI_UNIT" || die "systemctl enable $BUS_NAME $WEBUI_UNIT failed; nothing is started"
|
||||
"$SYSTEMCTL" start "$BUS_NAME" || die "systemctl start $BUS_NAME failed; the Console is not started"
|
||||
"$SYSTEMCTL" start "$WEBUI_UNIT" || die "systemctl start $WEBUI_UNIT failed; the bus is running"
|
||||
if [ -z "$running" ]; then
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT"
|
||||
exit 0
|
||||
fi
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled both"
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
case " $running " in
|
||||
*" $name "*) echo "case2-unit: $name was already running, so it is still on ${ids[0]:+copy }${ids[0]:-the copy it started from}, not copy $COPY_ID" ;;
|
||||
*) echo "case2-unit: started $name on copy $COPY_ID" ;;
|
||||
esac
|
||||
done
|
||||
echo "case2-unit: restart $BUS_NAME and $WEBUI_UNIT together to put both on copy $COPY_ID; until then they may run different copies"
|
||||
@@ -0,0 +1,872 @@
|
||||
// Case 2 row C2-13a (#1732): scripts/case2-unit.sh renders and installs the hub's two system units,
|
||||
// and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md
|
||||
// "C2-13a". Every case runs against a scratch unit directory, a scratch home and fake systemctl,
|
||||
// getent, sudo, id, node, bwrap and claude. Nothing here reads or writes /etc/systemd,
|
||||
// ~/.mosaic-dev or ~/.config/mosaic-dev, and no fixture value is a real credential.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
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, 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");
|
||||
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
const BUS_TEMPLATE = join(REPO, "packages", "cli", "systemd", "mosaic-bus.service.in");
|
||||
const WEBUI_TEMPLATE = join(REPO, "packages", "webui", "systemd", "mosaic-webui.service.in");
|
||||
const COPY_A = "a".repeat(40);
|
||||
const COPY_B = "b".repeat(40);
|
||||
const UID = process.getuid();
|
||||
const GID = process.getgid();
|
||||
// A made-up key value; no output may hold it.
|
||||
const FIXTURE_KEY = "fixture-zai-key-3c9e51";
|
||||
|
||||
// A root-owned executable outside /usr/local/bin, /usr/bin and /bin, for the PATH case.
|
||||
function outsideNode() {
|
||||
const dir = "/usr/lib/systemd";
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p, { throwIfNoEntry: false });
|
||||
if (s?.isFile() && s.uid === 0 && (s.mode & 0o111) && (s.mode & 0o022) === 0 && realpathSync(p) === p) return p;
|
||||
}
|
||||
throw new Error(`no root-owned executable in ${dir}`);
|
||||
}
|
||||
const USR_NODE = realpathSync("/usr/bin/node");
|
||||
const TRUE_BIN = realpathSync("/usr/bin/true");
|
||||
|
||||
function script(file, text) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
|
||||
chmodSync(file, 0o755);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A scratch hub: a home with two copies and a proof file, a unit directory, and fakes.
|
||||
function hub(t, { uid = UID } = {}) {
|
||||
const root = tmp(t, "case2-unit-");
|
||||
const home = join(root, "home", "mosaic");
|
||||
for (const id of [COPY_A, COPY_B]) {
|
||||
mkdirSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts"), { recursive: true });
|
||||
writeFileSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts", "mosaic"), "");
|
||||
}
|
||||
const secrets = join(home, ".config", "mosaic-dev", "secrets");
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
const proof = join(secrets, "proxy-proof");
|
||||
writeFileSync(proof, "made-up-proof\n");
|
||||
chmodSync(proof, 0o600);
|
||||
const units = join(root, "units");
|
||||
mkdirSync(units);
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "systemctl.log");
|
||||
script(join(bin, "getent"), `case "$1" in
|
||||
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
|
||||
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
||||
*) exit 2 ;;
|
||||
esac`);
|
||||
// is-active answers yes for each unit named in CASE2_TEST_ACTIVE.
|
||||
script(join(bin, "systemctl"), `echo "$*" >>'${log}'
|
||||
if [ "$1" = is-active ]; then case " \${CASE2_TEST_ACTIVE:-} " in *" $3 "*) exit 0 ;; *) exit 3 ;; esac; fi
|
||||
[ "$*" != "\${CASE2_TEST_FAIL:-}" ]`);
|
||||
const dataRoot = join(home, '.mosaic-dev'), query = join(root, 'query.json'), sudoLog = join(root, 'sudo.log');
|
||||
mkdirSync(dataRoot, { recursive: true });
|
||||
writeJson(query, { dataRoot });
|
||||
for (const copy of [COPY_A, COPY_B]) {
|
||||
const src = join(home, '.local/share/mosaic-stack/copies', copy, 'packages');
|
||||
mkdirSync(join(src, 'cli/src'), { recursive: true }); mkdirSync(join(src, 'webui/src'), { recursive: true });
|
||||
writeFileSync(join(src, 'cli/src/config.mjs'), `import fs from 'node:fs';export const loadSystem=()=>JSON.parse(fs.readFileSync(${JSON.stringify(query)},'utf8'));`);
|
||||
writeFileSync(join(src, 'webui/src/cli.mjs'), '// MOSAIC_TESTER_SOCKET_V1\n');
|
||||
}
|
||||
script(join(bin, 'sudo'), `${CASE_SUDO_SESSION}
|
||||
echo "$*" >>'${sudoLog}'
|
||||
[ "$1" = -u ] && [ "$2" = mosaic ] && [ "$3" = -H ] || exit 97
|
||||
shift 3
|
||||
[ "$1" = env ] && [ "$2" = -i ] && [ "$3" = 'HOME=${home}' ] || exit 98
|
||||
if [ -f '${root}/query-hang' ]; then
|
||||
trap '' TERM
|
||||
(trap '' TERM; echo "$BASHPID" >>'${root}/query-pids'; exec sleep 60) &
|
||||
echo "$BASHPID" >>'${root}/query-pids'; wait; exit
|
||||
fi
|
||||
if [ -f '${root}/query-orphan' ]; then
|
||||
(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
|
||||
if [ -f '${root}/query-output' ]; then cat '${root}/query-output'; exit "$(cat '${root}/query-status')"; fi
|
||||
case_sudo_run '${root}' "$@"`);
|
||||
return { root, home, secrets, proof, units, bin, log, dataRoot, query, sudoLog };
|
||||
}
|
||||
|
||||
const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-host", "books.example", "--auth-header", "X-Auth-User",
|
||||
"--allow-user", "melanie", "--send", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
||||
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
|
||||
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
|
||||
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
||||
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const r = spawnSync(UNIT, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
});
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
const busFile = (h) => join(h.units, "[email protected]");
|
||||
const webuiFile = (h) => join(h.units, "mosaic-webui.service");
|
||||
const read = (f) => readFileSync(f, "utf8");
|
||||
const lines = (text, key) => text.split("\n").filter((l) => l.startsWith(`${key}=`)).map((l) => l.slice(key.length + 1));
|
||||
const copyDir = (h, id = COPY_A) => join(h.home, ".local", "share", "mosaic-stack", "copies", id);
|
||||
const HARDENING = (h) => ["ProtectSystem=strict", `ReadWritePaths=${h.home}`, "ProtectHome=yes", "PrivateTmp=yes"];
|
||||
|
||||
test('row253 factory socket uses actual config and target-uid query without widening hardening', t=>{
|
||||
const h=hub(t),custom=join(h.home,'custom');mkdirSync(custom);writeJson(h.query,{dataRoot:custom});
|
||||
const r=unit(h,{profile:'factory'});assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(lines(read(webuiFile(h)),'ExecStart'),[`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(' ')}`]);
|
||||
assert.deepEqual(read(webuiFile(h)).split('\n').filter(l=>/^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)),HARDENING(h));
|
||||
assert.match(read(h.sudoLog),new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, 'm'));
|
||||
assert.equal(existsSync(join(custom,'console')),false,'installer never creates the socket parent');
|
||||
});
|
||||
|
||||
test('row253 installer refuses unsafe location and query facts before unit writes',t=>{
|
||||
const cases=[
|
||||
['sibling prefix',h=>writeJson(h.query,{dataRoot:h.home+'2/data'}),/outside.*home/],
|
||||
['dot',h=>writeJson(h.query,{dataRoot:h.home+'/./data'}),/normalized/],
|
||||
['dotdot',h=>writeJson(h.query,{dataRoot:h.home+'/x/../data'}),/normalized/],
|
||||
['double slash',h=>writeJson(h.query,{dataRoot:h.home+'//data'}),/normalized/],
|
||||
['trailing slash',h=>writeJson(h.query,{dataRoot:h.home+'/data/'}),/normalized/],
|
||||
['newline',h=>writeJson(h.query,{dataRoot:h.home+'/x\nP10 forged closed'}),/query/],
|
||||
['non ASCII',h=>writeJson(h.query,{dataRoot:h.home+'/é'}),/query/],
|
||||
['middle symlink',h=>{symlinkSync(h.dataRoot,join(h.home,'link'));writeJson(h.query,{dataRoot:join(h.home,'link','nested')});},/symlink/],
|
||||
['long path',h=>writeJson(h.query,{dataRoot:join(h.home,'a'.repeat(108))}),/too.long/],
|
||||
['parent file',h=>writeFileSync(join(h.dataRoot,'console'),'x',{mode:0o600}),/parent-type/],
|
||||
['parent symlink',h=>symlinkSync(h.home,join(h.dataRoot,'console')),/symlink/],
|
||||
['parent mode',h=>{mkdirSync(join(h.dataRoot,'console'));chmodSync(join(h.dataRoot,'console'),0o755);},/parent/],
|
||||
['foreign parent',h=>{mkdirSync(join(h.dataRoot,'console'),{mode:0o700});},/parent/],
|
||||
['unsupported',h=>writeFileSync(join(copyDir(h),'packages/webui/src/cli.mjs'),'// old copy'),/support/],
|
||||
['schema',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true,extra:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
||||
['wrong type',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:'true'}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
||||
['noisy query',h=>{writeFileSync(join(h.root,'query-output'),'noise\n'+JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
||||
['failed valid query',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'1');},/query/],
|
||||
];
|
||||
for(const [name,change,why] of cases){
|
||||
const h=hub(t,{uid:name==='foreign parent'?UID+4242:UID});change(h);
|
||||
const r=unit(h,{profile:'factory'});refuses(r,why);
|
||||
assert.equal(existsSync(busFile(h)),false,name);assert.equal(existsSync(webuiFile(h)),false,name);assert.equal(existsSync(h.log),false,name);
|
||||
assert.equal((r.out+r.err).includes('P10 forged closed'),false,name);
|
||||
}
|
||||
});
|
||||
|
||||
test('row253 query timeout kills its process group and leaves no running child',async t=>{
|
||||
const h=hub(t);writeFileSync(join(h.root,'query-hang'),'');
|
||||
const r=unit(h,{profile:'factory'});refuses(r,/query/);
|
||||
const pids=read(join(h.root,'query-pids')).trim().split('\n').map(Number);assert.ok(pids.length>=2);
|
||||
for(const pid of pids){
|
||||
let state='gone';
|
||||
for(let n=0;n<50;n++){
|
||||
try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';}
|
||||
if(state==='gone'||state==='Z')break;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`);
|
||||
}
|
||||
assert.equal(existsSync(h.log),false);assert.equal(existsSync(busFile(h)),false);
|
||||
});
|
||||
|
||||
test('row253 protected homes refuse before any filesystem access',()=>{
|
||||
const source=read(UNIT), body=source.slice(source.indexOf('tester_location() {'),source.indexOf('# tester-location-end'));
|
||||
for(const home of ['/home/mosaic','/root/mosaic','/run/user/997','/tmp/mosaic','/var/tmp/mosaic']){
|
||||
const r=spawnSync('/bin/bash',['-c',body+'\nif tester_location "$1/data" "$1" 997 ""; then exit 99; fi; printf "%s" "$TS_REASON"','--',home],{encoding:'utf8'});
|
||||
assert.equal(r.status,0);assert.equal(r.stdout,'protected-private-home');
|
||||
}
|
||||
});
|
||||
|
||||
// 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),sessions=join(h.root,'session-pids'),queries=join(h.root,'query-pids'),files=[sessions,queries];
|
||||
writeFileSync(join(h.root,'session-mode'),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 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
// A deterministic clock models a slow inventory without host-load timing.
|
||||
// All process reads and signals are stand-ins; no real PID is signalled.
|
||||
const source=read(UNIT);
|
||||
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
const standins=`
|
||||
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
stat_fields() {
|
||||
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
[ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
return 0
|
||||
}
|
||||
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
kill() { kills=$((kills+1)); return 0; }
|
||||
clean_session; rc=$?
|
||||
printf '%s' "$kills"
|
||||
exit "$rc"
|
||||
`;
|
||||
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
});
|
||||
|
||||
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;
|
||||
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';
|
||||
for(let n=0;n<50;n++){
|
||||
try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';}
|
||||
if(state==='gone'||state==='Z')break;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`);
|
||||
});
|
||||
|
||||
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'];
|
||||
for(const fact of facts){
|
||||
const r=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1"','--',fact]);
|
||||
assert.notEqual(r.status,0,fact);
|
||||
}
|
||||
});
|
||||
|
||||
function refuses(r, pattern) {
|
||||
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
||||
assert.match(r.err, pattern);
|
||||
}
|
||||
|
||||
// A template copy with one edit, for the template cases.
|
||||
function template(h, from, name, edit) {
|
||||
const file = join(h.root, name);
|
||||
writeFileSync(file, edit(read(from)));
|
||||
return file;
|
||||
}
|
||||
|
||||
test("a clean library render writes both units 0644 with the fixed lines, and enables both then starts the bus then the Console", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.equal(statSync(f).mode & 0o777, 0o644);
|
||||
const bus = read(busFile(h));
|
||||
const webui = read(webuiFile(h));
|
||||
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin"]);
|
||||
assert.deepEqual(lines(bus, "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
||||
assert.deepEqual(lines(bus, "EnvironmentFile"), [`-${h.home}/.config/mosaic-dev/secrets/claude-session.env`]);
|
||||
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${libraryFlags(h).join(" ")}`]);
|
||||
assert.deepEqual(lines(webui, "EnvironmentFile"), []);
|
||||
for (const text of [bus, webui]) {
|
||||
assert.deepEqual(lines(text, "WantedBy"), ["multi-user.target"]);
|
||||
assert.deepEqual(lines(text, "User"), ["mosaic"]);
|
||||
assert.deepEqual(lines(text, "Group"), ["mosaic"]);
|
||||
assert.deepEqual(lines(text, "NoNewPrivileges"), ["yes"]);
|
||||
const hardening = text.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l));
|
||||
assert.deepEqual(hardening, HARDENING(h));
|
||||
assert.doesNotMatch(text, /@[A-Z_]+@|%h|MOSAIC_CONFIG=|MOSAIC_CLAUDE_BIN|After=|Wants=/);
|
||||
}
|
||||
assert.deepEqual(lines(bus, "AmbientCapabilities"), []);
|
||||
assert.deepEqual(lines(bus, "CapabilityBoundingSet"), []);
|
||||
assert.deepEqual(lines(webui, "AmbientCapabilities"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.deepEqual(lines(webui, "CapabilityBoundingSet"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n"), [
|
||||
"is-active --quiet [email protected]",
|
||||
"is-active --quiet mosaic-webui.service",
|
||||
"daemon-reload",
|
||||
"enable [email protected] mosaic-webui.service",
|
||||
"start [email protected]",
|
||||
"start mosaic-webui.service",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
|
||||
const h = hub(t);
|
||||
const node = outsideNode();
|
||||
assert.equal(unit(h, { node }).code, 0);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
|
||||
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
|
||||
});
|
||||
|
||||
test("a failed bus start refuses with the Console not started", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h, { env: { CASE2_TEST_FAIL: "start [email protected]" } });
|
||||
refuses(r, /start mosaic-bus@acme\.service failed; the Console is not started/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(2), ["daemon-reload", "enable [email protected] mosaic-webui.service", "start [email protected]"]);
|
||||
});
|
||||
|
||||
test("the script never links a unit file", () => {
|
||||
assert.doesNotMatch(read(UNIT), /systemctl link/);
|
||||
});
|
||||
|
||||
test("factory renders --pm once and one MOSAIC_CLAUDE_BIN line, and without them both tokens render empty", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
||||
const bus = read(busFile(h));
|
||||
const exec = lines(bus, "ExecStart");
|
||||
assert.equal(exec.length, 1);
|
||||
assert.match(exec[0], / bus start %i --pm$/);
|
||||
assert.equal(exec[0].split("--pm").length, 2);
|
||||
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin", `MOSAIC_CLAUDE_BIN=${TRUE_BIN}`]);
|
||||
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(" ")}`]);
|
||||
|
||||
const plain = hub(t);
|
||||
assert.equal(unit(plain, { profile: "factory" }).code, 0);
|
||||
const text = read(busFile(plain));
|
||||
assert.deepEqual(lines(text, "ExecStart"), [`${copyDir(plain)}/scripts/mosaic bus start %i`]);
|
||||
assert.doesNotMatch(text, /MOSAIC_CLAUDE_BIN|@PM_FLAGS@|@CLAUDE_ENV@/);
|
||||
});
|
||||
|
||||
test("a re-install over running units says they are still on the old copy and must restart together, never that it started them", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
const both = unit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: "[email protected] mosaic-webui.service" } });
|
||||
assert.equal(both.code, 0, both.err);
|
||||
assert.doesNotMatch(both.out, /started/);
|
||||
assert.match(both.out, new RegExp(`mosaic-bus@acme\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`restart mosaic-bus@acme\\.service and mosaic-webui\\.service together to put both on copy ${COPY_B}`));
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(-2), ["start [email protected]", "start mosaic-webui.service"]);
|
||||
|
||||
// Only the Console ran (the bus had stopped): the bus starts on the new copy, the Console stays.
|
||||
const one = unit(h, { copy: COPY_A, env: { CASE2_TEST_ACTIVE: "mosaic-webui.service" } });
|
||||
assert.equal(one.code, 0, one.err);
|
||||
assert.match(one.out, new RegExp(`started mosaic-bus@acme\\.service on copy ${COPY_A}`));
|
||||
assert.match(one.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_B}, not copy ${COPY_A}`));
|
||||
assert.doesNotMatch(one.out, /started mosaic-webui/);
|
||||
assert.match(one.out, /restart mosaic-bus@acme\.service and mosaic-webui\.service together/);
|
||||
});
|
||||
|
||||
test("a re-install to another copy re-renders both units, and an edited installed unit refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
||||
assert.match(read(busFile(h)), new RegExp(`copies/${COPY_B}/scripts/mosaic`));
|
||||
assert.doesNotMatch(read(busFile(h)) + read(webuiFile(h)), new RegExp(COPY_A));
|
||||
|
||||
const edits = [
|
||||
[busFile, (s) => s.replace("User=mosaic", "User=root"), /User= must be exactly once in \[Service\], as User=mosaic/],
|
||||
[webuiFile, (s) => s.replace(/^ExecStart=(.*)$/m, "ExecStart=$1 --board http://127.0.0.1:1"), /ExecStart= must be exactly once/],
|
||||
[busFile, (s) => s.replace("ProtectHome=yes\n", ""), /ProtectHome= must be exactly once/],
|
||||
[webuiFile, (s) => s.replace("PrivateTmp=yes\n", ""), /PrivateTmp= must be exactly once/],
|
||||
[busFile, (s) => s.replace("Type=simple", "Type=simple\nX-Note=%h"), /%h is left/],
|
||||
];
|
||||
for (const [file, edit, pattern] of edits) {
|
||||
const before = read(file(h));
|
||||
writeFileSync(file(h), edit(before));
|
||||
refuses(unit(h, { copy: COPY_B }), pattern);
|
||||
writeFileSync(file(h), before);
|
||||
}
|
||||
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
||||
});
|
||||
|
||||
test("a template with a namespace, user or system call restriction refuses", (t) => {
|
||||
const h = hub(t);
|
||||
for (const line of ["RestrictNamespaces=yes", "PrivateUsers=yes", "SystemCallFilter=@system-service"]) {
|
||||
const key = line.split("=")[0];
|
||||
for (const [from, env] of [[BUS_TEMPLATE, "CASE2_BUS_TEMPLATE"], [WEBUI_TEMPLATE, "CASE2_WEBUI_TEMPLATE"]]) {
|
||||
const file = template(h, from, `t-${key}.in`, (s) => s.replace("NoNewPrivileges=yes", `NoNewPrivileges=yes\n${line}`));
|
||||
refuses(unit(h, { env: { [env]: file } }), new RegExp(`refusing ${key}=`));
|
||||
}
|
||||
}
|
||||
assert.equal(existsSync(busFile(h)), false);
|
||||
});
|
||||
|
||||
test("the bus template's EnvironmentFile line must be there and point at the session env, and the Console template must have none", (t) => {
|
||||
const h = hub(t);
|
||||
const gone = template(h, BUS_TEMPLATE, "no-env.in", (s) => s.replace(/^EnvironmentFile=.*\n/m, ""));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: gone } }), /EnvironmentFile= must be exactly once/);
|
||||
const elsewhere = template(h, BUS_TEMPLATE, "env-elsewhere.in", (s) => s.replace("EnvironmentFile=-@SESSION_ENV@", "EnvironmentFile=-/etc/other.env"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: elsewhere } }), /EnvironmentFile= must be exactly once/);
|
||||
const added = template(h, WEBUI_TEMPLATE, "webui-env.in", (s) => s.replace("NoNewPrivileges=yes", "NoNewPrivileges=yes\nEnvironmentFile=-/etc/other.env"));
|
||||
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: added } }), /refusing EnvironmentFile=/);
|
||||
});
|
||||
|
||||
test("a template holding an unknown token, PermissionsStartOnly or a privileged Exec prefix refuses", (t) => {
|
||||
const h = hub(t);
|
||||
const token = template(h, BUS_TEMPLATE, "token.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: token } }), /a token is left after the render: @SECRET@/);
|
||||
const webuiToken = template(h, WEBUI_TEMPLATE, "wtoken.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
||||
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: webuiToken } }), /a token other than one @FLAGS@/);
|
||||
const pso = template(h, BUS_TEMPLATE, "pso.in", (s) => s.replace("Type=simple", "Type=simple\nPermissionsStartOnly=yes"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: pso } }), /refusing PermissionsStartOnly=/);
|
||||
for (const pre of ["+/srv/mosaic/x", "-+/srv/mosaic/x"]) {
|
||||
const file = template(h, BUS_TEMPLATE, "pre.in", (s) => s.replace("Type=simple", `Type=simple\nExecStartPre=${pre}`));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: file } }), /an Exec line that runs with full privileges: ExecStartPre=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a symlink at either unit path refuses", (t) => {
|
||||
for (const name of ["[email protected]", "mosaic-webui.service"]) {
|
||||
const h = hub(t);
|
||||
const target = join(h.root, "elsewhere");
|
||||
writeFileSync(target, "");
|
||||
symlinkSync(target, join(h.units, name));
|
||||
refuses(unit(h), new RegExp(`${name.replace(".", "\\.")} is a symlink`));
|
||||
}
|
||||
});
|
||||
|
||||
test("a --node that is relative, not root-owned or under <home> refuses", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { node: "bin/node" }), /--node must be an absolute path/);
|
||||
const mine = script(join(h.root, "opt", "node"), "exit 0");
|
||||
refuses(unit(h, { node: mine }), /--node: .* is not owned by root/);
|
||||
const inside = script(join(h.home, "bin", "node"), "exit 0");
|
||||
refuses(unit(h, { node: inside }), /--node must be outside/);
|
||||
});
|
||||
|
||||
test("a re-install with a different --node or different serve flags refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
|
||||
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
refuses(unit(h, { flags }), /installed .*: ExecStart= must be exactly once/);
|
||||
// A line check_unit doesn't fix still has to equal the render.
|
||||
writeFileSync(busFile(h), read(busFile(h)).replace(/^Description=.*$/m, "Description=edited"));
|
||||
refuses(unit(h), /differs from the render in more than the copy id/);
|
||||
});
|
||||
|
||||
test("an installed pair whose units name different copies refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
writeFileSync(webuiFile(h), read(webuiFile(h)).replaceAll(COPY_A, COPY_B));
|
||||
refuses(unit(h), new RegExp(`the installed units name different copies, ${COPY_A} and ${COPY_B}`));
|
||||
});
|
||||
|
||||
test("a copy id that is short, uppercase, holds / or .., or names no directory refuses", (t) => {
|
||||
const h = hub(t);
|
||||
for (const copy of ["a".repeat(39), "A".repeat(40), `${"a".repeat(19)}/${"a".repeat(20)}`, `..${"a".repeat(38)}`]) {
|
||||
refuses(unit(h, { copy }), /--copy must be a full 40-character lower-case commit id/);
|
||||
}
|
||||
refuses(unit(h, { copy: "c".repeat(40) }), /no copy directory/);
|
||||
});
|
||||
|
||||
test("library refuses a privileged-floor miss, a bad proof pair and every flag outside its set", (t) => {
|
||||
const h = hub(t);
|
||||
const base = libraryFlags(h);
|
||||
const without = (...names) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
if (names.includes(base[i])) { if (base[i] !== "--send") i++; continue; }
|
||||
out.push(base[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "443" ? "7332" : f)) }), /--port must be 1\.\.1023/);
|
||||
refuses(unit(h, { flags: without("--proof-header", "--proof-file") }), /serve flag --proof-header is required/);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f)) }), /must be an Authentik uid/);
|
||||
assert.equal(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f === "melanie" ? "0".repeat(64) : f)) }).code, 0);
|
||||
rmSync(busFile(h));
|
||||
rmSync(webuiFile(h));
|
||||
for (const [flag, value, pattern] of [
|
||||
["--tester-port", "7333", /case2-unit.sh retires tester TCP; use --tester-socket/],
|
||||
["--tester-socket", null, /the library profile refuses --tester-socket/],
|
||||
["--checkout", "/srv/x", /refusing --checkout/],
|
||||
["--board", "http://127.0.0.1:7331", /refusing --board/],
|
||||
["--sso", null, /refusing --sso/],
|
||||
["--nonsense", "1", /unknown serve flag: --nonsense/],
|
||||
]) {
|
||||
refuses(unit(h, { flags: [...base, flag, ...(value === null ? [] : [value])] }), pattern);
|
||||
}
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "acme" ? "other" : f)) }), /serve --business other is not the unit's business acme/);
|
||||
refuses(unit(h, { extra: ["--pm"] }), /the library profile refuses --pm/);
|
||||
refuses(unit(h, { extra: ["--claude", TRUE_BIN] }), /the library profile refuses --claude/);
|
||||
|
||||
chmodSync(h.proof, 0o644);
|
||||
refuses(unit(h), /mode 0600 or 0400, not 644/);
|
||||
chmodSync(h.proof, 0o600);
|
||||
const link = join(h.secrets, "proof-link");
|
||||
symlinkSync(h.proof, link);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? link : f)) }), /must not be a symlink/);
|
||||
const dataRoot = join(h.home, ".mosaic-dev");
|
||||
mkdirSync(dataRoot, { recursive: true });
|
||||
const outside = join(dataRoot, "proof");
|
||||
writeFileSync(outside, "made-up-proof\n");
|
||||
chmodSync(outside, 0o600);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? outside : f)) }), /must be under .*secrets, outside the data root/);
|
||||
assert.equal(existsSync(busFile(h)), false);
|
||||
});
|
||||
|
||||
// A non-root test can't make a root-owned file under the scratch home, so the
|
||||
// owner case names another uid in the passwd database instead.
|
||||
test("library refuses a proof file not owned by <user>'s uid", (t) => {
|
||||
const h = hub(t, { uid: UID + 4242 });
|
||||
refuses(unit(h), new RegExp(`proxy proof file must be owned by mosaic \\(uid ${UID + 4242}\\)`));
|
||||
});
|
||||
|
||||
test("factory refuses duplicate socket, a missing --port or --business, and each proxy flag", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--tester-socket'] }), /given twice/);
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--port', '443'] }), /given twice/);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--business", "acme"] }), /serve flag --port is required/);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--port", "443"] }), /serve flag --business is required/);
|
||||
for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof], ["--send", null]]) {
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), new RegExp(`the factory profile refuses ${flag}`));
|
||||
}
|
||||
for (const [flag, value, pattern] of [["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--sso", null, /refusing --sso/], ["--nonsense", "1", /unknown serve flag/]]) {
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), pattern);
|
||||
}
|
||||
});
|
||||
|
||||
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
|
||||
refuses(unit(h, { profile: 'factory', flags: [...factoryFlags(), '7333'] }), /unknown serve flag: 7333/);
|
||||
});
|
||||
|
||||
test("factory refuses a --claude that is relative, not root-owned or under <home>, and a re-install with another --pm or --claude", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", "bin/claude"] }), /--claude must be an absolute path/);
|
||||
const mine = script(join(h.root, "opt", "claude"), "exit 0");
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", mine] }), /--claude: .* is not owned by root/);
|
||||
const inside = script(join(h.home, "bin", "claude"), "exit 0");
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", inside] }), /--claude must be outside/);
|
||||
|
||||
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", TRUE_BIN] }), /--pm and --claude must equal the installed ones|ExecStart= must be exactly once/);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--pm"] }), /Environment= lines must be exactly|differs from the render/);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--pm", "--claude", USR_NODE] }), /Environment= lines must be exactly|differs from the render/);
|
||||
});
|
||||
|
||||
// ---- case2-precheck.sh ----
|
||||
|
||||
// A scratch user for the precheck: a config, a business file, an installed Console unit,
|
||||
// fakes on PATH and a fake /proc/sys.
|
||||
function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\n`, port = "443", busClaude = null } = {}) {
|
||||
const root = tmp(t, "case2-pre-");
|
||||
const home = join(root, "home");
|
||||
const configDir = join(home, ".config", "mosaic-dev");
|
||||
const config = writeJson(join(configDir, "config.json"), {
|
||||
configVersion: 1,
|
||||
environment: "production",
|
||||
dataRoot: join(home, ".mosaic-dev"),
|
||||
execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "pi" },
|
||||
});
|
||||
const doc = businessDoc(root);
|
||||
doc.roles.cto.vars = { harness: "pi" };
|
||||
for (const [name, v] of Object.entries(roles)) doc.roles[name].vars = v;
|
||||
Object.assign(doc.vars, vars);
|
||||
writeJson(join(configDir, "businesses", "acme.json"), doc);
|
||||
const secrets = join(configDir, "secrets");
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
if (keyFile !== null) {
|
||||
writeFileSync(join(secrets, "claude-session.env"), keyFile);
|
||||
chmodSync(join(secrets, "claude-session.env"), 0o600);
|
||||
}
|
||||
const units = join(root, "units");
|
||||
mkdirSync(units);
|
||||
writeFileSync(join(units, "mosaic-webui.service"), `[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port ${port} --business acme\n`);
|
||||
writeFileSync(join(units, "[email protected]"), `[Service]\nEnvironment=PATH=/usr/bin\n${busClaude ? `Environment=MOSAIC_CLAUDE_BIN=${busClaude}\n` : ""}`);
|
||||
const sys = join(root, "sys");
|
||||
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
|
||||
mkdirSync(join(sys, "kernel"), { recursive: true });
|
||||
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
||||
const bin = join(root, "bin");
|
||||
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
|
||||
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
|
||||
script(join(bin, "sudo"), "exit 1");
|
||||
script(join(bin, "id"), `if [ "$1" = -G ]; then echo "\${CASE2_TEST_GROUPS:-${GID}}"; else exec /usr/bin/id "$@"; fi`);
|
||||
script(join(bin, "node"), `if [ "$1" = --version ]; then echo "\${CASE2_TEST_NODE:-v24.3.0}"; exit 0; fi\nexec '${process.execPath}' "$@"`);
|
||||
script(join(bin, "bwrap"), `exit "\${CASE2_TEST_BWRAP:-0}"`);
|
||||
return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") };
|
||||
}
|
||||
|
||||
// A fake claude: replace answers with the marker and a small prompt; append answers with a large one;
|
||||
// nomarker answers with a small prompt and no marker; text answers with plain text, not JSON.
|
||||
function fakeClaude(dir, kind) {
|
||||
return script(join(dir, "claude"), `[ "$1" = --version ] && { echo "9.9.9 (Claude Code, ${kind} fake)"; exit 0; }
|
||||
file=''
|
||||
while [ $# -gt 0 ]; do [ "$1" = --system-prompt-file ] && file="$2"; shift; done
|
||||
marker="$(awk '{ print $NF }' "$file")"
|
||||
${kind === "nomarker" ? "marker=something-else" : ""}
|
||||
${kind === "text" ? 'echo "$marker"; exit 0' : ""}
|
||||
tokens=${kind === "append" ? 9000 : 40}
|
||||
printf '{"result":"%s","usage":{"input_tokens":%s,"cache_read_input_tokens":0}}\\n' "$marker" "$tokens"`);
|
||||
}
|
||||
|
||||
// This host may have a claude in /usr/bin, so this PATH links every /usr/bin entry but that one.
|
||||
function pathWithoutClaude(x) {
|
||||
const usr = join(x.root, "usr-bin");
|
||||
mkdirSync(usr);
|
||||
for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name));
|
||||
return `${x.bin}:${usr}`;
|
||||
}
|
||||
|
||||
function precheck(x, { profile = "library", env = {}, path } = {}) {
|
||||
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: {
|
||||
PATH: path ?? `${x.bin}:/usr/bin:/bin`,
|
||||
HOME: x.home,
|
||||
MOSAIC_CONFIG: x.config,
|
||||
CASE2_UNIT_DIR: x.units,
|
||||
CASE2_PROC_SYS: x.sys,
|
||||
CASE2_GETENT: join(x.bin, "getent"),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
const out = r.stdout + r.stderr;
|
||||
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the precheck printed the fixture key");
|
||||
return { code: r.status, out };
|
||||
}
|
||||
|
||||
function blocked(r, pattern) {
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.match(r.out, pattern);
|
||||
}
|
||||
|
||||
test("the precheck passes on a good host and prints free memory, without the key", (t) => {
|
||||
const x = host(t);
|
||||
const r = precheck(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /ok {6}config validates/);
|
||||
assert.match(r.out, /ok {6}bus\.tester is off/);
|
||||
assert.match(r.out, /ok {6}net\.ipv4\.ip_unprivileged_port_start 1024 is above the Console's port 443/);
|
||||
assert.match(r.out, /claude-session\.env sets ZAI_API_KEY/);
|
||||
assert.match(r.out, /free memory: \d+ MiB/);
|
||||
assert.doesNotMatch(r.out, /claude .*fake|marker/);
|
||||
});
|
||||
|
||||
test("the precheck refuses each failed host check", (t) => {
|
||||
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
|
||||
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
|
||||
const sudo = host(t);
|
||||
script(join(sudo.bin, "sudo"), "exit 0");
|
||||
blocked(precheck(sudo), /REFUSED sudo -n true succeeds/);
|
||||
blocked(precheck(host(t), { env: { XDG_DATA_HOME: "/elsewhere" } }), /REFUSED XDG_DATA_HOME is \/elsewhere/);
|
||||
const home = host(t);
|
||||
assert.equal(precheck(home, { env: { XDG_DATA_HOME: `${home.home}/.local/share` } }).code, 0);
|
||||
blocked(precheck(host(t), { env: { HOME: "/tmp" } }), /REFUSED HOME is \/tmp/);
|
||||
|
||||
const runs = host(t);
|
||||
mkdirSync(join(runs.dataRoot, "launches", "acme"), { recursive: true, mode: 0o755 });
|
||||
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o755);
|
||||
blocked(precheck(runs), /REFUSED run root .* is not a 0700 directory \(mode 755\)/);
|
||||
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o700);
|
||||
assert.match(precheck(runs).out, /ok {6}run root .* is 0700/);
|
||||
|
||||
const bad = host(t);
|
||||
writeFileSync(bad.config, "{ not json");
|
||||
blocked(precheck(bad), /REFUSED config:/);
|
||||
});
|
||||
|
||||
test("the precheck refuses a user missing from passwd, a business file that does not load, and a symlinked run root", (t) => {
|
||||
const nouser = host(t);
|
||||
script(join(nouser.bin, "getent"), "exit 2");
|
||||
blocked(precheck(nouser), /REFUSED user: no .* in the passwd database/);
|
||||
|
||||
const unparsed = host(t);
|
||||
writeFileSync(join(unparsed.configDir, "businesses", "acme.json"), "{ not json");
|
||||
blocked(precheck(unparsed), /REFUSED business file: cannot read .*acme\.json/);
|
||||
const gone = host(t);
|
||||
rmSync(join(gone.configDir, "businesses", "acme.json"));
|
||||
blocked(precheck(gone), /REFUSED business file: cannot read .*acme\.json: ENOENT/);
|
||||
const invalid = host(t);
|
||||
const file = join(invalid.configDir, "businesses", "acme.json");
|
||||
const doc = JSON.parse(read(file));
|
||||
doc.notAField = true;
|
||||
writeJson(file, doc);
|
||||
const r = precheck(invalid);
|
||||
blocked(r, /REFUSED business file: /);
|
||||
assert.doesNotMatch(r.out, /REFUSED business file: cannot read/);
|
||||
|
||||
// stat follows a link, so a link to a 0700 directory would read as 0700.
|
||||
const link = host(t);
|
||||
const target = join(link.root, "elsewhere");
|
||||
mkdirSync(target, { mode: 0o700 });
|
||||
chmodSync(target, 0o700);
|
||||
mkdirSync(join(link.dataRoot, "launches"), { recursive: true });
|
||||
symlinkSync(target, join(link.dataRoot, "launches", "acme"));
|
||||
blocked(precheck(link), /REFUSED run root .* is a symlink/);
|
||||
});
|
||||
|
||||
test("a bwrap that fails refuses and prints the sysctl and where bwrap is", (t) => {
|
||||
const x = host(t);
|
||||
blocked(precheck(x, { env: { CASE2_TEST_BWRAP: "1" } }), new RegExp(`REFUSED bwrap .* failed; bwrap: ${join(x.bin, "bwrap")}; kernel\\.apparmor_restrict_unprivileged_userns: 1`));
|
||||
});
|
||||
|
||||
test("an unprivileged port floor at or below the Console's port refuses, and so does a missing Console unit", (t) => {
|
||||
for (const floor of ["443", "80"]) {
|
||||
const x = host(t);
|
||||
writeFileSync(join(x.sys, "net", "ipv4", "ip_unprivileged_port_start"), `${floor}\n`);
|
||||
blocked(precheck(x), new RegExp(`REFUSED net\\.ipv4\\.ip_unprivileged_port_start is ${floor}, at or below the Console's port 443`));
|
||||
}
|
||||
const x = host(t);
|
||||
rmSync(join(x.units, "mosaic-webui.service"));
|
||||
blocked(precheck(x), /REFUSED no installed Console unit/);
|
||||
const noPort = host(t);
|
||||
writeFileSync(join(noPort.units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --business acme\n");
|
||||
blocked(precheck(noPort), /REFUSED .*mosaic-webui\.service has no --port in its ExecStart/);
|
||||
const noFloor = host(t);
|
||||
rmSync(join(noFloor.sys, "net", "ipv4", "ip_unprivileged_port_start"));
|
||||
blocked(precheck(noFloor), /REFUSED cannot read net\.ipv4\.ip_unprivileged_port_start/);
|
||||
});
|
||||
|
||||
test("bus.tester: library refuses it on, factory needs it on, and factory refuses a library business", (t) => {
|
||||
blocked(precheck(host(t, { vars: { "bus.tester": true } })), /REFUSED bus\.tester is on; the library profile needs it off/);
|
||||
blocked(precheck(host(t), { profile: "factory" }), /REFUSED bus\.tester is off; the factory profile needs it on/);
|
||||
const on = precheck(host(t, { vars: { "bus.tester": true } }), { profile: "factory" });
|
||||
assert.equal(on.code, 0, on.out);
|
||||
const lib = host(t, { vars: { "bus.tester": true } });
|
||||
const file = join(lib.configDir, "businesses", "acme.json");
|
||||
const doc = JSON.parse(read(file));
|
||||
doc.projects.stack.kind = "library";
|
||||
writeJson(file, doc);
|
||||
blocked(precheck(lib, { profile: "factory" }), /REFUSED profile-library-business: .* projects stack are library projects/);
|
||||
});
|
||||
|
||||
test("the claude check runs only for a claude-code instance, and refuses a claude that appends or answers wrong", (t) => {
|
||||
// No claude-code instance and no claude anywhere on PATH: the precheck passes.
|
||||
const none = host(t);
|
||||
const r = precheck(none, { path: pathWithoutClaude(none) });
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.doesNotMatch(r.out, /--system-prompt-file|claude is installed/);
|
||||
|
||||
const good = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(good.bin, "replace");
|
||||
const pass = precheck(good);
|
||||
assert.equal(pass.code, 0, pass.out);
|
||||
assert.match(pass.out, /claude .*9\.9\.9 \(Claude Code, replace fake\)/);
|
||||
assert.match(pass.out, /ok {6}claude replaces its prompt with --system-prompt-file \(40 input tokens\)/);
|
||||
|
||||
const bad = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(bad.bin, "append");
|
||||
blocked(precheck(bad), /REFUSED claude adds to its own prompt with --system-prompt-file: 9000 input tokens/);
|
||||
|
||||
const nomarker = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(nomarker.bin, "nomarker");
|
||||
blocked(precheck(nomarker), /REFUSED claude's reply to the marker prompt lacks the marker \(40 input tokens\)/);
|
||||
|
||||
const text = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(text.bin, "text");
|
||||
blocked(precheck(text), /REFUSED claude gave no JSON reply to the marker prompt/);
|
||||
|
||||
const missing = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
blocked(precheck(missing, { path: pathWithoutClaude(missing) }), /REFUSED an instance runs claude-code and no claude is installed/);
|
||||
});
|
||||
|
||||
test("the claude check runs the bus unit's MOSAIC_CLAUDE_BIN over claude on PATH", (t) => {
|
||||
const pinned = join(tmp(t, "case2-claude-"), "pinned");
|
||||
const replace = fakeClaude(pinned, "replace");
|
||||
const x = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: replace });
|
||||
fakeClaude(x.bin, "append");
|
||||
const r = precheck(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /replace fake/);
|
||||
|
||||
const pinnedBad = join(tmp(t, "case2-claude-"), "pinned");
|
||||
const append = fakeClaude(pinnedBad, "append");
|
||||
const y = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: append });
|
||||
fakeClaude(y.bin, "replace");
|
||||
blocked(precheck(y), /REFUSED claude adds to its own prompt/);
|
||||
});
|
||||
|
||||
test("the session env file refuses when missing, open to others, a symlink, empty, or emptied by a later line", (t) => {
|
||||
blocked(precheck(host(t, { keyFile: null })), /REFUSED an instance's provider is zai and .*claude-session\.env is missing/);
|
||||
const open = host(t);
|
||||
chmodSync(join(open.secrets, "claude-session.env"), 0o644);
|
||||
blocked(precheck(open), /REFUSED .*claude-session\.env must be owned by .* at 0600 \(mode 644\)/);
|
||||
const link = host(t, { keyFile: null });
|
||||
const real = join(link.root, "real.env");
|
||||
writeFileSync(real, `ZAI_API_KEY=${FIXTURE_KEY}\n`);
|
||||
chmodSync(real, 0o600);
|
||||
symlinkSync(real, join(link.secrets, "claude-session.env"));
|
||||
blocked(precheck(link), /REFUSED .*claude-session\.env is a symlink/);
|
||||
blocked(precheck(host(t, { keyFile: "ZAI_API_KEY=\n" })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
||||
blocked(precheck(host(t, { keyFile: `ZAI_API_KEY=${FIXTURE_KEY}\nZAI_API_KEY=\n` })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
||||
const quoted = precheck(host(t, { keyFile: `ZAI_API_KEY=\nZAI_API_KEY="${FIXTURE_KEY}"\n` }));
|
||||
assert.equal(quoted.code, 0, quoted.out);
|
||||
});
|
||||
|
||||
// ---- the bind test (mos-1100) ----
|
||||
|
||||
// A session can't take the Console's port: inside the sandbox a real session gets, a bind below
|
||||
// 1024 fails with EACCES, and the same bind on a free port of 1024 or above succeeds.
|
||||
const sandboxSkip = sandboxProblem() ?? false;
|
||||
const LOW_PORT = 980;
|
||||
test("inside a session's sandbox a bind below 1024 fails with EACCES, and a bind on a free high port succeeds", { skip: sandboxSkip, timeout: 30000 }, async (t) => {
|
||||
const floor = Number(read("/proc/sys/net/ipv4/ip_unprivileged_port_start").trim());
|
||||
assert.ok(floor > LOW_PORT, `this host's net.ipv4.ip_unprivileged_port_start is ${floor}, at or below the test port ${LOW_PORT}, so the test can't show the sandbox refuses it`);
|
||||
const root = tmp(t, "c2b-");
|
||||
const runDir = join(root, "run");
|
||||
const workspace = join(root, "ws");
|
||||
mkdirSync(runDir);
|
||||
mkdirSync(workspace);
|
||||
mkdirSync(join(runDir, "bundle"));
|
||||
writeFileSync(join(runDir, "session.json"), "{}");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
const brokerSocket = join(root, "broker.sock");
|
||||
writeFileSync(adapter, "");
|
||||
writeFileSync(brokerSocket, "");
|
||||
prepareRun(runDir);
|
||||
const list = binds({ runDir, workspace, adapter, harnessMounts: [], brokerSocket });
|
||||
const high = await new Promise((done) => {
|
||||
const s = createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => done(port)); });
|
||||
});
|
||||
const bindIn = (port) => {
|
||||
const code = `require("net").createServer().on("error", (e) => { console.log(e.code); process.exit(0); }).listen(${port}, "127.0.0.1", function () { console.log("bound"); this.close(); });`;
|
||||
const argv = sandboxArgv(list, runDir, [process.execPath, "-e", code], { business: "acme", role: "coder" });
|
||||
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
assert.equal(bindIn(LOW_PORT), "EACCES");
|
||||
assert.equal(bindIn(high), "bound");
|
||||
});
|
||||
@@ -4,11 +4,9 @@ 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
|
||||
local pid="$1" raw="" rest
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
@@ -22,9 +20,7 @@ clock_cs() {
|
||||
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))
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
@@ -42,6 +38,8 @@ clean_session() {
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
@@ -62,7 +60,8 @@ trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
|
||||
@@ -310,5 +310,13 @@
|
||||
"new": "MOSAIC_TESTER_SUPERVISOR' || :\n# one-copy drift\nset +m",
|
||||
"killer": "packages/cli/tests/case2-unit.test.mjs",
|
||||
"pattern": "row253 C4 inline supervisor is byte-identical"
|
||||
},
|
||||
{
|
||||
"id": "M40",
|
||||
"file": "scripts/case2-unit.sh",
|
||||
"old": "clean_session() {\n local entry pid rc live until=\"\"\n while :; do\n live=0\n for entry in /proc/[0-9]*/stat; do\n pid=\"${entry%/stat}\"; pid=\"${pid##*/}\"\n rc=0; stat_fields \"$pid\" || rc=$?\n [ \"$rc\" != 3 ] || continue\n [ \"$rc\" = 0 ] || return 1\n [ \"$ST_SESSION\" = \"$child\" ] || continue\n case \"$ST_STATE\" in Z|X) continue ;; esac\n live=1\n if ! kill -KILL \"$pid\" 2>/dev/null; then\n rc=0; stat_fields \"$pid\" || rc=$?\n [ \"$rc\" = 3 ] || { [ \"$rc\" = 0 ] && { [ \"$ST_STATE\" = Z ] || [ \"$ST_STATE\" = X ]; }; } || return 1\n fi\n done\n [ \"$live\" != 0 ] || return 0\n clock_cs || return 1\n # Inventory pass 1 is outside the bounded confirmation/retry budget.\n [ -n \"$until\" ] || until=$((CLOCK_CS + 25))\n [ \"$CLOCK_CS\" -lt \"$until\" ] || return 1\n \"$sleep_bin\" 0.01\n done\n}\n",
|
||||
"new": "clean_session() {\n local entry pid rc live until\n clock_cs || return 1\n until=$((CLOCK_CS + 25))\n while :; do\n live=0\n for entry in /proc/[0-9]*/stat; do\n pid=\"${entry%/stat}\"; pid=\"${pid##*/}\"\n rc=0; stat_fields \"$pid\" || rc=$?\n [ \"$rc\" != 3 ] || continue\n [ \"$rc\" = 0 ] || return 1\n [ \"$ST_SESSION\" = \"$child\" ] || continue\n case \"$ST_STATE\" in Z|X) continue ;; esac\n live=1\n if ! kill -KILL \"$pid\" 2>/dev/null; then\n rc=0; stat_fields \"$pid\" || rc=$?\n [ \"$rc\" = 3 ] || { [ \"$rc\" = 0 ] && { [ \"$ST_STATE\" = Z ] || [ \"$ST_STATE\" = X ]; }; } || return 1\n fi\n done\n [ \"$live\" != 0 ] || return 0\n clock_cs || return 1\n # Inventory pass 1 is outside the bounded confirmation/retry budget.\n :\n [ \"$CLOCK_CS\" -lt \"$until\" ] || return 1\n \"$sleep_bin\" 0.01\n done\n}\n",
|
||||
"killer": "packages/cli/tests/case2-unit.test.mjs",
|
||||
"pattern": "row253 C4 cleanup budget starts after inventory pass one"
|
||||
}
|
||||
]
|
||||
@@ -459,3 +459,5 @@ Tracked as row 276, Gitea #1781 (sage-2331), owner Gander, reviewer Duckworth, a
|
||||
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.
|
||||
|
||||
Row 276 / #1781 also owns the case1 root-side capture/tools mutant counterparts (duckworth-0112 N1) and documenting the setuid-member cleanup refusal in the headers (N2). A member that changes uid and cannot be killed causes inner-cleanup refusal, never successful observation. N4 is addressed in row253 by allowing SID confirmation until the existing inner watchdog expires, instead of a separate 200 ms scheduling margin.
|
||||
@@ -236,6 +236,28 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
||||
assert.ok(a?.length>100);assert.equal(a,b);
|
||||
});
|
||||
|
||||
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
// A deterministic clock models a slow inventory without host-load timing.
|
||||
// All process reads and signals are stand-ins; no real PID is signalled.
|
||||
const source=read(UNIT);
|
||||
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
const standins=`
|
||||
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
stat_fields() {
|
||||
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
[ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
return 0
|
||||
}
|
||||
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
kill() { kills=$((kills+1)); return 0; }
|
||||
clean_session; rc=$?
|
||||
printf '%s' "$kills"
|
||||
exit "$rc"
|
||||
`;
|
||||
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
});
|
||||
|
||||
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}));
|
||||
|
||||
@@ -118,11 +118,9 @@ 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
|
||||
local pid="$1" raw="" rest
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
@@ -136,9 +134,7 @@ clock_cs() {
|
||||
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))
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
@@ -156,6 +152,8 @@ clean_session() {
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
@@ -176,7 +174,8 @@ trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
|
||||
@@ -160,11 +160,9 @@ 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
|
||||
local pid="$1" raw="" rest
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
@@ -178,9 +176,7 @@ clock_cs() {
|
||||
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))
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
@@ -198,6 +194,8 @@ clean_session() {
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
@@ -218,7 +216,8 @@ trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
|
||||
Reference in new issue
Block a user