Mosaic wrote into the operator's harness base installs — ~/.claude, ~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a 102-symlink skill farm per harness. Any experiment with hooks or gating therefore mutated the operator's own tooling, and a broken framework change could take out the very harness needed to repair it. Harness home isolation ---------------------- Each runtime now reads config from a dedicated mosaic-owned home via the harness's own config-dir variable: claude CLAUDE_CONFIG_DIR ~/.config/mosaic/.claude pi PI_CODING_AGENT_DIR ~/.config/mosaic/.pi (replaces ~/.pi/agent) codex CODEX_HOME ~/.config/mosaic/.codex opencode XDG_CONFIG_HOME ~/.config/mosaic/.opencode These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator ownership and a keep-mode upgrade can neither overwrite nor prune them. A bare `claude` / `pi` keeps its own config AND auth, making it a structural break-glass rather than one depending on restoring a file under pressure. opencode is blunter than the rest: it has no dedicated variable and follows XDG, so isolation also relocates XDG lookups for anything it spawns. Documented in place. mosaic-sync-skills now links into those homes and cleans the legacy farms it previously planted in base installs. Ownership is proven by RESOLUTION, not by name — only symlinks resolving inside the canonical/local skills dirs are removed, mirroring the refusal already in commands/skill.js. Verified against a real install: codex's own .system directory survived while its 102 mosaic links were removed. Both resolution prefixes are length-checked first; an empty prefix would make "$resolved" == "$prefix/"* match every absolute path and delete foreign symlinks. Immutable launch record ----------------------- Every launch now appends one record to fleet/run/sessions/events.ndjson before exec. Mandatory, mechanical, no model involvement. pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the launch evidence — that has already produced a confident wrong diagnosis ("this agent bypassed the launcher"), disproved only by the parent's argv and only because the parent had not yet exited. A record written before exec is the only place this survives. The path is the #797 Runtime Session Ledger, already operator-classified and already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a populated ledger survives keep-mode upgrades — but nothing shipped ever wrote it. This implements it in the shape that guard already asserts (0600 files under a 0700 dir). `mosaic` writes session.launch; launch-runtime.py appends lease.register with the broker session id and activation capability. They correlate by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a child with a different pid. Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/ TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is mechanically detectable rather than a matter of judgement. Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over 256 bytes become a sha256 + length rather than being inlined. Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED. resolveTool() uses that same failing specifier, which is why its documented preference for bundled tools over the deployed ~/.config/mosaic copy has never once applied — noted in place, not fixed here. Verified on sb-it-1-dt: isolated homes written and base installs byte-identical for all four harnesses; 408 legacy symlinks removed with 1 foreign entry preserved; launch records paired across the spawn boundary. typecheck shows zero errors in launch.ts (the @mosaicstack/types failures are pre-existing and reproduce on a pristine origin/main worktree).
223 lines
9.3 KiB
Python
223 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Register a runtime parent with the lease broker, then exec without changing PID."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import socket
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
from activation_version_gate import (
|
|
EXPECTED_ACTIVATION_CAPABILITY,
|
|
ActivationCapability,
|
|
VersionCouplingError,
|
|
assert_activation_capability_matches,
|
|
default_probe_activation_capability,
|
|
)
|
|
from lease_generation import initialize_runtime_generation
|
|
|
|
MAX_FRAME: Final = 64 * 1024
|
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
|
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
|
|
# Distinct, non-overlapping exit code for the C4 version-coupling gate (see
|
|
# `activation_version_gate.py`) — deliberately different from the `1`
|
|
# (broker registration failed closed) and `64` (usage error) codes already
|
|
# owned by this script, so a version-skew denial is unambiguous in caller
|
|
# logs/tests and is never confused with a broker-availability failure.
|
|
EXIT_VERSION_SKEW: Final = 65
|
|
|
|
|
|
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
|
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
|
response = bytearray()
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
|
connection.connect(str(socket_path))
|
|
connection.sendall(payload)
|
|
connection.shutdown(socket.SHUT_WR)
|
|
while len(response) <= MAX_FRAME:
|
|
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
|
if not chunk:
|
|
break
|
|
response.extend(chunk)
|
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
|
raise ValueError("invalid broker reply")
|
|
value = json.loads(response)
|
|
if not isinstance(value, dict):
|
|
raise ValueError("invalid broker reply")
|
|
return value
|
|
|
|
|
|
def _self_starttime() -> str | None:
|
|
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
|
|
|
Read past the comm field's parens, since a process name may contain them.
|
|
"""
|
|
try:
|
|
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
|
return raw.rsplit(")", 1)[1].split()[19]
|
|
except (OSError, IndexError, ValueError):
|
|
return None
|
|
|
|
|
|
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
|
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
|
|
|
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
|
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
|
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
|
asserts.
|
|
|
|
Never raises: a launch must not be denied over bookkeeping. But it also never
|
|
fails silently — a missing record is exactly the kind of gap that made the
|
|
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
|
"""
|
|
try:
|
|
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
|
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(directory, 0o700)
|
|
framed = {
|
|
"seq": time.time_ns() // 1_000_000,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
**record,
|
|
}
|
|
path = directory / "events.ndjson"
|
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
with os.fdopen(descriptor, "w") as handle:
|
|
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
|
except (OSError, ValueError, TypeError) as error:
|
|
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
|
|
|
|
|
def main(
|
|
argv: Sequence[str] | None = None,
|
|
*,
|
|
environ: Mapping[str, str] | None = None,
|
|
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
|
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
|
initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation,
|
|
probe_activation_capability: Callable[
|
|
[Mapping[str, str]], ActivationCapability | None
|
|
] = default_probe_activation_capability,
|
|
expected_activation_capability: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY,
|
|
) -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
|
parser.add_argument("--dangerous", action="store_true")
|
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
arguments = parser.parse_args(argv)
|
|
command = arguments.command
|
|
if command and command[0] == "--":
|
|
command = command[1:]
|
|
if not command:
|
|
print("lease-gated runtime command is required", file=sys.stderr)
|
|
return 64
|
|
if arguments.dangerous:
|
|
if arguments.runtime != "claude" or Path(command[0]).name != "claude":
|
|
print("dangerous mode is supported only for the Claude runtime", file=sys.stderr)
|
|
return 64
|
|
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
|
|
|
|
source_environment = os.environ if environ is None else environ
|
|
|
|
# C4 version-coupling gate (#869 Point-1): before this ENFORCEMENT half
|
|
# chains into anything, assert that the ACTIVATION contract it is about
|
|
# to rely on (MOSAIC_LEASE_* injection, broker chaining) matches what
|
|
# this enforcement build expects. This is a build/deploy-defect check,
|
|
# not a broker-availability question, so it runs before — and
|
|
# independently of — broker registration below, and it FAILS LOUD: a
|
|
# clear stderr message plus a dedicated non-zero exit code, never a
|
|
# silent pass and never folded into the generic registration-failure
|
|
# branch.
|
|
try:
|
|
activation_capability = probe_activation_capability(source_environment)
|
|
assert_activation_capability_matches(
|
|
activation_capability,
|
|
expected_activation_capability,
|
|
)
|
|
except VersionCouplingError as version_error:
|
|
print(str(version_error), file=sys.stderr)
|
|
return EXIT_VERSION_SKEW
|
|
|
|
try:
|
|
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
|
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
|
if generation < 0:
|
|
raise ValueError("invalid generation")
|
|
reply = request(
|
|
socket_path,
|
|
{
|
|
"action": "register_anchor",
|
|
"runtime_generation": generation,
|
|
},
|
|
)
|
|
session_id = reply.get("session_id")
|
|
if (
|
|
reply.get("ok") is not True
|
|
or not isinstance(session_id, str)
|
|
or len(session_id) != 64
|
|
or any(character not in "0123456789abcdef" for character in session_id)
|
|
):
|
|
raise ValueError("registration refused")
|
|
generation_file = socket_path.parent / f"generation-{session_id}.state"
|
|
initialize_generation(generation_file, generation)
|
|
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
|
return 1
|
|
|
|
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
|
# config/provenance it knows; only this process knows the broker session id
|
|
# and the activation capability it just asserted. os.execvpe preserves the
|
|
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
|
# record. Never fatal — bookkeeping must not deny a launch — but never
|
|
# silent either.
|
|
_append_launch_record(
|
|
source_environment,
|
|
{
|
|
"kind": "lease.register",
|
|
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
|
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
|
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
|
# preserves it), which is a separate and still-useful fact.
|
|
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
|
"pid": os.getpid(),
|
|
"runtime": arguments.runtime,
|
|
"session_id": session_id,
|
|
"runtime_generation": generation,
|
|
"generation_file": str(generation_file),
|
|
"anchor_starttime": _self_starttime(),
|
|
"activation_capability": activation_capability,
|
|
"command": Path(command[0]).name,
|
|
},
|
|
)
|
|
|
|
environment = dict(source_environment)
|
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
|
environment["MOSAIC_LEASE_GENERATION_FILE"] = str(generation_file)
|
|
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
|
# Matches daemon.py's production default; deployments using a distinct
|
|
# observer socket may set this authenticated transport path explicitly.
|
|
environment.setdefault(
|
|
"MOSAIC_RECEIPT_OBSERVER_SOCKET",
|
|
str(socket_path.with_name("receipt-observer.sock")),
|
|
)
|
|
try:
|
|
execute(command[0], command, environment)
|
|
except OSError:
|
|
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|