15 KiB
Row 253: private tester socket in the host scripts
Gander, 2026-10-21. F27, #1758. Base c3d88840c, after row196 landing
7247d80c7. Brief: docs/plans/2026-10-21_tester-socket-scripts.md at
0cdead66c. Reviewer Duckworth; gate owner Sage. Status: proposed, no product
code or tests changed. Claim gander-253-start is queue rev2995.
Outcome and scope
The factory installer renders a bare --tester-socket flag instead of a TCP port. Case1 acceptance consumes that saved form and examines the private listener without claiming that a private pathname defeats hostile same-uid processes. The library profile still has no tester. No .53 or .54 host may take a copy carrying196 before253 lands, per mos-1198.
Change scripts/case2-unit.sh, scripts/case1-accept.sh and their two existing CLI test files. Their comments/usage and fixture docs move with the code. Do not edit the deliberate legacy paths named in the brief. No runtime bus, Console, role, grant, control-board, dependency or systemd-template change. No live installer or acceptance run, provider, home/config, unit or host access.
Choices
- Recommended: resolve the configured dataRoot as the stack user, preserve hardening, check the final pathname's metadata and use SO_PEERCRED on a connection to identify its listener. This supports custom roots within the unit's existing writable home and matches196's kernel-peer approach.
- Assume /.mosaic-dev and inspect a pathname in /proc/net/unix. This misses a custom config and is not a reliable final-path ownership check: 196 binds a temporary name and renames it. A socket's filesystem inode also must not be confused with its kernel socket inode. Do not use this option.
- Add a TCP relay, relax hardening or give acceptance a configurable probe executable. All widen the surface or trust inputs and are outside this row.
There is no second bus principal verifier. The acceptance probe observes an OS endpoint; it grants no capability and sends no HTTP or bus request.
Installer arguments and preflight
Factory keeps required --port (1..1023) and --business; tester remains optional for the installer. --tester-socket is a single marker, takes no value and keeps its position in the rendered flags. The existing duplicate-flag check applies. A following stray value is an unknown flag. --tester-port always refuses in serve arguments, for both profiles, before unit writes or systemctl actions:
case2-unit: case2-unit.sh retires tester TCP; use --tester-socket
Library rejects --tester-socket with an explicit library-profile refusal. Its other flags, proof-file checks and install behavior are unchanged. No legacy conversion or fallback to TCP is introduced in this installer.
When the factory socket marker is present, a small inline metadata query runs the selected copy's config loader under the target uid with sudo -u USER -H, env -i, explicit HOME and the same pinned-node-first PATH used by the unit. Root never imports user-writable copy code as root. It invokes the previously validated --node path after dropping uid, with the copy module path as a separate argument. This resolves the default instance just as the rendered unit does; no MOSAIC_CONFIG or named-instance override is inherited.
The query returns only dataRoot and whether that copy's Console carries the MOSAIC_TESTER_SOCKET_V1 marker. It prints no config values, credentials or child diagnostics. Bound it to five seconds and 4096 output bytes; reject errors, extra output/keys, malformed results, missing config and unsupported targets. No root process opens a config or credential under the user's home. Existing root metadata checks remain root metadata checks. Both script headers explain the new, explicitly unprivileged config query.
Before any unit writes, validate the socket location:
- dataRoot is an absolute normalized plain path, below the declared home, with no symlink in its existing path. Never expand the unit's writable scope.
- Neither home nor dataRoot is under /home, /root, /run/user, /tmp or /var/tmp. These roots conflict with the existing ProtectHome/PrivateTmp contract.
- /console/tester.sock is at most 107 ASCII bytes, matching196.
- A missing console parent is allowed; runtime creates it. An existing parent must be a real directory, not a symlink, owned by the target uid with no group/other permissions. A foreign owner, non-directory or unsafe mode refuses.
- Do not create/chmod the parent or inspect/delete an existing tester entry in the installer. A running Console may own it during a re-pin. Runtime196 owns binding and cleanup. Reinstall still does not restart running units.
The dataRoot query and all refusal families get fake-only tests. A failed preflight leaves both units and the systemctl log unchanged.
How the rendered unit reaches the socket
The unit still has User/Group for the stack account, ProtectSystem=strict, ReadWritePaths=, ProtectHome=yes and PrivateTmp=yes, in the same order. Its ExecStart contains --tester-socket, with no pathname argument. The Console loads dataRoot and derives console/tester.sock itself. Case1/2's /srv/mosaic home is outside ProtectHome's protected hierarchies; the allowed dataRoot is inside the existing writable home, and the socket is outside private /tmp. No extra capability or writable directory is added. Systemd's documented namespace rules motivate the location refusals, rather than a hardening change. systemd.exec
A mutable same-uid config can change between preflight and start. The unit's writable boundary is still home; runtime's own config/socket checks still refuse unsafe state. This row does not promise an immutable same-uid config. The host window must verify the rendered unit actually starts under its namespace; scratch tests inspect its directives, not a real systemd namespace.
Acceptance arguments and endpoint observation
The saved args must contain one --tester-socket and the numeric --port. Reject a missing/duplicate marker, a marker with an old numeric value, and --tester-port with the same migration wording naming case1-accept.sh. Preserve all existing pre-- argument validation and the default-instance contract. Malformed saved inputs still refuse with exit2 before functional checks.
Acceptance resolves the actual configured tester dataRoot with the same unprivileged, bounded query. It does not silently use the default path. An unreadable/invalid config or a root outside the unit's allowed home refuses before the functional talk. The old DATA_ROOT used by F11 remains unchanged; generalizing that separate Discord check is outside this row.
P10 independently requires the Console's systemd MainPID to be positive and stable, and its four /proc uid fields to be the stack uid. Check the private parent's type, owner and mode, and the final entry's non-symlink socket type, owner and exact 0600 mode. Snapshot parent/socket dev+ino and metadata before and after the peer probe; a replacement or MainPID change fails observation. The port floor must still be numeric and above the privileged main Console port, retaining that existing part of the observation.
The bounded probe runs as the stack user with an empty inherited environment, the fixed system /usr/bin/python3 resolved to a root-owned ELF, safe root-owned ancestors, and -I -S. This is the system Python dependency already required by 196; never use PATH python, a home shim or a configurable helper. Unsafe/missing Python is a failed P10 observation, not a fallback. Resolve/check the system entry and target using metadata and the ELF header; do not run copy Python code as root. Test seams emulate these root facts and the system executable.
The probe opens AF_UNIX/SOCK_STREAM, uses a one-second connect timeout, reads SO_PEERCRED and closes. An outer three-second process-group bound applies. It returns only pid/uid/gid, with exact structure, bounded output and numeric validation. pid must equal MainPID and uid the stack uid; gid is validated but not an identity grant. No application bytes, cookies, credentials or proof envelope are sent. Linux supplies the peer credentials for the connected stream, so the check does not depend on the temporary bind pathname. unix(7), SO_PEERCRED
This is a snapshot. Hostile same-uid processes can alter same-uid state or interfere with the unprivileged probe. Root, ptrace and later pathname changes are not defeated here. The probe does not prove another uid is denied; that remains a host-window observation, not a fake test claim.
P8 and P10 outcome lines
P8 must change with removal of TESTER_PORT. It checks the main Console port is loopback-only and held only by MainPID, and that nothing listens on TCP7331 or the retired TCP7333. Socket ownership belongs to P10. Its healthy line is:
P8 lan-listeners closed <port> is on 127.0.0.1 only by MainPID <pid>; no TCP listener on 7331 or 7333; tester uses <socket>
An unexpected listener, missing main listener or wrong owner produces P8 lan-listeners unnamed with the observed listener summaries, as today.
Do not reuse accepted mos-1101 for a new Unix-socket claim: that ruling named a TCP-port condition. Proposed safe default pending any new Mos classification:
P10 tester-socket-squat unnamed boundary=observed path=<socket> parent=0700 owner=<uid> socket=0600 peer_pid=<pid> peer_uid=<uid> floor=<floor> port=<port>; hostile same-uid substitution remains
Any missing/unsafe metadata, wrong peer, unsafe interpreter, malformed/timeout probe, floor miss or changed snapshot produces one P10 line instead:
P10 tester-socket-squat unnamed boundary=failed reason=<bounded-code> path=<socket> MainPID=<pid>; hostile same-uid substitution remains
Healthy observation does not mark the same-uid squat path closed or invent an accepted ruling. P checks still never change exit status. Tests pin the reason and observed/failed distinction, so validation cannot disappear behind an always-unnamed mark. Sage/Mos may supply a new classification at design review; it must be explicit before changing this proposed mark. No Jason question is needed merely to retain unnamed evidence.
Evidence and planned checks
Keep the existing scratch fixtures, fake account database/systemctl/curl/ss/ sudo/stat and fake interpreter. Add copy config/marker fixtures and probe metadata under those same roots. No test connects to a live socket or runs the real host scripts without their existing seams. Fixture fakes verify the unprivileged command argv; no test claims a real uid drop or namespace. The fake system interpreter executes the actual probe payload against a fake Python socket API, checking AF_UNIX/SOCK_STREAM, timeout, connect, getsockopt arguments and returned peer bytes. This catches errors in the inline probe without opening an OS listener. The metadata query is tested with nonzero status plus valid-looking output as well as malformed output, so removing its status refusal cannot hide behind a later parse error.
| Checks | Required observations |
|---|---|
| T1 factory render | Bare marker exactly once, argument order retained, no TCP flag, hardening unchanged; duplicate/stray value refuses |
| T2 profile/migration | Both profiles refuse retired TCP; library refuses socket; all refuse before writes/start |
| T3 config/target | Invalid/missing/noisy/timed-out metadata and unmarked target refuse; query uses target uid and clean environment |
| T4 location | Default and custom in-home roots work; outside-home, protected/private hierarchies, symlinks and >107-byte pathname refuse |
| T5 parent | Missing parent allowed; file/symlink/foreign owner/group-other mode refuse without mutation or systemctl calls |
| T6 saved args | Required socket marker; missing/duplicate/stray value and retired TCP refuse exit2 before talk |
| T7 P8 | Main-only loopback succeeds; LAN/wrong owner/7331 or7333 listener gives unnamed |
| T8 P10 | Exact metadata, stable MainPID and matching peer produce boundary=observed; custom configured root used |
| T9 failed P10 | Parent/socket faults, wrong PID/uid, malformed/timeout probe, unsafe Python, bad floor and changed snapshot each give failed reason |
| T10 preserved behavior | Functional checks, their exit semantics, library behavior and all other P paths retained |
Planned checks to kill, one target change at a time in isolated snapshots:
| IDs | Removed requirement |
|---|---|
| M1-M3 | Installer TCP refusal, library socket refusal, duplicate socket refusal |
| M4-M5 | Config-query failure refusal, unsupported-target refusal |
| M6-M8 | Home containment, protected/private path refusal,107-byte limit |
| M9-M11 | Existing parent type/symlink, uid, mode checks |
| M12-M15 | Acceptance TCP refusal, required marker, duplicate/stray marker rejection, config-query refusal |
| M16 | P8 retired TCP7333 check |
| M17-M18 | P10 parent and final socket metadata checks |
| M19-M21 | Peer PID, peer uid, malformed/timeout probe refusal |
| M22-M23 | Safe fixed system interpreter, stable metadata/MainPID snapshot |
| M24-M25 | Target-uid invocation in installer and acceptance metadata/probe paths |
| M26 | Privileged main-port floor observation |
| M27 | Probe returns its own PID rather than the connected peer's credentials |
Before execution, give every definition one unique source target and a named killer test. Preserve raw logs, base times, syntax preflight and byte-identical SHA/Git restoration. A timeout counts only under Sage's explicit rules and must kill the process group. Refusal variants in T1-T9 must not be merely assertions of a generic nonzero exit.
Static inventory and gate
After implementation, enumerate every --tester-port hit in scripts/packages in BUILD.md and a machine-readable file. The only permitted hits are the brief's deliberate legacy producers/parsers/tests and this row's explicit refusals and negative fixtures. Inspect arguments and generated units, not just filenames. Readmes must describe retirement rather than produce old argv. No modification to webui-service, stack-release, instance, their tests/fixture, case1-hostcheck or tester-cli negative calls is planned. Stop and ask Sage if one is needed.
Then request Sage's slot. Under that grant, run CLI whole, WebUI whole, every shell test suite whole and all named checks above. Use an isolated candidate, plain linked node_modules, a short gXXX TMPDIR under /mnt/storage/scratch/tmp, busy &&, pipefail and tee. Single/pattern runs need the slot too, per sage-2142. No harness/helper/log argument may match the busy detector pattern, per mos-1205. Two failures end the window; report any stop to Sage/Mos before a retry. Return the slot with script-summed counts/timings and a leftover sweep.
Design approval precedes code. The manifest, immutable candidate hashes, static inventory and gate evidence go to Duckworth before Sage's landing. Queue moves use the helper, with a coordinated queue-write gap. Sage posts the jarvis review request after the in-review move. BUILD-LOG/session history stays append-only; Sage continues session registration. No host deployment is part of row253 acceptance.