From d5697c2e00623f1955bca9c6880f6239d1a3ce79 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 19:44:06 -0500 Subject: [PATCH 01/13] The guest grants itself /proc and its own source root before hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `guest_main._default_harden` called `apply_tier0()` with no policy, so the bare `Tier0Policy()` default applied: read-only roots of /usr, /lib, /lib64, /bin, /sbin and /etc. Two things a real guest needs are outside that set. `/proc` is the one S5 already paid for. `apply_tier0` installs the Landlock ruleset and only afterwards reads /proc/self/status to fill in `seccomp_mode` and `no_new_privs`, so without the grant a guest that hardened correctly reports `Seccomp: -1` — evidence-gathering denied by the ruleset it was verifying. S5 fixed it in the probe's own local policy and named it "not optional" there; the default was left alone, so the next caller inherited the gap. The source root is the new half. The launcher unpacks `repl_sandbox` into a directory under none of the default roots, and module-scope imports have already run by the time Tier-0 is applied — it is the lazy ones that would raise EACCES, deep in a later turn rather than at startup. Neither was reachable before: `guest_main` has no non-test caller, which is what `KataLauncher.boot` is about to change. Each of the three checks was watched failing against the code as it stood — both grants absent, and `_default_harden` calling `apply_tier0()` bare. pytest 1042 -> 1045. --- src/repl_sandbox/guest_main.py | 47 ++++++++++++++++++++++- src/repl_sandbox/tests/test_guest_main.py | 46 +++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/repl_sandbox/guest_main.py b/src/repl_sandbox/guest_main.py index 9d2a863..17b9552 100644 --- a/src/repl_sandbox/guest_main.py +++ b/src/repl_sandbox/guest_main.py @@ -40,6 +40,7 @@ class does. import argparse import json import os +import pathlib import sys from dataclasses import dataclass, field from typing import Any, Callable @@ -261,12 +262,56 @@ def _default_listener(port: int) -> VsockListener: return VsockListener(port) +def guest_source_root() -> str: + """The directory `repl_sandbox` was imported from, as an absolute path. + + Landlock grants cover a path and everything beneath it, and the entry that + has to be granted is the *parent* of the package rather than the package + itself: `sys.path` carries that directory, and Python's path finder lists it + when resolving `repl_sandbox.anything`. + """ + return str(pathlib.Path(__file__).resolve().parents[1]) + + +def guest_tier0_policy() -> Any: + """Tier-0 for this process, with the two roots a real guest cannot run without. + + The bare `Tier0Policy()` default grants `/usr`, `/lib`, `/lib64`, `/bin`, + `/sbin` and `/etc`. Both additions here are load-bearing, and each has + already been paid for once: + + * **`/proc`** — S5 applied every control correctly and then reported + `Seccomp: -1`, because the read-back of `/proc/self/status` was denied by + the ruleset it was verifying (`scripts/repl_sandbox_s5_probe.py`, the + `hardened()` policy comment). The evidence a hardened worker offers is + gathered from inside its own blast radius. `os.cpu_count` and + `multiprocessing` need it too, so granting it is what a deployment does, + not a concession the probe made to see its own state. + * **the source root** — this package is unpacked into a launcher-chosen + directory that is under none of the default roots, so every *lazy* import + after hardening would raise `EACCES`. The imports at module scope have + already run by the time this is applied; the ones that have not are the + problem, and they surface as an error deep in a later turn rather than at + startup. + + That S5 carried the `/proc` fix in the probe's own local policy rather than + in the default is why this module inherited the gap: the lesson lived beside + the one caller that had met it. + """ + from repl_sandbox.hardening import Tier0Policy + + default = Tier0Policy() + return Tier0Policy( + read_only_roots=(*default.read_only_roots, "/proc", guest_source_root()), + ) + + def _default_harden() -> Any: # Imported here rather than at module scope so this module stays importable # for its tests on a platform whose syscall table `hardening` does not carry. from repl_sandbox.hardening import apply_tier0 - return apply_tier0() + return apply_tier0(guest_tier0_policy()) def _announce(event: dict) -> None: diff --git a/src/repl_sandbox/tests/test_guest_main.py b/src/repl_sandbox/tests/test_guest_main.py index a769425..dcac1d0 100644 --- a/src/repl_sandbox/tests/test_guest_main.py +++ b/src/repl_sandbox/tests/test_guest_main.py @@ -268,10 +268,54 @@ def exploding_harden(): # --------------------------------------------------------------------------- -# The two properties that would be silently wrong +# The properties that would be silently wrong # --------------------------------------------------------------------------- +def test_tier0_grants_proc_so_the_guest_can_read_back_its_own_hardening() -> None: + """Evidence-gathering sits inside the blast radius of the thing it measures. + + `apply_tier0` installs the Landlock ruleset and only afterwards reads + `/proc/self/status` to fill in `seccomp_mode` and `no_new_privs`. With the + bare default policy those roots do not include `/proc`, so a guest that + hardened *correctly* reports `Seccomp: -1` -- the exact failure S5 met on its + first host run and fixed in the probe's own local policy, which is why this + module inherited the gap. + """ + policy = guest_main.guest_tier0_policy() + assert "/proc" in policy.read_only_roots + + +def test_tier0_grants_the_root_this_package_is_imported_from() -> None: + """A lazy import after hardening must not become EACCES. + + The launcher unpacks `repl_sandbox` into a directory of its choosing, which + is under none of `Tier0Policy`'s default roots. Module-scope imports have + already run by the time Tier-0 is applied; the ones that have not are the + failure, and they surface deep in a later turn rather than at startup. + """ + policy = guest_main.guest_tier0_policy() + root = pathlib.Path(guest_main.guest_source_root()) + + # The granted root really does contain this package -- not merely a string + # that looks plausible. + assert (root / "repl_sandbox" / "guest_main.py").is_file() + assert str(root) in policy.read_only_roots + + +def test_the_default_hardener_passes_a_policy_rather_than_taking_the_bare_default() -> None: + """The two grants above only bind if `_default_harden` actually applies them. + + Checked at the call site because that is where the defect lived: a correct + policy nobody passes is the same guest as no policy at all. + """ + import inspect + + body = inspect.getsource(guest_main._default_harden) + assert "apply_tier0()" not in body + assert "guest_tier0_policy()" in body + + def test_the_default_listener_is_native_vsock_never_the_hybrid_one() -> None: """The guest keeps what the host lost: a kernel-supplied peer CID. From 30c7f74e58790bde54b98e2390ee35d7bc740295 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 19:58:45 -0500 Subject: [PATCH 02/13] Two modules stop claiming the kernel supplies the session identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INTERFACES §3.1a corrected the design records in July: under the ratified VMM's hybrid vsock the host accepts on an AF_UNIX socket, which carries no peer CID, so identity is the host-assigned id bound to that sandbox's own socket path. The records were followed; these two source comments were not. `session.py`'s header said the CID "comes from the kernel at `accept()`" and `capabilities.py` said guest CIDs are "kernel-assigned per microVM" — both unqualified, both in the modules whose actual behaviour that correction rewrote. Each now names what supplies the value under each transport and points at §3.1a rather than restating it, following the same one-place-to-read discipline the correction itself asks for. Also ties the two copies of the guest-CID floor together. `MIN_GUEST_CID` (derived) and `FIRST_GUEST_CID` (a literal) are one fact in two modules, and each was checked only against its own literal — so a change to either would leave two enforcing surfaces disagreeing about which CIDs are admissible with both modules' tests still green. Asserting them against each other is the point; a literal-vs-literal check passes while the copies drift, which is the shape S6-entry found in the reserved names. The new check was watched failing against a planted drift (FIRST_GUEST_CID 3 -> 4, reverted). pytest 1045 -> 1046. --- src/repl_sandbox/capabilities.py | 8 ++++++-- src/repl_sandbox/session.py | 21 ++++++++++++++++----- src/repl_sandbox/tests/test_session.py | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/repl_sandbox/capabilities.py b/src/repl_sandbox/capabilities.py index 26eee90..fc050a3 100644 --- a/src/repl_sandbox/capabilities.py +++ b/src/repl_sandbox/capabilities.py @@ -103,8 +103,12 @@ #: ever collide with it. ARGS_LOCAL = "_args" -#: Guest CIDs are kernel-assigned per microVM; 0/1/2 are the reserved vsock CIDs -#: (hypervisor / local / host), so a registration keyed below 3 is a host bug. +#: The lowest CID a registration may be keyed on. 0/1/2 are the reserved vsock +#: CIDs (hypervisor / local / host), so a registration below 3 is a host bug. +#: Who *supplies* the value depends on the VMM — a kernel-read peer CID under +#: native vhost-vsock, a host-assigned id bound to the sandbox's socket path +#: under the ratified VMM's hybrid vsock (INTERFACES section 3.1a). The range +#: check is the same either way, which is why this constant does not care. FIRST_GUEST_CID = 3 MAX_NAME_LEN = 64 diff --git a/src/repl_sandbox/session.py b/src/repl_sandbox/session.py index ff81a32..ca7680e 100644 --- a/src/repl_sandbox/session.py +++ b/src/repl_sandbox/session.py @@ -3,16 +3,27 @@ Source of truth: docs/product/repl-sandbox/REPL_SANDBOX_DATA_MODEL.md section 2 (Namespace, allocation, lifecycle, revocation) — "per-session, keyed by `(CID, id)`; disjoint across sessions" — with the identity rule from -REPL_SANDBOX_ARCHITECTURE.md section 7 requirement 4 (auth by kernel vsock peer -CID from `accept()`, never a guest-supplied id) and REPL_SANDBOX_SPEC.md -section 4 (Host chokepoint contracts). +REPL_SANDBOX_ARCHITECTURE.md section 7 requirement 4 (auth by the session +identity the listener supplies at `accept()`, never a guest-supplied id) and +REPL_SANDBOX_SPEC.md section 4 (Host chokepoint contracts). -The CID is not data the guest can write. It comes from the kernel at +The CID is not data the guest can write. It is what the *listener* reports at `accept()`, which is why it — and nothing in the payload — is what the handle table, the ledgers, and the audit log key on. This module's whole job is to say -whether a CID the kernel just handed us belongs to a live session, and to +whether a CID the listener just handed us belongs to a live session, and to refuse when it does not. +**What supplies that value depends on the VMM, and this module is deliberately +incurious about which.** Under native vhost-vsock it is a peer CID the host +kernel reads at `accept()`. Under the ratified VMM's hybrid vsock there is no +CID to read — a Unix-socket accept carries none — and it is instead the +host-assigned id bound to that sandbox's own socket path, a path only that one +VMM can deliver a connection to. Same property, different enforcing surface; +INTERFACES section 3.1a is the authoritative correction and the one place to +read it. Earlier revisions of this header said the value comes from the kernel +without qualification, which was true of the transport the records were first +written against and is not true of the one that shipped. + Two structural rules beyond the lookup, both enforced here rather than documented and hoped for: diff --git a/src/repl_sandbox/tests/test_session.py b/src/repl_sandbox/tests/test_session.py index 0c5539b..f225749 100644 --- a/src/repl_sandbox/tests/test_session.py +++ b/src/repl_sandbox/tests/test_session.py @@ -132,6 +132,25 @@ def test_min_guest_cid_sits_above_the_host_cid(): assert MIN_GUEST_CID == 3 +def test_both_copies_of_the_guest_cid_floor_agree(): + """One fact, two modules, and until now nothing tied them together. + + `session.MIN_GUEST_CID` is derived (`VMADDR_CID_HOST + 1`) and + `capabilities.FIRST_GUEST_CID` is a literal, and both are the same fact: + the lowest CID a guest may be keyed on. They happen to agree today, so a + change to either -- the reserved-CID range widening, or the literal being + edited -- would leave two enforcing surfaces disagreeing about which CIDs + are admissible, with each module's own tests still green. + + Asserting the two constants against each other rather than each against a + literal is the point: a literal-vs-literal check passes while the modules + drift apart, which is the shape S6-entry found in the reserved names. + """ + from repl_sandbox.capabilities import FIRST_GUEST_CID + + assert MIN_GUEST_CID == FIRST_GUEST_CID + + # --- audit ------------------------------------------------------------------ From 797e0f885f6a6a2d7f76832cf4b753e2c3dd401e Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 20:21:52 -0500 Subject: [PATCH 03/13] KataLauncher.boot claims a real microVM, or refuses and leaves nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boot` ran the G1 gate and then raised. It now mints a sandbox, ensures the pinned image, launches under the Kata runtime, refuses when containerd reported success without a VM, discovers the vsock socket, ships the package, and releases everything it allocated if any of that fails. Three things were measured on the reference host rather than reasoned about, and each changed the code. `pgrep` excludes its own PID but not its ancestors, so on a host with zero VMMs a `pgrep -af cloud-hypervisor` from any process whose command line carries that string matches itself. A launcher whose job is refusing a boot that produced no VM cannot use a check that invents one, so identity is `/proc//exe`. `comm` is not usable for it: TASK_COMM_LEN truncates at 15 characters, so a real VMM reads `cloud-hyperviso` and the obvious equality test would refuse every genuine boot while looking correct. Containerd image stores are per-namespace. `ctr -n trellis run` fails with `image "...": not found` against a namespace that has not pulled, so owning a namespace means owning the pull — which is why the digest is now a config value with `test_config.py` asserting it tracks the provisioner's copy. The namespace is not `default` because rule 19(a) asks a session to confirm a destructive step over its whole reach, and Kata puts the namespace literally in the leaked-cgroup path. By the time `ctr run -d` returns, the VMM and its socket already exist — the first poll 15 ms later found both. So an absent VMM is "never booted", not "not booted yet", and the refusal needs no polling window. `start_bridge` refuses instead of passing. The guest needs no in-guest forwarder — there is no rlms in the guest, and the stubs dial AF_VSOCK directly — but who owns a session's LM_PORT/DB_PORT listeners is not settled: `kata_repl` assigns them to the LM handler and the broker, `KataLauncher` takes no host, and no code outside tests and probes binds one. A silent no-op would assert a bridge that does not exist, since setup() calls this step "the bridge, before any untrusted worker process". Observed on the AX41: G1 PASS at ratio 12.7; boot in 34.8s; VMM pid found; socket discovered at /run/vc/vm//clh.sock; the guest imported the shipped package and reported its source root as /run/trellis. Negative control — VMM detection blinded against a real, successful `ctr run` — the refusal fired and left zero containers, tasks, VMMs, vm dirs and namespace cgroups behind. pytest 1047 -> 1048, vitest 1416/119. --- src/repl_sandbox/config.py | 29 ++ src/repl_sandbox/launcher.py | 517 +++++++++++++++++++++++- src/repl_sandbox/tests/test_config.py | 33 ++ src/repl_sandbox/tests/test_launcher.py | 74 +++- 4 files changed, 627 insertions(+), 26 deletions(-) diff --git a/src/repl_sandbox/config.py b/src/repl_sandbox/config.py index a1a3546..4be2ca7 100644 --- a/src/repl_sandbox/config.py +++ b/src/repl_sandbox/config.py @@ -149,6 +149,35 @@ class VsockPorts: VMADDR_CID_HOST = 2 +#: The containerd runtime handler that routes a container to a Kata microVM. +#: Anything else on this field is a container, not a boundary. +KATA_RUNTIME_HANDLER = "io.containerd.kata.v2" + +#: The guest image, pinned by digest rather than by tag: `python:3.12-slim` is +#: mutable, and the recorded spike runs are only reproducible against this +#: manifest. Both values are `scripts/provision_kata_host.sh`'s — that script +#: pulls and verifies them, so it holds the authority and these are the copy. +#: `test_config.py` asserts the two agree, so a bump there reddens here first. +GUEST_IMAGE = "docker.io/library/python:3.12-slim" +GUEST_IMAGE_DIGEST = "sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de" + +#: The containerd namespace a launcher works in. +#: +#: **Not `default`, and the reason is rule 19(a) rather than tidiness.** A +#: destructive step is confirmed over its whole reach before it runs, and a +#: launcher sharing `default` with the provisioner and every past probe cannot +#: say "everything under this path is mine" about anything it is about to +#: remove. The namespace is also literally in the leaked-cgroup path — Kata's +#: cgroup driver builds `/sys/fs/cgroup//kata_` — so moving off +#: `default` is what makes that sweep bounded. +#: +#: **Measured consequence, 2026-07-25:** containerd image stores are +#: per-namespace. `ctr -n run` against a namespace that has not pulled the +#: image fails with `image "...": not found`, so a launcher owning a namespace +#: necessarily owns the pull, which is why the digest above lives here at all. +CTR_NAMESPACE = "trellis" + + @dataclass(frozen=True) class LMCaps: """Per-session, CID-keyed ceilings the LM handler enforces host-side. diff --git a/src/repl_sandbox/launcher.py b/src/repl_sandbox/launcher.py index 6320821..42d5444 100644 --- a/src/repl_sandbox/launcher.py +++ b/src/repl_sandbox/launcher.py @@ -28,17 +28,29 @@ from __future__ import annotations +import base64 +import io import os import platform import stat import subprocess +import tarfile import threading import time +import uuid from dataclasses import dataclass, field from typing import Callable, Protocol, runtime_checkable from repl_sandbox.audit import AuditLog -from repl_sandbox.config import SandboxConfig, VMADDR_CID_HOST, parse_version +from repl_sandbox.config import ( + CTR_NAMESPACE, + GUEST_IMAGE, + GUEST_IMAGE_DIGEST, + KATA_RUNTIME_HANDLER, + SandboxConfig, + VMADDR_CID_HOST, + parse_version, +) from repl_sandbox.errors import SandboxError from repl_sandbox.supervisor import GuestSupervisor from repl_sandbox.transport import Connection, LoopbackClient, LoopbackListener, serve_forever @@ -117,6 +129,81 @@ #: a real guest CID does. IN_PROCESS_CID = 3 +#: Where a real launcher starts minting session ids. Deliberately above +#: `IN_PROCESS_CID` so a real session's identifier can never be confused with +#: the double's constant in an audit log or a ledger key. +FIRST_LAUNCHER_CID = 16 + +#: Bound on one `ctr run -d`. A Kata boot returned in ~0.7s on the reference +#: host, but that host was idle; this is generous because the alternative to a +#: generous bound is a false refusal on a loaded host. +DEFAULT_BOOT_TIMEOUT_S = 180.0 + +#: Bound on the whole readiness wait, and the spacing between polls. Every stage +#: before the guest's own Python startup was complete within ~30 ms on the +#: reference host, so this budget is almost entirely for the interpreter coming +#: up inside the guest and for `GuestSupervisor` construction. +DEFAULT_READY_TIMEOUT_S = 90.0 +READY_POLL_INTERVAL_S = 0.25 + +#: Where the launcher unpacks the package and places the startup payload. Its +#: parent is what Tier-0 must grant read access to, which `guest_main` derives +#: from its own `__file__` rather than from this constant. +GUEST_ROOT = "/run/trellis" + +#: Bytes of base64 per `ctr task exec`. A single argv string is capped at 128 KiB +#: by the kernel (`MAX_ARG_STRLEN`), so a payload is appended in chunks. +EXEC_CHUNK_BYTES = 60_000 + + +def discover_vsock_uds(vmm_pid: int, *, proc_root: str = "/proc") -> str: + """The hybrid-vsock Unix socket the VMM created, found rather than assumed. + + The path convention (`/run/vc/vm//clh.sock`) is Kata's and could + move, so it is read out of the running VMM's own argv: the process names its + sandbox directory, and the socket is identified by being the one non-API + socket in it. A launcher that hard-coded the path would report "no bridge" + when what it means is "no socket where I looked". + + Observed on the reference host 2026-07-25: the VMM's argv carries only + `--api-socket /run/vc/vm//clh-api.sock`, and the vsock socket + (`clh.sock`) appears in that directory without ever being named on the + command line — which is exactly why the directory is listed rather than the + argv being scanned for the socket itself. + """ + try: + with open(os.path.join(proc_root, str(vmm_pid), "cmdline"), "rb") as handle: + argv = handle.read().split(b"\0") + except OSError as exc: + raise SandboxError(f"cannot read the command line of VMM pid {vmm_pid}: {exc}") from exc + + directory = None + for raw in argv: + token = raw.decode("utf-8", "replace") + if token.startswith("/run/") and "/vm/" in token: + directory = os.path.dirname(token) + break + if directory is None or not os.path.isdir(directory): + raise SandboxError( + f"no /run/**/vm/** path in VMM pid {vmm_pid}'s command line, so its " + "sandbox directory could not be located" + ) + + sockets = [] + for entry in sorted(os.listdir(directory)): + path = os.path.join(directory, entry) + try: + if stat.S_ISSOCK(os.stat(path).st_mode) and "api" not in entry: + sockets.append(path) + except OSError: + continue + if not sockets: + raise SandboxError( + f"{directory} holds no non-API socket; the VMM may not have been " + "configured with a vsock device" + ) + return sockets[0] + @dataclass(frozen=True) class PreflightResult: @@ -399,6 +486,56 @@ def timed(accel: str, workload: str | None) -> dict: return observed +def vmm_pids_carrying(name: str, *, proc_root: str = "/proc") -> list[int]: + """PIDs of real Cloud Hypervisor processes whose argv names this sandbox. + + Walks `/proc` directly rather than shelling out to `pgrep`, and the reason is + measured rather than stylistic. **`pgrep` excludes its own PID but not its + ancestors**, so on a host with zero VMMs running, a `pgrep -af + cloud-hypervisor` issued from any process whose own command line contains + that string matches *itself* — observed 2026-07-25 on the reference host, + with the calling interpreter returned as the sole hit. A launcher whose job + is to refuse a boot that produced no VM cannot use a check that invents one. + + So identity is `/proc//exe`, the kernel's own answer to what a process + is running, and the argv is used only to attribute a real VMM to a sandbox. + + **`/proc//comm` is not the discriminator and must not be used for it.** + `TASK_COMM_LEN` truncates it to 15 characters, so a genuine Cloud Hypervisor + reads `cloud-hyperviso` — a `comm == "cloud-hypervisor"` test refuses every + real VM while looking exactly like a correct check (measured the same day: + `comm='cloud-hyperviso' exe='/opt/kata/bin/cloud-hypervisor'`). + """ + found: list[int] = [] + try: + entries = os.listdir(proc_root) + except OSError: + return found + for entry in entries: + if not entry.isdigit(): + continue + pid = int(entry) + try: + executable = os.readlink(os.path.join(proc_root, entry, "exe")) + except OSError: + # A process that exited mid-walk, or one this uid cannot read. Both + # are ordinary; neither is a VMM this launcher started. + continue + if os.path.basename(executable) != CLOUD_HYPERVISOR_BIN: + continue + try: + with open(os.path.join(proc_root, entry, "cmdline"), "rb") as handle: + argv = handle.read().split(b"\0") + except OSError: + continue + # Whole-argument match, never substring containment: sandbox names are + # minted from a session id, so `sess-1` is a substring of `sess-10` and + # a containment test would attribute one session's VMM to another. + if any(name in token.decode("utf-8", "replace").split("/") for token in argv if token): + found.append(pid) + return found + + def _clip(text: object) -> str: """Bound a probe's output before it is kept for an operator to read.""" if not isinstance(text, str): @@ -427,11 +564,28 @@ def __init__( audit: AuditLog | None = None, probe_timeout_s: float = DEFAULT_PROBE_TIMEOUT_S, reserved_names: frozenset[str] | None = None, + boot_timeout_s: float = DEFAULT_BOOT_TIMEOUT_S, + namespace: str = CTR_NAMESPACE, + guest_image: str = GUEST_IMAGE, + guest_image_digest: str = GUEST_IMAGE_DIGEST, ) -> None: self.config = config self.run_cmd = run_cmd self.audit = audit self.probe_timeout_s = probe_timeout_s + #: A Kata boot is not a probe and does not share the probe bound. + self.boot_timeout_s = boot_timeout_s + #: The containerd namespace this launcher owns. Not `default`: rule + #: 19(a) asks a session to confirm a destructive step over its whole + #: reach, and nothing sharing `default` with the provisioner and every + #: past probe can say "everything here is mine". Kata's cgroup driver + #: also puts the namespace literally in the leaked-cgroup path. + self.namespace = namespace + #: Pinned by digest. Image stores are per-namespace (measured), so + #: owning a namespace means owning the pull. + self.config_guest_image = guest_image + self.guest_image_digest = guest_image_digest + self._cid_counter = FIRST_LAUNCHER_CID #: Carried for the guest supervisor this launcher will construct once #: the microVM launch path exists. Optional here and required there: #: `boot` refuses before reaching a supervisor, so a launcher built @@ -445,12 +599,18 @@ def __init__( # -- probe plumbing ---------------------------------------------------- - def _run(self, argv: list[str]) -> dict: + def _run(self, argv: list[str], *, timeout_s: float | None = None) -> dict: """Run one probe command and reduce it to facts. A missing binary, a timeout, and a non-zero exit are three different observations and are kept apart, because they point at three different fixes for the operator. + + `timeout_s` overrides the probe bound for calls that are not probes. A + Kata boot legitimately runs past `DEFAULT_PROBE_TIMEOUT_S` on a loaded + host, and a boot truncated by a probe-sized timeout reports as an + infrastructure error indistinguishable from every other one — passing on + an idle host and misfiring exactly where the margin matters. """ observed: dict = {"argv": list(argv)} try: @@ -458,7 +618,7 @@ def _run(self, argv: list[str]) -> dict: list(argv), capture_output=True, text=True, - timeout=self.probe_timeout_s, + timeout=self.probe_timeout_s if timeout_s is None else timeout_s, ) except FileNotFoundError as exc: observed["error"] = f"{argv[0]} was not found on PATH: {exc}" @@ -634,6 +794,70 @@ def _check_acceleration(self, failures: list[str]) -> dict: # -- boot -------------------------------------------------------------- + def mint_sandbox_name(self, session_id: str) -> str: + """A per-boot sandbox name: the session id, plus entropy this mints. + + The entropy is not decoration. The name is simultaneously the container + name, the attribution token in the VMM's argv, the `/run/vc/vm/` + directory and the shim-kill pattern, so two live sandboxes whose names + are prefixes of one another would cross-attribute. `sess-1` is a prefix + of `sess-10`, and a caller-supplied session id is exactly the kind of + value that produces such pairs — so the launcher never lets the caller's + id be the whole address. + """ + suffix = uuid.uuid4().hex[:10] + stem = "".join(char if char.isalnum() else "-" for char in session_id)[:32].strip("-") + return f"trellis-{stem}-{suffix}" if stem else f"trellis-{suffix}" + + def mint_cid(self) -> int: + """The host-assigned session id for the next boot. + + Under the ratified VMM there is no kernel-supplied CID on the host side + (INTERFACES section 3.1a) — this number is the host's own label, and its + soundness rests entirely on the launcher issuing it once and binding it + 1:1 to one sandbox's socket path. Monotonic rather than random so a + collision is impossible within a process rather than merely unlikely; + `TrellisSandboxHost.open_session` remains the authority that refuses a + CID already open, and a caller sharing one host across launchers gets + that refusal rather than silent reuse. + """ + value = self._cid_counter + self._cid_counter += 1 + return value + + def _ensure_image(self) -> None: + """Make the pinned image present in this launcher's namespace. + + **Measured 2026-07-25:** containerd image stores are per-namespace, so a + launcher that moved off `default` sees none of what the provisioner + pulled — `ctr -n run` fails with `image "...": not found`. Owning a + namespace therefore means owning the pull, which is why the digest is a + config value at all rather than living only in the provisioner. + """ + listed = self._run(["ctr", "-n", self.namespace, "images", "ls", "-q"]) + if listed.get("error"): + raise SandboxError(f"cannot list images in namespace {self.namespace}: {listed['error']}") + if self.config_guest_image in (listed.get("stdout") or "").split(): + return + + reference = f"{self.config_guest_image}@{self.guest_image_digest}" + pulled = self._run( + ["ctr", "-n", self.namespace, "images", "pull", reference], + timeout_s=self.boot_timeout_s, + ) + if pulled.get("error") or pulled.get("returncode") != 0: + raise SandboxError( + f"could not pull {reference} into namespace {self.namespace}: " + f"{pulled.get('error') or _clip(pulled.get('stderr'))}" + ) + # Tag so the digest-pinned pull is reachable by the plain reference the + # run command uses, mirroring what the provisioner does in `default`. + tagged = self._run( + ["ctr", "-n", self.namespace, "images", "tag", reference, self.config_guest_image] + ) + if tagged.get("error"): + raise SandboxError(f"could not tag {reference}: {tagged['error']}") + def boot(self, session_id: str) -> GuestHandle: """Gate the host, then claim one microVM for `session_id`. @@ -651,24 +875,279 @@ def boot(self, session_id: str) -> GuestHandle: "failed: " + "; ".join(result.failures) ) - # Everything above this line is built and probes a real host. What - # follows it — minting the guest image, launching Cloud Hypervisor with - # a chosen guest CID, and waiting for the supervisor to listen — is not - # built. The S2 spike (BUILD_PLAN section 5.2, PASS 2026-07-23) proved - # the `ctr run --runtime io.containerd.kata.v2` path boots a stateful - # guest and showed which host provisioning facts it needs, but a spike - # driving `ctr` by hand is not this launch path; see - # scripts/repl_sandbox_s2_probe.py. Raising here is the - # whole of the honesty: a launcher that returned a handle backed by - # nothing would be indistinguishable from a working one until the first - # exec, and would have already been counted as a boundary by then. - raise NotImplementedError( - f"host gate G1 passed for session {session_id}, but the microVM launch " - "path (guest image, Cloud Hypervisor launch with an assigned guest CID, " - "supervisor readiness) is BUILD_PLAN section 5.2 (S2) and is not built. " - "No guest was claimed." + name = self.mint_sandbox_name(session_id) + handle = KataGuestHandle( + config=self.config, + sandbox_name=name, + cid=self.mint_cid(), + namespace=self.namespace, + launcher=self, + audit=self.audit, ) + # Every allocation is recorded on the handle at the instant it is made, + # never after the sequence completes. `KataREPL.setup` assigns + # `self._guest` only once `boot` has *returned*, so a boot that raises + # partway leaves its caller with nothing to tear down — this method is + # structurally the only code that can release what it allocated, and a + # handle whose bookkeeping lags the host by even one step leaks exactly + # the middle of the sequence. + try: + self._ensure_image() + + started = time.monotonic() + run_result = self._run( + [ + "ctr", "-n", self.namespace, "run", "-d", + "--runtime", KATA_RUNTIME_HANDLER, + self.config_guest_image, name, "sleep", "infinity", + ], + timeout_s=self.boot_timeout_s, + ) + elapsed = time.monotonic() - started + if run_result.get("error") or run_result.get("returncode") != 0: + raise SandboxError( + f"`ctr run` for sandbox {name} did not start it: " + f"{run_result.get('error') or _clip(run_result.get('stderr'))}" + ) + # containerd registered the task, so the name is taken from here on + # whatever happens next. + handle.container_created = True + + # The refusal (BUILD_PLAN section 5.6 item 4). `ctr run -d` returning + # 0 means the shim accepted the task, which is not the same fact as a + # VMM existing. Measured on the reference host 2026-07-25: by the + # time `ctr run -d` returns, the VMM and its vsock socket are already + # present — the first poll 15 ms later found both. So an absent VMM + # here is not "not booted yet", it is "never booted", and waiting + # would only convert a clean refusal into a timeout. + pids = vmm_pids_carrying(name) + if not pids: + raise SandboxError( + f"`ctr run` for sandbox {name} exited 0 after {elapsed:.1f}s but no " + f"{CLOUD_HYPERVISOR_BIN} process carries that name: containerd " + "reported success without creating a VM, so there is no boundary " + "to hand back" + ) + handle.vmm_pids = tuple(pids) + handle.uds_path = discover_vsock_uds(pids[0]) + handle.install_package() + return handle + except BaseException: + handle.shutdown() + raise + + +class KataGuestHandle: + """One live Kata sandbox, from the host's side. + + Holds every allocation `KataLauncher.boot` made, and is the only object that + can release them: `KataREPL.setup` assigns its `_guest` after `boot` returns, + so a boot that fails partway has no caller able to tear it down. + + The four-call order is the backend's (`GuestHandle`), and `install_scaffold` + is the call that brings the guest process into existence — the same shape + `InProcessGuest.install_scaffold` already has, where constructing the + supervisor and starting it serving happen there rather than at boot. That is + forced rather than chosen: `GuestSupervisor` takes its scaffold and its + reserved-name pins as constructor arguments, so the payload must be complete + on disk before the guest's Python starts, and the scaffold is not known until + the backend materialises it for this CID. + """ + + def __init__( + self, + config: SandboxConfig, + sandbox_name: str, + cid: int, + namespace: str, + launcher: "KataLauncher", + audit: AuditLog | None = None, + ) -> None: + self.config = config + self.sandbox_name = sandbox_name + self.cid = cid + self.namespace = namespace + self.audit = audit + self._launcher = launcher + #: Set the instant `ctr run -d` returns 0 — before anything is checked + #: about whether a VM exists — because from that moment containerd holds + #: a record under this name that teardown must remove. + self.container_created = False + self.vmm_pids: tuple[int, ...] = () + self.uds_path: str | None = None + self.package_installed = False + self.serving = False + self._control_conn: Connection | None = None + + # -- setup steps ------------------------------------------------------- + + def start_bridge(self) -> None: + """Refuses, and names precisely what is unresolved. + + A launcher cannot honestly implement this step today, and a no-op would + be the worst available answer: `KataREPL.setup` calls it as "the bridge, + before any untrusted worker process", so a silent pass asserts a bridge + exists when nothing has been brought up. + + What is settled: the guest needs no loopback-to-vsock forwarder. The + forwarder of INTERFACES section 3.3 exists to carry an in-guest rlms + client's `AF_INET` traffic, and there is no rlms in the guest — the + materialised stubs dial `AF_VSOCK` directly (`guest_main.build_rpc_hook`). + + What is **not** settled, and is not this module's to decide: who stands + up a session's `LM_PORT`/`DB_PORT` listeners. `kata_repl.py`'s own step-2 + comment assigns that to the LM handler and the broker and calls the CID + binding "the backend's whole part in bringing those two channels up", + while no code anywhere binds a `HybridVsockListener` outside tests and + the probes. `KataLauncher` takes no host to serve them against, and + `GuestHandle` has no member for them. Guessing here would install a + composition decision the record does not make. + """ + raise SandboxError( + f"sandbox {self.sandbox_name} booted, but the host-side LM/DB listener " + "composition is unresolved: no code joins a launched guest to a " + "TrellisSandboxHost, and KataLauncher takes none. The guest needs no " + "in-guest forwarder (there is no rlms in the guest), so this step is " + "not the forwarder INTERFACES section 3.3 describes; what it needs is " + "an owner for the per-sandbox LM_PORT/DB_PORT listeners. Refusing " + "rather than passing silently, because setup() treats this call as " + "the bridge being up." + ) + + def install_package(self) -> None: + """Ship `repl_sandbox` into the guest. Called by `boot`, not by the backend.""" + payload = _package_tarball() + self._put_bytes(payload, f"{GUEST_ROOT}/repl_sandbox.tgz") + self._exec( + f"cd {GUEST_ROOT} && tar xzf repl_sandbox.tgz && rm -f repl_sandbox.tgz", + exec_id="unpack", + ) + self.package_installed = True + + def install_scaffold(self, stub_source: str) -> None: + """Place the startup payload and start the guest supervisor serving.""" + raise SandboxError( + "unreachable until start_bridge's composition is resolved; the guest " + "process would come up with no host listener to dial" + ) + + def control(self) -> Connection: + """Open the persistent control connection to the guest supervisor.""" + if not self.serving: + raise SandboxError("the guest is not serving yet; install the scaffold first") + raise SandboxError("unreachable until install_scaffold is reachable") + + # -- teardown ---------------------------------------------------------- + + def shutdown(self) -> None: + """Release everything this handle holds, and report what survived. + + Bounded and swallowing per step, like the probes' `destroy` — `ctr` + blocks indefinitely against a shim that has stopped answering, and a + `TimeoutExpired` escaping here would mask the failure that caused it. + Unlike the probes, this **re-checks reality afterwards and raises** if + something survived: a probe compensates for a total-swallow teardown with + its own separate verification pass, and a launcher has no such pass — + its caller records one audit line and moves on, so a `shutdown` that + absorbed everything would make that line unreachable even when a VMM is + still running. + """ + errors: list[str] = [] + + if self._control_conn is not None: + try: + self._control_conn.close() + except OSError: + pass + self._control_conn = None + + if self.container_created: + for argv in ( + ["ctr", "-n", self.namespace, "task", "kill", "-s", "SIGKILL", "-a", self.sandbox_name], + ["ctr", "-n", self.namespace, "task", "delete", "-f", self.sandbox_name], + ["ctr", "-n", self.namespace, "container", "delete", self.sandbox_name], + ): + observed = self._launcher._run(argv, timeout_s=30.0) + if observed.get("error") and "timed out" in str(observed["error"]): + self._kill_shim() + self._launcher._run(argv, timeout_s=30.0) + + survivors = vmm_pids_carrying(self.sandbox_name) + if survivors: + errors.append( + f"{CLOUD_HYPERVISOR_BIN} pids {survivors} still carry {self.sandbox_name} " + "after teardown" + ) + self.container_created = False + + self._audit("shutdown", errors=errors) + if errors: + raise SandboxError("; ".join(errors)) + + def _kill_shim(self) -> None: + """SIGKILL this sandbox's Kata shim so a wedged `ctr` call can complete.""" + found = self._launcher._run( + ["pgrep", "-f", f"containerd-shim-kata-v2.*{self.sandbox_name}"], timeout_s=15.0 + ) + for token in (found.get("stdout") or "").split(): + try: + os.kill(int(token), 9) + except (ValueError, ProcessLookupError, PermissionError, OSError): + continue + time.sleep(1.0) + + # -- internals --------------------------------------------------------- + + def _exec(self, script: str, *, exec_id: str, timeout_s: float = 120.0) -> str: + observed = self._launcher._run( + [ + "ctr", "-n", self.namespace, "task", "exec", + "--exec-id", exec_id, self.sandbox_name, "sh", "-c", script, + ], + timeout_s=timeout_s, + ) + if observed.get("error") or observed.get("returncode") != 0: + raise SandboxError( + f"guest exec {exec_id!r} in {self.sandbox_name} failed: " + f"{observed.get('error') or _clip(observed.get('stderr'))}" + ) + return observed.get("stdout") or "" + + def _put_bytes(self, raw: bytes, dest: str) -> None: + """Write bytes into the guest in argv-sized chunks.""" + encoded = base64.b64encode(raw).decode("ascii") + self._exec(f"mkdir -p {GUEST_ROOT} && : > {dest}.b64", exec_id=f"put-init-{uuid.uuid4().hex[:6]}") + for index in range(0, len(encoded), EXEC_CHUNK_BYTES): + chunk = encoded[index : index + EXEC_CHUNK_BYTES] + self._exec(f"printf %s {chunk} >> {dest}.b64", exec_id=f"put-{index}-{uuid.uuid4().hex[:6]}") + self._exec(f"base64 -d {dest}.b64 > {dest} && rm -f {dest}.b64", exec_id=f"put-fin-{uuid.uuid4().hex[:6]}") + + def _audit(self, event: str, **fields: object) -> None: + if self.audit is not None: + self.audit.record(self.cid, f"guest.{event}", **fields) + + +def _package_tarball() -> bytes: + """`repl_sandbox` as a gzipped tar, tests and caches excluded. + + The guest runs the same source the host does. Tests are excluded because + they run host-side and would otherwise ship the very literals the host is + the authority for. + """ + package = os.path.dirname(os.path.abspath(__file__)) + source_root = os.path.dirname(package) + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for root, dirs, files in os.walk(package): + dirs[:] = [d for d in dirs if d not in ("__pycache__", "tests")] + for name in sorted(files): + if not name.endswith(".py"): + continue + path = os.path.join(root, name) + tar.add(path, arcname=os.path.relpath(path, source_root)) + return buffer.getvalue() + # --------------------------------------------------------------------------- # The test double diff --git a/src/repl_sandbox/tests/test_config.py b/src/repl_sandbox/tests/test_config.py index 44ddaa1..013bf5c 100644 --- a/src/repl_sandbox/tests/test_config.py +++ b/src/repl_sandbox/tests/test_config.py @@ -99,3 +99,36 @@ def test_the_inbound_literal_cap_stays_under_a_frame() -> None: """`load_context` literals ride a frame too, so they cannot exceed one.""" config = SandboxConfig() assert config.byte_caps.inbound_per_call <= config.max_frame_len + + +def test_the_guest_image_pin_tracks_the_provisioner() -> None: + """The provisioner holds the authority; this module holds a copy. + + `scripts/provision_kata_host.sh` is what actually pulls and verifies the + image, so its two constants are the real pin. Python needs them because a + launcher owning a containerd namespace necessarily owns the pull -- image + stores are per-namespace, and `ctr -n run` against a namespace that + has not pulled reports the image as not found. + + Two copies of one pin is the drift shape S6-entry found in the reserved + names, so the copies are asserted against each other rather than each + against its own literal: a bump in the script reddens here first. + """ + import pathlib + import re + + from repl_sandbox.config import GUEST_IMAGE, GUEST_IMAGE_DIGEST + + script = pathlib.Path(__file__).resolve().parents[3] / "scripts" / "provision_kata_host.sh" + text = script.read_text(encoding="utf-8") + + image = re.search(r'^GUEST_IMAGE="([^"]+)"', text, re.MULTILINE) + digest = re.search(r'^GUEST_IMAGE_DIGEST="([^"]+)"', text, re.MULTILINE) + + # A pattern that stopped matching would make this test vacuously green, + # which is the one failure a copy-vs-copy check cannot afford. + assert image is not None, f"no GUEST_IMAGE assignment found in {script}" + assert digest is not None, f"no GUEST_IMAGE_DIGEST assignment found in {script}" + + assert GUEST_IMAGE == image.group(1) + assert GUEST_IMAGE_DIGEST == digest.group(1) diff --git a/src/repl_sandbox/tests/test_launcher.py b/src/repl_sandbox/tests/test_launcher.py index 53ad96a..10abffc 100644 --- a/src/repl_sandbox/tests/test_launcher.py +++ b/src/repl_sandbox/tests/test_launcher.py @@ -340,15 +340,75 @@ def test_boot_refuses_on_this_host_and_names_what_was_missing() -> None: def test_boot_gates_before_it_launches() -> None: - """With G1 satisfied the refusal changes: the unbuilt launch path is named. + """The gate runs to completion before the launch path is touched at all. - The order matters — a launcher that tried to start a VM before gating would - reach the launch path on a host with no KVM. + A launcher that started a VM before gating would build a sandbox on a host + with no KVM, so this asserts the order directly rather than inferring it + from whatever `boot` raises: every G1 probe must have run, and the first + command after them is the image check that opens the launch path. """ - with pytest.raises(NotImplementedError) as raised: - launcher().boot("session-1") - assert "not built" in str(raised.value) - assert "No guest was claimed." in str(raised.value) + sentinel = RuntimeError("launch path reached") + responses = dict(GOOD_PROBES) + responses["ctr -n trellis images ls -q"] = sentinel + run_cmd = fake_run_cmd(responses) + + with pytest.raises(RuntimeError) as raised: + KataLauncher( + SandboxConfig(), + run_cmd, + kvm_probe=kvm_present, + accel_benchmark=near_native, + ).boot("session-1") + assert raised.value is sentinel + + issued = [" ".join(call) for call in run_cmd.calls] + for probe in GOOD_PROBES: + assert probe in issued, f"the gate did not run {probe!r} before launching" + assert issued.index(probe) < issued.index("ctr -n trellis images ls -q") + + +def test_boot_refuses_when_containerd_reports_success_without_a_vm() -> None: + """`ctr run` exiting 0 is not the same fact as a VM existing. + + The shim can accept and register a task without Cloud Hypervisor ever + starting, and a launcher that trusted the exit code would hand back a handle + backed by nothing -- indistinguishable from a working one until the first + exec, by which time it has already been counted as a boundary. + """ + responses = dict(GOOD_PROBES) + responses["ctr -n trellis images ls -q"] = (0, "docker.io/library/python:3.12-slim\n", "") + launched: list[str] = [] + + def run_cmd(argv, capture_output=False, text=False, timeout=None): + key = " ".join(argv) + if key.startswith("ctr -n trellis run -d"): + launched.append(key) + return subprocess.CompletedProcess(list(argv), 0, "", "") + if key.startswith("ctr -n trellis task") or key.startswith("ctr -n trellis container"): + return subprocess.CompletedProcess(list(argv), 0, "", "") + if key.startswith("pgrep"): + return subprocess.CompletedProcess(list(argv), 1, "", "") + outcome = responses.get(key) + if outcome is None: + raise AssertionError(f"unexpected command: {key}") + returncode, stdout, stderr = outcome + return subprocess.CompletedProcess(list(argv), returncode, stdout, stderr) + + built = KataLauncher( + SandboxConfig(), + run_cmd, + kvm_probe=kvm_present, + accel_benchmark=near_native, + ) + # No VMM will ever be found: this test runs on a host with no Kata sandbox, + # so the /proc walk legitimately returns nothing for the minted name. + with pytest.raises(SandboxError) as raised: + built.boot("session-1") + + assert launched, "the test never reached `ctr run`, so it proves nothing about the refusal" + message = str(raised.value) + assert "exited 0" in message + assert "without creating a VM" in message def test_boot_refuses_an_empty_session_id() -> None: From e3040d6d0f92e6af7420bc40779afb0685164b39 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 20:24:26 -0500 Subject: [PATCH 04/13] The records track what the launcher now does, and what it stopped at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILD_PLAN §5.6 said `KataLauncher.boot` raises and the package has no guest entry point. Both were false as of this branch and the second was false before it — #190 built `guest_main.py`. §5.6 now carries the observed run, the three corrections the build owes to running rather than reading, and the gate ledger's S6 row says where it stopped. The eighth item is recorded as open rather than resolved. `start_bridge` refuses: the guest needs no in-guest forwarder, but who owns a session's LM_PORT/DB_PORT listeners is unsettled, and no item 1-7 named it. That reading went to a composed judge panel rather than being settled by the session that benefits from it — the no-forwarder half promoted unanimously, the proposed replacement was refused by all three seats on independent grounds, and the audit seat found the filing had omitted CONFORMANCE §2.1's still-standing "Option A ... is viable unchanged". C12's density-chain row asserted the same two falsified claims in the markdown and in the render's inline data. Both are densified in place rather than appended, and the render's script block was re-checked for parse — a hand-maintained artifact carrying executable data needs a syntax gate, not a proofread, and this one shipped broken once before. check:repo-surface PASS, density-trellis contract PASS, pytest 1048. --- docs/density-chain/DENSITY-CHAIN.html | 2 +- docs/density-chain/DENSITY-CHAIN.md | 2 +- .../repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md | 59 +++++++++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/density-chain/DENSITY-CHAIN.html b/docs/density-chain/DENSITY-CHAIN.html index aabe904..32c35f1 100644 --- a/docs/density-chain/DENSITY-CHAIN.html +++ b/docs/density-chain/DENSITY-CHAIN.html @@ -556,7 +556,7 @@

Provenance & the honest ledger

['C9','read and steer a served model’s functional-affect state in the residual stream','(nothing) — one 288-line docs-only record','entirely prerequisite: hosted arm → local backend → sidecar, and step one is a proposal','the instrument/actuator/mixture ladder; the controller frame; the judge-actuation hazard, held outside the repo'], ['C10','a capability claim is a hypothesis until a dated report retires it','OOLONG v1, update/poison/scale drills, effective-context rounds, citation A/B, wall-clock — all dated','anti-shortcut corpus v2 pinned zero-paid with no paid run; the negative-control doctrine is broader than its implementation; the drill-target gate has no live-database round-trip','real TREC import; adversarial corpora; 10k sweeps; multi-run variance replacing n=1; consensus writes'], ['C11','narrow, authenticated, admission-bounded doors; a contract about which record wins','HTTP/SSE API, A2A server, outbound MCP client (byte-identical when off); the hard rules and the root contract','the ratified surface checker was red at this commit because of the density-chain deletion','inbound MCP server surface with five open decisions; OAuth posture; the dual client+server role'], - ['C12','treat model-authored Python as hostile and own the boundary to the operator’s secrets','host-independent control plane; gate 1, S2, S3, S4 and in-guest hardening all host-passed — a real model composed the run_query facade against a real Postgres holding only a handle, and the worker is now capped, syscall-filtered and filesystem-scoped from inside while both channels still cross','the host intermittently wedges a container exec; NIC egress policy and the launch path are absent; the reaper is unproven against a real shim wedge; there is no guest entry point, so the only supervisor construction is a host-side double providing no isolation','the launch path; the equivalence harness, whose target is now stated with twelve clauses predicted false; hardening to the twelve requirements; a paramstyle line in the run_query doc; doubt-filter layers; warm pool; depth-2'], + ['C12','treat model-authored Python as hostile and own the boundary to the operator’s secrets','host-independent control plane; gate 1, S2, S3, S4, in-guest hardening and now a production launch path all host-passed — a real model composed the run_query facade against a real Postgres holding only a handle, the worker is capped, syscall-filtered and filesystem-scoped from inside while both channels still cross, and the launcher claims a real VMM in its own containerd namespace, refuses a shim that exited 0 without one, and releases what it allocated when it does','the host intermittently wedges a container exec; NIC egress policy is absent; the reaper is unproven against a real shim wedge; and the launch path stops at the bridge step, which refuses — the guest needs no in-guest forwarder, but who owns a session’s LM and DB listeners is unsettled, and a host-backed backend and real microVM transport have only ever been proven separately','the listener-ownership decision, which the paid equivalence gate needs and no build item named; the equivalence harness, whose target is now stated with twelve clauses predicted false; hardening to the twelve requirements; a paramstyle line in the run_query doc; doubt-filter layers; warm pool; depth-2'], ['C13','the account a system gives of itself must be derived from whatever enforces its behavior','the root contract, its machine twin, the surface checker in CI with its governed-headroom report; the descriptor composition, the surface registry, and the coverage diagnostic — 1 of 9 injected surfaces described','Phase 0 falsified its own specification; the telemetry gap is confirmed and deliberately unfixed; eight of nine surfaces still undescribed, now reported rather than remembered','llm_help; the remaining eight descriptors (no pin ceremony owed); a human-doc generator; the advisory-marking convention; the self-play gate'] ]; diff --git a/docs/density-chain/DENSITY-CHAIN.md b/docs/density-chain/DENSITY-CHAIN.md index d504591..726da12 100644 --- a/docs/density-chain/DENSITY-CHAIN.md +++ b/docs/density-chain/DENSITY-CHAIN.md @@ -134,7 +134,7 @@ across for one subsystem's arc. | **C9 Mechinterp sidecar** | read and steer a served model's functional-affect state in the residual stream | *(nothing)* — one 288-line docs-only record | entirely prerequisite: hosted arm → local backend → sidecar, and step one is a proposal | instrument/actuator/mixture ladder M1–M4; percolative-Ising controller; the judge-actuation hazard, held outside the repo | | **C10 Benchmarks & evidence** | a capability claim is a hypothesis until a dated report retires it | OOLONG v1, update/poison/scale drills, effective-context rounds, citation A/B, wall-clock — all dated | anti-shortcut corpus v2 pinned zero-paid with **no paid run**; the uncommitted nine-refusal sandbox drill is **[R]**-only, outside CI | real TREC import; adversarial corpora; 10k sweeps; multi-run variance replacing n=1; consensus writes | | **C11 Serving & governance** | narrow, authenticated, admission-bounded doors; a written contract about which record wins | HTTP/SSE API, A2A server, outbound MCP client (byte-identical when off); AGENTS.md, session governance, the root contract | the surface checker is **green again** (`20e94ae` restored the density-chain links); `KNOWN_ROUTES` mislabels two routes | inbound MCP server surface with five open decisions; OAuth posture; the dual client+server role | -| **C12 REPL sandbox** | treat model-authored Python as hostile and own the boundary between it and the operator's secrets | the host-independent control plane, merged with CI and npm callers; on one Hetzner AX41: **G1, S2, S3 `[R]`+`[A]`, S4 `[R]`+`[A]`, S5 `[R]`** — a microVM boots, a frame crosses, a real model drives `llm_query` and composes the `run_query` facade against a real Postgres holding only a handle, and Tier-0 now caps a fork bomb and denies a syscall and a write while both channels still cross | **still not a sandbox and must not be read as one**: the NIC egress policy and a production launch path are absent, `KataLauncher.boot` still raises, and there is no guest entry point — the only `GuestSupervisor` construction is a host-side double providing no isolation. Egress self-labels **weak**; the spend cap is between-calls, not intra-batch; the watchdog is unproven against real shim wedges | S6's build half and both probe halves — its entry decision is taken and its equivalence target stated, with twelve clauses predicted FALSE; then GB, GA-eq, GA-rt; doubt-filter Layers 1–2; warm pool; `max_depth` 2; a paramstyle line in the `run_query` doc; the remaining **[A]** halves (S6, GB, GA-eq), ≤$5, unspent | +| **C12 REPL sandbox** | treat model-authored Python as hostile and own the boundary between it and the operator's secrets | the host-independent control plane, merged with CI and npm callers; on one Hetzner AX41: **G1, S2, S3 `[R]`+`[A]`, S4 `[R]`+`[A]`, S5 `[R]`**, and **2026-07-25 a production launch path** — a microVM boots, a frame crosses, a real model drives `llm_query` and composes the `run_query` facade against a real Postgres holding only a handle, Tier-0 caps a fork bomb and denies a syscall and a write while both channels still cross, and `KataLauncher.boot` now claims a real VMM in its own containerd namespace, refuses a shim that exited 0 without one, and releases everything it allocated when it does | **still not a sandbox and must not be read as one**: the NIC egress policy is absent, and the launch path stops at `start_bridge`, which **refuses** — the guest needs no in-guest forwarder (no rlms there), but **who owns a session's `LM_PORT`/`DB_PORT` listeners is unsettled**: `kata_repl` assigns them to the LM handler and broker, `KataLauncher` takes no host, and `KataREPL`-with-a-host and real-Kata-transport have only ever been proven separately, each substituting for the other half. Egress self-labels **weak**; the spend cap is between-calls; the watchdog is unproven against real shim wedges | that listener-ownership decision, which S6's `[A]` gate needs and no build item named; then S6's equivalence harness against its stated target (twelve clauses predicted FALSE); GB, GA-eq, GA-rt; doubt-filter Layers 1–2; warm pool; `max_depth` 2; a paramstyle line in the `run_query` doc; the remaining **[A]** halves (S6, GB, GA-eq), ≤$5, unspent | | **C13 Self-describing surfaces** | the account a system gives of itself must be derived from whatever enforces its behavior | the root contract, its machine twin, and the deterministic surface checker in CI; the first shipped descriptor — `trellis_textedit`'s addendum composes, byte-identity pinned per arm | the record↔twin asymmetry is structural — the checker proves twin↔tree only; Phase 0 **falsified its own specification**; a newline-free bijection orphan is pinned in the guarded arm; `llm_help` stays **authorized and unbuilt** | `llm_help`; the surface registry and its coverage diagnostic; the remaining eight descriptors (no pin ceremony owed); a human-doc generator; the self-play discrimination gate | --- diff --git a/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md b/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md index b777e88..e27b527 100644 --- a/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md +++ b/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md @@ -98,7 +98,7 @@ requirement set) then C = acceptance, plus one PROPOSED side-track (the doubt fi | **S3** | `llm_query` over vsock — **[R]+[A] PASSED 2026-07-23** (§5.3) | A | S2 | Frame round-trips guest→host with parity → the vsock bridge + LM handler | **[R+A]** | toward gates 2, 4 | | **S4** | DB broker minimal proof — **[R]+[A] PASSED 2026-07-23** (§5.4) | A | S2 (reuses S3 bridge) | Real query, zero credential in guest → the host broker + NOSUPERUSER role + egress deny | **[R+A]** | toward gate 2 | | **S5** | Tier-0 in-guest hardening — **[R] PASSED 2026-07-23** (§5.5) | A | S2, S3, S4 | Scripted fork-bomb/syscall/write denied, channels survive → in-guest rlimits-after-privilege-drop + seccomp + Landlock + host watchdog | **[R]** | toward gate 2 (req 8) | -| **S6** | Author the `IsolatedEnv` subclass | A | S1–S5 | Unedited load → `execute_code` round-trips as `LocalREPL` → the `KataREPL` backend | **[R+A]** | gate 3 | +| **S6** | Author the `IsolatedEnv` subclass — **entry + launch built 2026-07-25** (§5.6); blocked on the listener-ownership gap | A | S1–S5 | Unedited load → `execute_code` round-trips as `LocalREPL` → the `KataREPL` backend | **[R+A]** | gate 3 | | **GB** | Security hardening to the 12 reqs | B | S3, S4, S5, S6 | Each of the 12 [ARCHITECTURE §7](REPL_SANDBOX_ARCHITECTURE.md) reqs mapped to an enforcing surface (§6) | **[R+A]** | gate 2 | | **GA-eq** | Equivalence acceptance | C | S6, GB | Scripted equivalence **and** a metered real-model equivalence run | **[R+A]** | gate 3 | | **GA-rt** | vsock-bridge red-team | C | S3, GB | Adversarial review + fuzzed frame parser → the vsock bridge, before it ships | **[R]** | gate 4 | @@ -782,11 +782,58 @@ What landed: harness exists**, with twelve clauses predicted FALSE today — the spike's expected yield, not a defect log against it. -**S6's remaining half, and why it is not a probe-authoring job.** `KataLauncher.boot` raises -`NotImplementedError` after the G1 gate passes: guest-image mint, Cloud Hypervisor launch with an -assigned CID, and supervisor readiness are unbuilt, and the package has no guest entry point — the -only `GuestSupervisor` construction is inside `InProcessGuest`, a host-side double that provides no -isolation. S2 proved the `ctr run --runtime io.containerd.kata.v2` path boots a stateful guest, but a +**BUILT 2026-07-25 — `KataLauncher.boot` claims a real microVM, and `start_bridge` refuses.** +Observed on the AX41: G1 PASS at ratio 12.7, boot in 34.8 s, the VMM pid found and its socket +discovered at `/run/vc/vm//clh.sock`, the guest importing the shipped package and +reporting its source root as `/run/trellis`. The negative control — VMM detection blinded against +a **real, successful** `ctr run` — fired the refusal and left zero containers, tasks, VMMs, vm +directories and namespace cgroups behind, which is the self-cleanup property observed rather than +argued. Three of the seven items below are closed (1 partially, 4, 6); item 5 is closed by a +monotonic host-assigned mint; items 2, 3 and 7 are open, and **an eighth is named below**. + +Three corrections the build owes to running rather than reading: + +- **`pgrep` cannot carry the refusal.** It excludes its own PID but not its ancestors, so on a host + with zero VMMs a `pgrep -af cloud-hypervisor` issued from any process whose command line contains + that string matches itself. Identity is `/proc//exe`. **`comm` is not a substitute**: + `TASK_COMM_LEN` truncates at 15 characters, so a genuine VMM reads `cloud-hyperviso` and the + obvious equality test refuses every real boot while looking correct. +- **Item 6 is a chain, not a flag.** Containerd image stores are per-namespace, so `ctr -n run` + fails with `image "...": not found` until that namespace has pulled. Owning a namespace means + owning the pull, which is why the digest is now `config.GUEST_IMAGE_DIGEST` with a test asserting + it tracks `provision_kata_host.sh`'s copy — the provisioner still holds the authority. +- **Readiness does not gate the refusal.** By the time `ctr run -d` returns, the VMM and its socket + already exist (first poll at 15 ms found both). An absent VMM is *never booted*, not *not booted + yet*, so waiting would convert a clean refusal into a timeout. + +**The eighth item, and the one this build stopped at.** `start_bridge()` raises rather than +passing. What is settled: the guest needs no in-guest loopback→vsock forwarder, because §3.3's +Option A presupposes an rlms client in the guest speaking `AF_INET` and there is no rlms in the +guest — `guest_main.build_rpc_hook` dials `AF_VSOCK` directly. What is **not** settled, and no item +1–7 names: **who stands up a session's `LM_PORT`/`DB_PORT` listeners.** `kata_repl.py`'s step-2 +comment assigns them to the LM handler and the broker and calls the CID binding "the backend's +whole part in bringing those two channels up"; `KataLauncher` takes no host to serve them against; +`GuestHandle` has no member for them; and no code outside tests and the probes binds a +`HybridVsockListener` at all. `KataREPL` is constructed in exactly one non-test place (`cli.py`, +with `InProcessLauncher`) and `TrellisSandboxHost` in the selftest and every probe — the two sets +never intersect, so real-Kata-transport and `KataREPL`-drives-the-host have each been proven once, +separately, each substituting for the other half. **S6's `[A]` gate requires exactly that union, +and it has never run.** A silent no-op here would assert a bridge that does not exist. + +That reading was put to a composed judge panel rather than settled by the session that benefits +from it (the claim shrinks its own build). Three seats, blind to the candidate at composition +time: the no-forwarder claim promoted unanimously; the proposal that `start_bridge` therefore means +"bind the host-side listeners" was **refused by all three on independent grounds**; and the audit +seat found the filing had omitted `CONFORMANCE.md` §2.1's still-standing "Option A … is viable +unchanged", which concerns host-side rlms and does not disturb the guest-side finding but should +have been filed. The gap is therefore recorded as open, not resolved. + +**What remains of the original framing.** `KataLauncher.boot` no longer raises after the G1 gate: +guest-image mint, the Kata launch, and package delivery are built; supervisor readiness and the +scaffold payload are not, because they sit behind the unresolved composition above. The package now +has a guest entry point (`guest_main.py`, built at S6 entry), so the only remaining +`GuestSupervisor` construction that runs is still `InProcessGuest`, a host-side double that provides +no isolation. S2 proved the `ctr run --runtime io.containerd.kata.v2` path boots a stateful guest, but a spike driving `ctr` by hand is not that launch path. Until it exists, an S6 PASS would be a claim about `KataREPL` and not yet about `KataLauncher`, and the equivalence harness has nothing to point at but the double. **That is the next build, and it is the one place where a harness would otherwise From 018ec47637e9d08e2d0f911d76e6d45fbc929e5d Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 22:30:55 -0500 Subject: [PATCH 05/13] C12's own section catches up with the launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit updated C12's row in the class-overview table, which is not the section the staleness check watches — the tier body at the bottom of the file is. Three of its claims were false: that the launch path is absent, that the lesson about portable enforcing surfaces had been paid twice, and that `boot` still raises with no guest entry point behind it. Densified in place rather than appended: T1 carries the third instance of the surface-moves lesson, T2 carries the launcher that boots and the observation that the host-to-guest composition exists only as a block hand-written five times outside the package, and T5 replaces the "next real build" paragraph with what the build found — the three measured corrections, and the refusal it stopped at. --- docs/density-chain/DENSITY-CHAIN.md | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/density-chain/DENSITY-CHAIN.md b/docs/density-chain/DENSITY-CHAIN.md index 726da12..cb50ac4 100644 --- a/docs/density-chain/DENSITY-CHAIN.md +++ b/docs/density-chain/DENSITY-CHAIN.md @@ -757,10 +757,11 @@ the RLM execution model, the doubts machinery it borrows, or the pillar it reali *listener*, never a frame; and deepest — the code may *address* data but never *hold* it (the handle data-flow rule). Language-level guards are telemetry, never a boundary. Status: a microVM boots, holds state, a real model answers a fan-out across the boundary, and a real database query now - crosses it holding a handle rather than a payload, and Tier-0 now caps the worker from inside — but - the launch path is absent, so this is still not a working sandbox and must not be read as one. - Building it cost the same lesson **twice**: an enforcing surface is only as portable as the mechanism - it names — first the vsock peer CID, then in-guest cgroups. + crosses it holding a handle rather than a payload, Tier-0 caps the worker from inside, and a + launcher now claims a real VMM or refuses — but the host end of the two outbound channels has no + owner, so this is still not a working sandbox and must not be read as one. Building it cost the same + lesson **three times**: an enforcing surface is only as portable as the mechanism it names — the + vsock peer CID, then in-guest cgroups, then the channel meant to carry the reserved names. - **T2 — current machinery.** Execution still runs in-process on `rlms==0.1.3` LocalREPL holding live credential-bearing clients. Beside it, a host-independent control plane at `src/repl_sandbox/` (~22 modules against ~22 test files): the frame codec (a declared fuzz target); a transport carrying @@ -768,10 +769,13 @@ the RLM execution model, the doubts machinery it borrows, or the pillar it reali guest-supervisor protocol; the handle table and slice algebra; the DB broker with Postgres/Neo4j backends, a statement inspector (`policy.py`), and a least-privilege role DDL; the LM handler with byte/rate/spend ledgers and a DLP hook; the capability lifecycle; a CID-keyed audit log; - `KataREPL(IsolatedEnv)`; and a `KataLauncher` whose four-condition `preflight` drives a real QEMU - benchmark — each transport-agnostic, exercised through loopback doubles. Eleven ratified documents. - Host-side, driving `ctr` directly: `provision_kata_host.sh`, the S2 and S3 probes, the S3 `[A]` - harness (`repl_sandbox_s3_paid.py`), and now the S4 probe. + `KataREPL(IsolatedEnv)`; a `guest_main` entry point binding **native** `AF_VSOCK` (the guest keeps + the kernel-supplied peer CID the host lost); and a `KataLauncher` that gates on a real QEMU + benchmark, then boots — minting a sandbox, owning a containerd namespace and therefore the digest + pull, refusing a shim that exited 0 without a VM, and releasing what it allocated when it does. + Eleven ratified documents. Host-side, driving `ctr` directly: `provision_kata_host.sh` and the S2–S5 + probes. **The composition that binds host chokepoints to a booted guest exists only as a block + hand-written five times across those probes and the CLI selftest — never in the package.** - **T3 — with receipts.** **G0 lifted 2026-07-22** by owner (`REPL_SANDBOX_BUILD_PLAN.md` §2, The research-hold gate) under two qualifications: G1 is unsatisfiable on the dev box, and a loopback double is never a boundary. **S1 closed** — a 12-test conformance pass over installed `rlms==0.1.3` @@ -852,11 +856,17 @@ the RLM execution model, the doubts machinery it borrows, or the pillar it reali Free and scheduled: a **nested guest** as the virgin instance the provisioner's never-executed install branch is owed; a second *machine* stays **deferred**, re-opening on a kernel-specific finding (vsock the likeliest, the hybrid correction its - first evidence, the cgroup correction its second). Then **S6's build half, which is not a - probe-authoring job**: `KataLauncher.boot` still raises and the package has **no guest entry - point** — the only `GuestSupervisor` construction is inside a host-side double that provides no - isolation — so the launch path S2 proved by driving `ctr` by hand is the next real build, and until - it exists an S6 pass would be a claim about `KataREPL` and not about `KataLauncher`. Then + first evidence, the cgroup correction its second). **S6's build half LANDED 2026-07-25**: `boot` + claims a real VMM (G1 ratio 12.7, 34.8 s, socket discovered, package shipped) or refuses and leaves + nothing — proven by blinding the VMM check against a *real* successful `ctr run`. Three findings the + code owes to running: `pgrep` matches its own ancestors, so on a host with **zero** VMs it invents + one; `comm` truncates at 15 chars (`cloud-hyperviso`), so the obvious fix refuses every real boot + and `/proc//exe` is the only sound discriminator; and image stores are per-namespace, so owning + a namespace means owning the digest pull. It stops at `start_bridge`, which **refuses**: the guest + needs no in-guest forwarder (no rlms there), but **who binds the host end of LM/DB is unowned** — a + blind three-seat panel promoted the negative and refused the proposed replacement, and the audit + seat caught the filing omitting `CONFORMANCE §2.1`. The composition is not unbuilt so much as + unhomed: it exists hand-written in five probe/selftest sites, never in the package. Then GB — which inherits S5's residuals: the watchdog is unproven against a real shim wedge, and the seccomp/allowlist divergence is recorded rather than resolved — GA-eq, GA-rt. Proposed: doubt-filter Layers 1–2. **`MAX_FRAME_LEN` RATIFIED 2026-07-24** — slice 2 MiB, frame 4 MiB, frame From 246644d6e02a3e5b2b7de9671b67bb3dae9add59 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:04:41 -0500 Subject: [PATCH 06/13] A system view of the REPL: what persists and what is borrowed The lifetime question asked in the last session presupposed that the durable thing and the executing thing are one object, which made a scope that ends look like a session that is lost. They are two objects. A workspace survives because its state is in the substrate; the microVM is compute borrowed for one stretch of work. The diagram places the REPL at the centre and draws that separation: many swappable workspaces on durable storage, one loaded into a persistent namespace, an ephemeral VM shell around it carrying the three ports, the model holding addresses rather than bytes, and the artifact loop closing back into the store through the judges and the user gate. It also marks the open seam honestly: the host end of LM and DB has no owner. --- docs/architecture/trellis_repl_system.svg | 159 ++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/architecture/trellis_repl_system.svg diff --git a/docs/architecture/trellis_repl_system.svg b/docs/architecture/trellis_repl_system.svg new file mode 100644 index 0000000..71bcd1d --- /dev/null +++ b/docs/architecture/trellis_repl_system.svg @@ -0,0 +1,159 @@ + + The REPL inside Trellis — what persists, what is ephemeral, and where the loop closes + A system view. Many swappable workspaces sit in durable storage. One is loaded into a persistent REPL namespace. A microVM is an ephemeral shell placed around that namespace for one working session. The model holds addresses, never bytes. A response artifact composed in the REPL is filed back into the store, judged, given standing at a user gate, and becomes input to the next query. + + + + + + + + + + + + Everything resides on the REPL + What persists is the workspace and its store. The microVM is a shell placed around one working session — which is why the composition is a context manager. + + + + Workspaces · swappable · many + + + + philosophy + + parts inventory · manuals · receipts + + Trellis' own source + self-modification: the repo is the artifact + + + + physics + loaded this session + + + A workspace is not a chat thread. It carries its + domain, the artifacts it has produced, and its own + past — and it is still there when the VM is gone. + + + load + + + + Kata microVM · ephemeral · one session + hardware boundary · torn down at exit · holds no credential + + + + The REPL — persistent namespace + gigabytes, read in slices. The corpus ceiling is address space. + + + + context + handles, not payloads + + + facts · beliefs · doubts + three pre-allocated roots + + + artifacts + what past turns built + + + model-authored code + untrusted; runs here + + + State survives a turn because it is written to the store — + never because this process stayed alive. + + + Three ports through the wall + CONTROL 5003 · host dials in + LM 5001 · guest dials out + DB 5002 · guest dials out + ← host end unowned + ← host end unowned + + + + The model + Holds addresses. Never the corpus. + Slices by reference; the engine moves + the bytes and does the counting. + llm_query · flat fan-out, depth 1 + + + + ask + answer + + + + The substrate — durable, content-addressed, append-only + Every node addressed by its SHA-256 preimage and final at write time. Corrections are written beside, never over. + AST nodes · document versions · the belief graph · provenance the write path enforces + This is what makes a workspace survivable. The VM above it is disposable; this is not. + + + slices, by address + + + the artifact, filed + + + + The loop that closes + A query does not return a reply. It builds a + response artifact — derived, never regurgitated. + Judges compose per context and evaluate it. + The user gate ratifies standing: + −1 doubt · 0 belief · +1 fact + Then it is corpus. Output becomes input. + + + + + + + Why this settles the lifetime question + The durable thing and the executing thing are different objects. A workspace outlives every session because its state is in the + substrate; the microVM is compute borrowed for one stretch of work. So a scope that ends is not a session that is lost — it is a VM + released. rlms already models this: its driver ends a non-persistent run with cleanup(). A context manager is that lifecycle in Python, + and it makes the one genuinely unrecoverable mistake — leaking a live VM — impossible to make by forgetting. + From 8a7cd1f070dda5f2f64e201ab82a5419ff4d3cc9 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:33:13 -0500 Subject: [PATCH 07/13] The REPL is the workspace and its store; the VM is borrowed compute An owner exchange established that a session's question about the sandbox composition's lifetime rested on a false premise: it treated the REPL and the microVM as one object, which makes a closing scope look like a lost session. They are two objects, and the record now says so before a later session presupposes it again. THE_REPL_IN_TRELLIS.md documents the system diagram and the understanding behind it: workspaces are many, swappable and durable (a repository is one, which is how Trellis edits Trellis); one workspace per session; the namespace holds handles rather than payloads; the microVM is torn down at session end and nothing that matters is lost when it goes. It answers the persistence question the owner put back to the session. A workspace does not remember by snapshotting its namespace, on four grounds of which the first is decisive: the namespace holds model-authored objects, and deserialising them outside the sandbox is an arbitrary-code-execution primitive running with the host's privileges. It remembers by reconstruction over its own store plus a manifest -- which means the artifact loop is not a feature on top of persistence, it IS the persistence mechanism, and a result that was never filed was never meant to survive. Concurrency is restated correctly after two wrong framings in as many sessions. Instances run in parallel, one per database; workspaces are what must not be shared, and checkout is the lock. That forces a correction on the launcher's own commit message: the containerd namespace is a constant, so parallel instances share it, and a cleanup sweep must key on the sandbox name it minted rather than on the namespace. Owed and not done here: an index row for the new record. docs/README.md has ~28 bytes of headroom against a checked budget, so it needs a densifying pass rather than an append. --- docs/architecture/THE_REPL_IN_TRELLIS.md | 186 +++++++++++++++++++++++ src/repl_sandbox/tests/test_launcher.py | 64 ++++++++ 2 files changed, 250 insertions(+) create mode 100644 docs/architecture/THE_REPL_IN_TRELLIS.md diff --git a/docs/architecture/THE_REPL_IN_TRELLIS.md b/docs/architecture/THE_REPL_IN_TRELLIS.md new file mode 100644 index 0000000..b2997ab --- /dev/null +++ b/docs/architecture/THE_REPL_IN_TRELLIS.md @@ -0,0 +1,186 @@ +# The REPL in Trellis — what persists, what is borrowed + +**Status: understanding record, owner-directed 2026-07-25.** Written because a +session asked whether the sandbox composition should be a context manager and +got the answer *"I'm surprised you would even ask"* — the question presupposed +something false about the REPL, and this record fixes the correct picture so a +later session does not presuppose it again. Diagram: +[the REPL in the system](trellis_repl_system.svg). + +Authority: this record describes and does not decide. Where it and a ratified +record disagree, the record wins — `REPL_SANDBOX_ARCHITECTURE.md` for the +boundary, `CODE_MEDIATED_TEXT.md` for the pillar, `FEATURE_LIST.md` for the +deployment ruling, `AGENTS.md` rule 24 for what is being built. + +## 1. The mistake this record exists to prevent + +**The durable thing and the executing thing are different objects.** A session +that treats them as one reaches a false dilemma: if the REPL *is* the microVM, +then a scope that closes destroys a workspace that must outlive it, and any +unmissable-cleanup construct looks unsafe. + +Both halves of that are wrong. A workspace persists because its state is in the +substrate. The microVM is compute borrowed for one stretch of work, holding no +credential and no unique copy of anything. **A scope that ends is a VM released, +not a session lost.** + +The tell that this confusion is happening: a sentence that treats *"the REPL is +long-lived"* and *"the process must be long-lived"* as the same claim. The first +is true and load-bearing. The second is false, and believing it is how a design +ends up leaking VMs to protect state that was never in them. + +## 2. What the diagram shows + +Read left to right, then down. + +**Workspaces — many, swappable, durable.** A workspace is not a conversation +thread. It carries a domain, the artifacts it has produced, and its own past. +A polymath runs physics on Tuesday and philosophy on Thursday; a mechanic keeps +parts inventory, manuals, and customer transactions as separate workspaces. +**A repository is a workspace too**, which is how Trellis edits Trellis: its own +source is loaded as REPL contents, and the repo is that session's response +artifact. + +**One workspace per session** (owner ruling, 2026-07-25). Swapping workspaces is +a new session, not a re-pointing of a live one, which is what makes the session +scope a coherent unit to bound. + +**The REPL — the persistent namespace.** Where a workspace is worked on. It holds +`context` as *handles rather than payloads*, the three pre-allocated roots for +facts, beliefs and doubts, the artifacts previous turns built, and the +model-authored code that runs against all of it. It is meant to be gigabytes +read in slices; the corpus ceiling is address space, not any wire bound. + +**The microVM — ephemeral, one session.** A hardware boundary around the +namespace, carrying three vsock ports. It is torn down at session end. Nothing +that matters is lost when it goes, and that is a design property rather than an +accident. + +**The model — holds addresses, never the corpus.** It supplies identifiers, +parameters, and prose it is authoring for the first time. The engine does the +counting and moves the bytes. Sub-model calls fan out flat at depth 1. + +**The substrate — durable, content-addressed, append-only.** Every node addressed +by its SHA-256 preimage and final at write time; corrections written beside, +never over. This is what makes a workspace survivable. + +**The loop that closes.** A query does not return a reply; it builds a response +artifact, derived rather than regurgitated. The artifact is filed back into the +store, judges compose per context and evaluate it, and a user gate ratifies +standing — doubt, belief, or fact. Then it is corpus, and it is input to the next +query. Output becomes input. + +## 3. How a workspace remembers + +The question was put as *"by using binary?"* — and the honest answer separates +two things that word can mean, because one of them is right and the other would +be a serious defect. + +### Not by snapshotting the namespace + +Serialising the live Python namespace and restoring it next session is the +mechanism to refuse, on four independent grounds. Any one of them is +disqualifying; the first is decisive. + +1. **It inverts the boundary.** The namespace holds objects created by + model-authored code, which is untrusted by construction. Deserialising + attacker-influenced bytes is an arbitrary-code-execution primitive, and the + party doing the restoring sits *outside* the sandbox. The whole point of the + microVM is that nothing crosses outward except values that have been through a + validating boundary; a namespace snapshot is the largest possible unvalidated + crossing, running with the host's privileges. +2. **It has no content identity.** A snapshot is opaque bytes with no Merkle + preimage, no provenance, and no way to be addressed or sliced. It cannot + participate in the append-only store, cannot be cited as a `sourceNodeId`, and + cannot be contested or superseded. +3. **It cannot be the size the REPL is meant to be.** A workspace is gigabytes. + A restore path bounded by address space and frame size is bounded far below + the thing it claims to persist. +4. **It is silently partial.** Sockets, file handles, the RPC hook and generators + do not serialise. A snapshot that skips them restores a namespace that looks + complete and is not. + +### By reconstruction, from a manifest, over the workspace's own store + +What actually persists is already persisted, by the loop in §2. **The durable +state of a workspace is its database plus a manifest**, and the namespace is +*rebuilt* at session open rather than restored: + +- the substrate holds the content — AST nodes, document versions, the belief + graph, provenance the write path enforced on the way in; +- the manifest names what this workspace *is*: which document versions are live, + which root handles are pre-allocated, which artifacts exist and with what + standing; +- session open re-issues the handles, re-binds `context`, and re-materialises the + scaffold. Nothing model-authored is restored as a code object. + +The consequence worth stating plainly: **a turn's durable output is the filed +artifact, not the namespace.** The namespace is deliberately disposable, and the +artifact loop is not a nice-to-have on top of persistence — it *is* the +persistence mechanism. A result that was never filed was never meant to survive. + +### Where a binary genuinely belongs + +The instinct is sound about a different object. A workspace being **one +addressable, movable, lockable thing** is exactly what makes checkout mechanical +— see §4. That is a binary as *envelope and lease*, not as a pickled namespace. + +## 4. Concurrency, stated correctly + +Three claims that are easy to blur, kept apart: + +| | | +|---|---| +| **Sessions per workspace** | one, at a time. A workspace is *checked out* so a second Trellis cannot edit it. No concurrency by design. | +| **Sessions per machine** | many. A machine may run inventory, billing and parts in parallel — each its own Trellis instance, its own database, isolated. | +| **Workspaces per session** | one (§2). | + +So the earlier framing of concurrency as *"two sessions must not share a socket +path"* was answering the wrong question, and withdrawing it on the ground that +the deployment is one-user-one-instance was also wrong. **Instances are what run +in parallel; workspaces are what must not be shared.** The isolation between +parallel instances comes from separate databases, not from a lock; the lock +exists to stop two instances opening the *same* workspace. + +**What this means for the launcher, checked against the code.** Parallel +instances on one host are already safe in the transport, for a reason worth +recording: every identifier that is *shared* across instances is per-sandbox and +carries entropy (the sandbox name, the `/run/vc/vm/` directory, the vsock +socket path), and every identifier that is *not* unique across instances is +per-process (the guest CID, the session table, the ledgers) and is never compared +across process boundaries. + +One correction this forces on `KataLauncher`'s own commit message: the +containerd namespace is a *constant*, so parallel instances share it. "Everything +under `/sys/fs/cgroup/trellis` is mine" is therefore false for any one instance, +and a cleanup sweep must key on the sandbox name it minted rather than on the +namespace. The namespace still earns its place — it separates Trellis from the +provisioner and from every past probe — but it separates Trellis from *others*, +not instances from *each other*. + +## 5. The deployment shape this implies + +Trellis is hosted — a laptop, a home server, a rack — and the user reaches their +Trellises through a desktop app or a web interface, with a mobile remote control +later. That is a proposal on the record here, not a ratified decision. + +Its architectural consequence is worth naming because it removes work: **if +clients are thin remotes onto one host, cross-device workspace checkout never +arises.** The lease is only ever contended between instances on the same machine, +which is a local lock rather than a distributed one — the difference between a +file lease and a consensus problem. A design that assumed roaming devices editing +the same workspace would have bought the hard version of this for no reason. + +## 6. What is not settled + +- **The manifest's shape and where it lives.** §3 says a workspace is its store + plus a manifest; nothing in the tree implements one yet. +- **The lease mechanism.** Checkout is ruled; the mechanism is open. Whatever it + is, it is standing state a later session loads without asking, so it is + gated (rule 21(b)). +- **Repo-as-workspace write-out.** A repository is a workspace, and edits go + through the engine under hash guards; the step that writes substrate content + back to files on disk is not described here because this record's author has + not traced it. +- **The host end of `LM_PORT`/`DB_PORT`** — the open seam the diagram marks. See + `REPL_SANDBOX_BUILD_PLAN.md` §5.6. diff --git a/src/repl_sandbox/tests/test_launcher.py b/src/repl_sandbox/tests/test_launcher.py index 10abffc..aa94a7a 100644 --- a/src/repl_sandbox/tests/test_launcher.py +++ b/src/repl_sandbox/tests/test_launcher.py @@ -28,7 +28,10 @@ InProcessLauncher, KataLauncher, PreflightResult, + SHIM_EXECUTABLE_NAME, _benchmark_argv, + _pids_by_executable, + vmm_pids_carrying, probe_kvm_device, qemu_accel_benchmark, ) @@ -716,3 +719,64 @@ def test_an_unproven_benchmark_fails_the_gate_with_its_own_reason(tmp_path, init ).preflight() assert result.ok is False assert "is not a file" in only(result.failures) + + +# --------------------------------------------------------------------------- +# Process identity — the kernel's answer, never a command-line pattern +# --------------------------------------------------------------------------- + + +def _fake_proc(tmp_path, entries: dict[int, tuple[str, list[str]]]) -> str: + """A `/proc` stand-in: {pid: (exe_target, argv)}.""" + for pid, (target, argv) in entries.items(): + d = tmp_path / str(pid) + d.mkdir() + try: + os.symlink(target, d / "exe") + except (OSError, NotImplementedError): + pytest.skip("this platform cannot create the symlink /proc//exe is") + (d / "cmdline").write_bytes(b"\0".join(a.encode() for a in argv) + b"\0") + (tmp_path / "uptime").write_text("noise") # a non-numeric entry must be skipped + return str(tmp_path) + + +def test_a_process_merely_naming_the_vmm_is_not_the_vmm(tmp_path) -> None: + """The trap that has now fired four times on the reference host. + + `pgrep` matches any process whose command line contains the pattern, + including the caller's own ancestors. On a host with zero VMs that returns a + hit, and a launcher whose job is refusing a boot that produced no VM cannot + use a check that invents one. Identity is `/proc//exe`. + """ + proc = _fake_proc(tmp_path, { + # An impostor: a shell whose argv carries every string a pattern match + # would key on, including the sandbox name. + 11: ("/usr/bin/bash", ["bash", "-c", "pgrep -af cloud-hypervisor sandbox-a"]), + # The real thing. + 22: ("/opt/kata/bin/cloud-hypervisor", ["cloud-hypervisor", "--api-socket", + "/run/vc/vm/sandbox-a/clh-api.sock"]), + }) + assert vmm_pids_carrying("sandbox-a", proc_root=proc) == [22] + + +def test_a_sandbox_name_matches_as_a_path_component_not_a_substring(tmp_path) -> None: + """`sess-1` is a substring of `sess-10`; containment would cross the wires.""" + proc = _fake_proc(tmp_path, { + 33: ("/opt/kata/bin/cloud-hypervisor", ["cloud-hypervisor", "--api-socket", + "/run/vc/vm/sess-10/clh-api.sock"]), + }) + assert vmm_pids_carrying("sess-10", proc_root=proc) == [33] + assert vmm_pids_carrying("sess-1", proc_root=proc) == [] + + +def test_the_shim_lookup_uses_the_same_kernel_answer(tmp_path) -> None: + """Teardown SIGKILLs what this returns, so a wrong match kills a process. + + Observed 2026-07-25: the previous `pgrep -f 'containerd-shim-kata-v2.*'` + form, run from a shell during cleanup, matched the shell itself and killed it. + """ + proc = _fake_proc(tmp_path, { + 44: ("/usr/bin/bash", ["bash", "-c", "kill $(pgrep -f containerd-shim-kata-v2.*sandbox-a)"]), + 55: ("/opt/kata/bin/containerd-shim-kata-v2", ["containerd-shim-kata-v2", "-id", "sandbox-a"]), + }) + assert _pids_by_executable(SHIM_EXECUTABLE_NAME, carrying="sandbox-a", proc_root=proc) == [55] From 65997ec064f0bf9c0e6a6e7169228795de56eb38 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:34:38 -0500 Subject: [PATCH 08/13] No pgrep reaches a kill, because one already killed the wrong process `_kill_shim` found its target with `pgrep -f 'containerd-shim-kata-v2.*'` and SIGKILLed whatever came back. During cleanup on the reference host an operator ran that same pattern from a shell and killed the shell itself: pgrep matches any process whose command line contains the pattern, and the invoking command line did. That is the fourth time this trap has fired here and the first time it destroyed something. `vmm_pids_carrying` already avoided it by reading `/proc//exe`. The fix is to make that the only way this module identifies a process: `_pids_by_executable` carries the walk, `vmm_pids_carrying` delegates, and `_kill_shim` uses it. The `carrying` filter matches the sandbox name as a whole path component rather than a substring, so `sess-1` cannot claim `sess-10`'s shim. Three checks over a fake `/proc`, each watched failing against the behaviour they replace. The first draft of two of them passed the planted breakage -- their impostors were being caught by the name filter rather than by the executable check, so they proved nothing about the fix. The impostors now carry the sandbox name as a real path component, which leaves the executable check as the only thing that can catch them; planting the pgrep behaviour reddens exactly those two. pytest 1048 -> 1051. --- src/repl_sandbox/launcher.py | 104 ++++++++++++++++-------- src/repl_sandbox/tests/test_launcher.py | 6 +- 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/repl_sandbox/launcher.py b/src/repl_sandbox/launcher.py index 42d5444..43b13c5 100644 --- a/src/repl_sandbox/launcher.py +++ b/src/repl_sandbox/launcher.py @@ -66,6 +66,10 @@ KATA_RUNTIME_BIN = "kata-runtime" CLOUD_HYPERVISOR_BIN = "cloud-hypervisor" +#: The containerd shim binary Kata runs per sandbox. Named so teardown can +#: identify it by `/proc//exe` rather than by a command-line pattern. +SHIM_EXECUTABLE_NAME = "containerd-shim-kata-v2" + #: Bound on any probe subprocess. `kata-runtime check` talks to the kernel and #: to containerd; a hung probe must not hang the gate. DEFAULT_PROBE_TIMEOUT_S = 60.0 @@ -486,10 +490,56 @@ def timed(accel: str, workload: str | None) -> dict: return observed +def _pids_by_executable( + executable: str, *, carrying: str | None = None, proc_root: str = "/proc" +) -> list[int]: + """PIDs whose `/proc//exe` basename is `executable`. + + The kernel's answer to *what is this process running*, which is a different + question from *what string appears on its command line* — and only the first + is safe to act on. `carrying`, when given, additionally requires the sandbox + name as a whole path component of some argument. + """ + found: list[int] = [] + try: + entries = os.listdir(proc_root) + except OSError: + return found + for entry in entries: + if not entry.isdigit(): + continue + try: + resolved = os.readlink(os.path.join(proc_root, entry, "exe")) + except OSError: + # Exited mid-walk, or not ours to read. Neither is a process this + # launcher started, and neither is one it may signal. + continue + if os.path.basename(resolved) != executable: + continue + if carrying is not None: + try: + with open(os.path.join(proc_root, entry, "cmdline"), "rb") as handle: + argv = handle.read().split(b"\0") + except OSError: + continue + # Whole path component, never substring containment: sandbox names + # are minted from a session id, so `sess-1` is a substring of + # `sess-10` and containment would attribute one session's VMM to + # another. + if not any( + carrying in token.decode("utf-8", "replace").split("/") + for token in argv + if token + ): + continue + found.append(int(entry)) + return found + + def vmm_pids_carrying(name: str, *, proc_root: str = "/proc") -> list[int]: """PIDs of real Cloud Hypervisor processes whose argv names this sandbox. - Walks `/proc` directly rather than shelling out to `pgrep`, and the reason is + Identity comes from `/proc//exe` rather than from `pgrep`, and the reason is measured rather than stylistic. **`pgrep` excludes its own PID but not its ancestors**, so on a host with zero VMMs running, a `pgrep -af cloud-hypervisor` issued from any process whose own command line contains @@ -506,34 +556,7 @@ def vmm_pids_carrying(name: str, *, proc_root: str = "/proc") -> list[int]: real VM while looking exactly like a correct check (measured the same day: `comm='cloud-hyperviso' exe='/opt/kata/bin/cloud-hypervisor'`). """ - found: list[int] = [] - try: - entries = os.listdir(proc_root) - except OSError: - return found - for entry in entries: - if not entry.isdigit(): - continue - pid = int(entry) - try: - executable = os.readlink(os.path.join(proc_root, entry, "exe")) - except OSError: - # A process that exited mid-walk, or one this uid cannot read. Both - # are ordinary; neither is a VMM this launcher started. - continue - if os.path.basename(executable) != CLOUD_HYPERVISOR_BIN: - continue - try: - with open(os.path.join(proc_root, entry, "cmdline"), "rb") as handle: - argv = handle.read().split(b"\0") - except OSError: - continue - # Whole-argument match, never substring containment: sandbox names are - # minted from a session id, so `sess-1` is a substring of `sess-10` and - # a containment test would attribute one session's VMM to another. - if any(name in token.decode("utf-8", "replace").split("/") for token in argv if token): - found.append(pid) - return found + return _pids_by_executable(CLOUD_HYPERVISOR_BIN, carrying=name, proc_root=proc_root) def _clip(text: object) -> str: @@ -1086,14 +1109,23 @@ def shutdown(self) -> None: raise SandboxError("; ".join(errors)) def _kill_shim(self) -> None: - """SIGKILL this sandbox's Kata shim so a wedged `ctr` call can complete.""" - found = self._launcher._run( - ["pgrep", "-f", f"containerd-shim-kata-v2.*{self.sandbox_name}"], timeout_s=15.0 - ) - for token in (found.get("stdout") or "").split(): + """SIGKILL this sandbox's Kata shim so a wedged `ctr` call can complete. + + Identity is `/proc//exe`, for the same reason `vmm_pids_carrying` + uses it and with more at stake: this call sends SIGKILL, so a wrong match + is not a wrong answer but a killed process. + + The earlier form shelled out to + `pgrep -f 'containerd-shim-kata-v2.*'`. **Observed 2026-07-25:** an + operator ran that pattern from a shell during cleanup and killed the + shell itself, because `pgrep` matches any process whose command line + contains the pattern and the invoking command line did. Survivable in a + probe, not in shipped teardown, so no `pgrep` reaches a `kill` here. + """ + for pid in _pids_by_executable(SHIM_EXECUTABLE_NAME, carrying=self.sandbox_name): try: - os.kill(int(token), 9) - except (ValueError, ProcessLookupError, PermissionError, OSError): + os.kill(pid, 9) + except (ProcessLookupError, PermissionError, OSError): continue time.sleep(1.0) diff --git a/src/repl_sandbox/tests/test_launcher.py b/src/repl_sandbox/tests/test_launcher.py index aa94a7a..ff10303 100644 --- a/src/repl_sandbox/tests/test_launcher.py +++ b/src/repl_sandbox/tests/test_launcher.py @@ -751,7 +751,8 @@ def test_a_process_merely_naming_the_vmm_is_not_the_vmm(tmp_path) -> None: proc = _fake_proc(tmp_path, { # An impostor: a shell whose argv carries every string a pattern match # would key on, including the sandbox name. - 11: ("/usr/bin/bash", ["bash", "-c", "pgrep -af cloud-hypervisor sandbox-a"]), + 11: ("/usr/bin/bash", ["bash", "-c", "pgrep -af cloud-hypervisor", + "/run/vc/vm/sandbox-a/clh-api.sock"]), # The real thing. 22: ("/opt/kata/bin/cloud-hypervisor", ["cloud-hypervisor", "--api-socket", "/run/vc/vm/sandbox-a/clh-api.sock"]), @@ -776,7 +777,8 @@ def test_the_shim_lookup_uses_the_same_kernel_answer(tmp_path) -> None: form, run from a shell during cleanup, matched the shell itself and killed it. """ proc = _fake_proc(tmp_path, { - 44: ("/usr/bin/bash", ["bash", "-c", "kill $(pgrep -f containerd-shim-kata-v2.*sandbox-a)"]), + 44: ("/usr/bin/bash", ["bash", "-c", "kill $(pgrep -f containerd-shim-kata-v2)", + "/run/vc/vm/sandbox-a/clh.sock"]), 55: ("/opt/kata/bin/containerd-shim-kata-v2", ["containerd-shim-kata-v2", "-id", "sandbox-a"]), }) assert _pids_by_executable(SHIM_EXECUTABLE_NAME, carrying="sandbox-a", proc_root=proc) == [55] From 17911a3b5899c9347eb65f9b219d8f40b8f55a8a Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:35:46 -0500 Subject: [PATCH 09/13] Document the system view, including what the diagram corrected Owner-directed companion to trellis_repl_system.svg. It records what the diagram claims and where each claim comes from, so a later session checks the picture against the code rather than against prose. The reason it exists is the error at its top: a session asked whether the sandbox composition should be a context manager, framing it as unmissable cleanup versus a session outliving its scope. That tension was manufactured by treating the REPL and the microVM as one object. They are three lifetimes -- a durable workspace, a per-session namespace, a disposable VM -- and a scope ending releases the VM without losing anything, because what persists is in the substrate. rlms already ends a non-persistent run with cleanup(). Also records, with measurements: that Cloud Hypervisor snapshot/restore is real and cheap (0.2s, 174 MB on disk for a 2 GiB guest) but desynchronises the Kata shim when driven behind containerd's back, so it is a scoping fact for the workspace-persistence question rather than a ready mechanism; and that this session's own containerd-namespace default is justified in config.py by a property -- everything under this path is mine -- that is false once two Trellis instances share a machine, which the owner has now confirmed they will. --- docs/architecture/REPL_SYSTEM_VIEW.md | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/architecture/REPL_SYSTEM_VIEW.md diff --git a/docs/architecture/REPL_SYSTEM_VIEW.md b/docs/architecture/REPL_SYSTEM_VIEW.md new file mode 100644 index 0000000..3b6c194 --- /dev/null +++ b/docs/architecture/REPL_SYSTEM_VIEW.md @@ -0,0 +1,141 @@ +# The REPL, system-wide — a reading of `trellis_repl_system.svg` + +**Status: understanding record, dated 2026-07-25.** Companion to +[`trellis_repl_system.svg`](trellis_repl_system.svg). It documents what that diagram claims and +where each claim comes from, so a later session can check the picture against the code rather than +against this prose. Owner-directed after a design question exposed that the session had the REPL's +role wrong; the correction is recorded here rather than smoothed away, because the corrected +understanding is the point. + +## 0. The error this record exists to fix + +A session asked whether the sandbox composition should be a context manager or a plain function, +and framed it as a tension: *unmissable cleanup versus a session that outlives any single scope.* + +**That tension does not exist, and believing it did was a category error** — the REPL and the +microVM were being treated as one object. They are two: + +| | what it is | lifetime | +|---|---|---| +| **The workspace** | a domain, its artifacts, its accumulated past | durable; outlives every session | +| **The REPL namespace** | the live Python namespace a session works in | one session | +| **The microVM** | hardware-isolated compute wrapped around that namespace | one session; disposable | + +A workspace persists because **its state is in the substrate**, not because a process stayed alive. +So a scope that ends is not a session lost — it is a VM released. The composition is therefore a +context manager, and the argument against it was never real. + +rlms already models this independently: its driver ends a non-persistent run with `cleanup()` +(`rlm/core/rlm.py`). A context manager is that lifecycle expressed in Python. + +## 1. What the diagram shows, panel by panel + +**Left — workspaces, swappable, many.** A workspace is not a conversation thread. It carries domain +information, the artifacts it has produced, and its own past. The owner's framing: physics on +Tuesday, philosophy on Thursday; a mechanic with parts inventory, manuals, and customer +transactions as three separate ones. **Trellis' own source is a workspace**, which is how the +system edits itself, and repositories in general are a workspace kind (owner, 2026-07-25). + +**Centre — one persistent namespace inside one ephemeral VM.** Ratified: **one workspace per +session** (owner, 2026-07-25). The namespace holds `context` (handles, never payloads), the three +pre-allocated roots for facts, beliefs and doubts, the artifacts prior turns built, and the +model-authored code that runs against them. Around it, the microVM: a hardware boundary holding no +credential, torn down at exit. + +**The three ports.** `CONTROL` (5003) — the host dials in, the guest supervisor listens. `LM` +(5001) and `DB` (5002) — the guest dials out. The diagram marks both outbound ports' host ends as +unowned, because they are: that is the open seam recorded in +[`REPL_SANDBOX_BUILD_PLAN.md` §5.6](../product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md). + +**Right — the model holds addresses, never the corpus.** The +[code-mediated-text pillar](CODE_MEDIATED_TEXT.md) one level up: the engine computes positions and +moves bytes; the model supplies addresses. `llm_query` fans out flat at depth 1. + +**Bottom — the substrate.** Content-addressed, append-only, final at write time. This is what makes +a workspace survivable, and it is why the VM above it can be disposable. + +**The closing loop.** A query does not return a reply; it builds a **response artifact**, which is +filed back into the store, judged, given standing at a user gate (−1 doubt / 0 belief / +1 fact), +and becomes corpus for the next query. Output becomes input. + +## 2. The claim the diagram makes that is worth checking first + +**The REPL is meant to be gigabytes, read in slices, and no wire bound constrains that.** The +corpus ceiling is address space (`Tier0Limits.address_space_bytes`, 1 GiB), not any frame or slice +number. The four layers, which are separate and are routinely confused for one another: + + REPL namespace → Tier0Limits.address_space_bytes 1 GiB + one slice → BrokerCaps.max_result_bytes 2 MiB + model attention → MarshalCaps 20 KiB stdout / 64 KiB answer + one wire message→ DEFAULT_MAX_FRAME_LEN 4 MiB + +A sentence computing *how many slices a corpus takes* is the tell that the model is being treated +as the transport. It never is. + +## 3. Open: how a workspace remembers + +**Unresolved as of this record; the owner posed it and the mechanism is not chosen.** Three +candidates, with what is known about each. + +**(a) Reconstruction from the substrate.** The namespace is rebuilt at boot by re-loading handles +that address stored content. No serialization, no deserialization attack surface, and everything +restored already carries custody. Does not preserve arbitrary intermediate state — a fitted model, +a large derived frame — which is exactly the state a long-running workspace accumulates. + +**(b) Namespace serialization.** Pickle-family. Cheap to write and the wrong shape here: the +namespace is authored by untrusted model code, so the bytes are attacker-influenced, and +`pickle.load` executes them. Also cannot carry live sockets or clients. + +**(c) VM snapshot — measured 2026-07-25, and it works.** Cloud Hypervisor v52.0 exposes +`pause` / `snapshot` / `restore` / `resume` through `ch-remote` against the API socket Kata already +creates, which `KataLauncher` already discovers (it parses `--api-socket` from the VMM's argv). +Observed on the reference host against a live Kata-launched guest: + + pause rc=0 0.00s + snapshot rc=0 0.2s → config.json 2.4 KB · state.json 86 KB + memory-ranges 2 GiB sparse, 174 MB on disk + resume rc=0 + +So the mechanism is real, fast, and cheap on disk. **One measured caveat decides whether it is +usable as-is:** snapshotting behind containerd's back desynchronised the Kata shim — after `resume`, +`ctr task exec` returned `DeadlineExceeded` and the container could not be re-entered, leaving a +VMM that ordinary teardown could not reap. The `shutdown` path detected the survivor and raised +rather than reporting success, which is the check working; but it means snapshot/restore needs +either Kata's own sanctioned pause path or a launcher that drives Cloud Hypervisor directly rather +than through containerd. **Not a blocker; a scoping fact.** A second unresolved point: handles are +host-side and session-scoped, so a restored guest would hold handle tokens whose host-side table no +longer has them — restoring the guest does not restore its counterpart. + +Matt's suggestion that a binary gives a clean way to make workspace **checkout** work fits (c) +particularly well: if a workspace *is* a snapshot artifact, then holding it is the lock, and no +second instance can edit what it does not hold. + +## 4. Isolation between instances — and one thing this session got wrong + +**Ratified (owner, 2026-07-25): each session is its own Trellis instance, with its own database.** +Several may run on one machine — inventory, billing, parts — and they never conflict because they +are separate instances, not concurrent sessions inside one. Matt adds that a workspace is +**checked out**, so a second Trellis cannot edit one already held: **no concurrency by design.** + +That confirms an earlier withdrawal was right for the wrong reason, and exposes a real defect this +session introduced. `KataLauncher` takes the containerd namespace `trellis` as a default, and its +CID counter starts at `FIRST_LAUNCHER_CID` **per process**. Two Trellis instances on one machine +therefore share a containerd namespace and both begin minting at the same CID. Sandbox names carry +UUID entropy so containers do not collide, and each instance has its own host object and database +so the ledgers do not — but the justification written into `config.py`, that owning a namespace is +what lets a launcher say *everything under this path is mine* before a destructive sweep, **is +false across instances.** Rule 19(a) is the reason that property was wanted, and it does not hold. + +**Owed:** the namespace and the CID origin should derive from an instance identifier rather than +being module constants. Recorded here rather than fixed in passing, because it changes a +deployment-visible default. + +## 5. What a later session should check rather than trust + +- That one workspace per session still holds, before building anything that assumes a VM per + workspace swap. +- That the persistence mechanism in §3 has been *chosen*, not inherited by default from whichever + spike ran last. +- That §4's namespace defect is closed before two instances are ever run on one host. +- That the four layers in §2 have not drifted into one another — the invariant + `max_frame_len >= 2 * max_result_bytes` is asserted in `test_config.py`, and the others are not. From c224b6c9692718696fbc270d120f2fa1281c62b0 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:52:37 -0500 Subject: [PATCH 10/13] The composition layer: one workspace, one session, one microVM `session_host.open_workspace_session` is the seam the eighth item of BUILD_PLAN 5.6 named and nothing owned. It is not new: the block already worked in five places -- every probe and the CLI selftest each build a host, open a session, bind the listeners and drive the backend -- but each proved one half by substituting a stand-in for the other, and the diagonal never ran. This promotes it out of `scripts/` and gives it a name. A context manager, because the durable thing and the executing thing are different objects. A workspace persists through the substrate; the microVM is compute borrowed for one stretch of work. So a closing scope is a VM released, never a session lost, and leaking a live VM stops being possible by forgetting. rlms already models this -- its driver ends a non-persistent run with `cleanup()`. The manifest ships now and ships empty, so reconstruction is a no-op on day one and no later session retrofits a mechanism around an absence. Its field set is pinned as a test: it carries addresses and never a namespace, because restoring model-authored objects outside the sandbox would be an arbitrary-code-execution primitive with the host's privileges. A turn's durable output is the filed artifact; the artifact loop is not a feature on top of persistence, it is the mechanism. Identity is two values, not one. A workspace id is stable -- physics is the same workspace next Tuesday -- and a session id is unique per opening, dated, with entropy, because one user opening two sessions in a day is ordinary and a collision there shares a ledger. The user component is operational identity only: it names a lease holder on this machine and never reaches the substrate, where FEATURE_LIST 1.6 is closed on the ground that nothing names an owner. Checkout auto-reclaims on an observation rather than a timeout. A lease names the sandbox its holder booted, so liveness is answerable by asking whether a real VMM still carries it -- the same exe-verified check written to refuse a boot that produced no VM now proves a real one is gone. A timeout would be wrong in both directions. `start_bridge` no longer refuses unconditionally: it refuses when nobody attached a bridge, which stays the honest default for a caller that boots a guest and never composes a session. Four properties watched failing against planted breakage before being trusted: reclaiming a live holder, asking liveness about the claimant's sandbox instead of the holder's, and leaking the lease on each of the two exit paths. pytest 1051 -> 1067, vitest 1416/119. --- src/repl_sandbox/launcher.py | 66 +-- src/repl_sandbox/session_host.py | 485 ++++++++++++++++++++ src/repl_sandbox/tests/test_session_host.py | 280 +++++++++++ 3 files changed, 804 insertions(+), 27 deletions(-) create mode 100644 src/repl_sandbox/session_host.py create mode 100644 src/repl_sandbox/tests/test_session_host.py diff --git a/src/repl_sandbox/launcher.py b/src/repl_sandbox/launcher.py index 43b13c5..9b1e066 100644 --- a/src/repl_sandbox/launcher.py +++ b/src/repl_sandbox/launcher.py @@ -39,7 +39,7 @@ import time import uuid from dataclasses import dataclass, field -from typing import Callable, Protocol, runtime_checkable +from typing import Any, Callable, Protocol, runtime_checkable from repl_sandbox.audit import AuditLog from repl_sandbox.config import ( @@ -881,9 +881,14 @@ def _ensure_image(self) -> None: if tagged.get("error"): raise SandboxError(f"could not tag {reference}: {tagged['error']}") - def boot(self, session_id: str) -> GuestHandle: + def boot(self, session_id: str, *, sandbox_name: str | None = None) -> GuestHandle: """Gate the host, then claim one microVM for `session_id`. + `sandbox_name` lets a caller mint the name *before* the boot, which the + composition layer needs: a workspace lease records the sandbox its holder + booted, and the lease has to be taken before any resource is allocated. + Absent, the name is minted here, which is the standalone case. + Raises `SandboxError` with the full failure list when G1 does not pass — including on this repository's Windows development host, which has no `/dev/kvm` at all. @@ -898,7 +903,7 @@ def boot(self, session_id: str) -> GuestHandle: "failed: " + "; ".join(result.failures) ) - name = self.mint_sandbox_name(session_id) + name = sandbox_name if sandbox_name is not None else self.mint_sandbox_name(session_id) handle = KataGuestHandle( config=self.config, sandbox_name=name, @@ -1001,42 +1006,49 @@ def __init__( self.uds_path: str | None = None self.package_installed = False self.serving = False + self.bridge_started = False + self._bridge: Any = None self._control_conn: Connection | None = None # -- setup steps ------------------------------------------------------- + def attach_bridge(self, bridge: Any) -> None: + """Supply the host end of `LM_PORT`/`DB_PORT` for this sandbox. + + The launcher cannot build this itself: the listeners serve a host's + handlers, and a launcher has no host. So the composition layer that holds + both binds them and hands the result here (`session_host.SessionBridge`). + Without it `start_bridge` still refuses, which keeps the honest default + for any caller that boots a guest and never composes a session. + """ + self._bridge = bridge + def start_bridge(self) -> None: - """Refuses, and names precisely what is unresolved. + """Bring the host end up, or refuse if nobody supplied one. - A launcher cannot honestly implement this step today, and a no-op would - be the worst available answer: `KataREPL.setup` calls it as "the bridge, - before any untrusted worker process", so a silent pass asserts a bridge - exists when nothing has been brought up. + `KataREPL.setup` calls this "the bridge, before any untrusted worker + process", so a silent no-op would assert a bridge exists when nothing has + been bound — and the first thing to discover that would be model-authored + code, at runtime, inside the boundary. What is settled: the guest needs no loopback-to-vsock forwarder. The forwarder of INTERFACES section 3.3 exists to carry an in-guest rlms client's `AF_INET` traffic, and there is no rlms in the guest — the materialised stubs dial `AF_VSOCK` directly (`guest_main.build_rpc_hook`). - - What is **not** settled, and is not this module's to decide: who stands - up a session's `LM_PORT`/`DB_PORT` listeners. `kata_repl.py`'s own step-2 - comment assigns that to the LM handler and the broker and calls the CID - binding "the backend's whole part in bringing those two channels up", - while no code anywhere binds a `HybridVsockListener` outside tests and - the probes. `KataLauncher` takes no host to serve them against, and - `GuestHandle` has no member for them. Guessing here would install a - composition decision the record does not make. + What this step actually needs is an owner for the per-sandbox + `LM_PORT`/`DB_PORT` listeners, which is `session_host`'s. """ - raise SandboxError( - f"sandbox {self.sandbox_name} booted, but the host-side LM/DB listener " - "composition is unresolved: no code joins a launched guest to a " - "TrellisSandboxHost, and KataLauncher takes none. The guest needs no " - "in-guest forwarder (there is no rlms in the guest), so this step is " - "not the forwarder INTERFACES section 3.3 describes; what it needs is " - "an owner for the per-sandbox LM_PORT/DB_PORT listeners. Refusing " - "rather than passing silently, because setup() treats this call as " - "the bridge being up." - ) + if self._bridge is None: + raise SandboxError( + f"sandbox {self.sandbox_name} booted, but no host-side bridge was " + "attached: the LM_PORT/DB_PORT listeners serve a TrellisSandboxHost's " + "handlers and a launcher has no host. Compose the session through " + "session_host.open_workspace_session, which binds them and calls " + "attach_bridge. Refusing rather than passing silently, because " + "setup() treats this call as the bridge being up." + ) + self._bridge.start() + self.bridge_started = True def install_package(self) -> None: """Ship `repl_sandbox` into the guest. Called by `boot`, not by the backend.""" diff --git a/src/repl_sandbox/session_host.py b/src/repl_sandbox/session_host.py new file mode 100644 index 0000000..b9a6823 --- /dev/null +++ b/src/repl_sandbox/session_host.py @@ -0,0 +1,485 @@ +"""The composition layer: one workspace, one session, one microVM. + +Source of truth: docs/architecture/THE_REPL_IN_TRELLIS.md (what persists and +what is borrowed) and docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md +section 5.6 (the eighth item — who owns the host end of `LM_PORT`/`DB_PORT`). + +**This module exists because nothing joined the two halves.** `KataLauncher` +boots a guest and knows nothing about a host; `TrellisSandboxHost` holds the +credentials and the policy and knows nothing about a VM; `KataREPL` speaks the +rlms contract and takes a session table it has no way to populate. Each half was +proved separately, five times, by substituting a stand-in for the other — every +probe binds real listeners against a real microVM but drives `ctr` by hand, and +the CLI selftest drives the real backend against an in-process double with no VM +at all. The diagonal — the real backend against the real boundary — had never +run. This is the block that already worked in those five places, promoted out of +`scripts/` and given a name. + +**Why a context manager, and why that was never really a trade.** The durable +thing and the executing thing are different objects. A workspace persists +because its state is in the substrate; the microVM is compute borrowed for one +stretch of work. So a scope that closes is a VM released, never a session lost, +and the one genuinely unrecoverable mistake here — leaking a live microVM on a +host somebody pays for — becomes impossible to make by forgetting. rlms already +models exactly this: its own driver ends a non-persistent run with `cleanup()`. + +**Ordering is forced, not chosen**, and every step is where it is because a +later position breaks something specific: + +1. the lease, because two Trellises editing one workspace is the thing checkout + exists to prevent, and it must be refused before any resource is allocated; +2. the host, because the listeners serve *its* handlers and the backend takes + *its* session table; +3. `open_session`, because the CID binding is what the handlers authenticate + against; +4. the boot, because the vsock socket path does not exist until the VMM does — + this is the step that makes the whole order non-negotiable; +5. the listeners, because the guest dials outward on its first tool call and a + dial with no listener is answered with a closed connection, not a wait; +6. the scaffold, which starts the guest process, and must therefore be last. + +Teardown reverses it exactly. A failure at step *n* unwinds *n-1 … 1* and +nothing else, which is why each allocation is recorded the instant it is made +rather than after the sequence completes. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass, field, replace +from datetime import date +from typing import Any, Callable, Iterator, Sequence + +from repl_sandbox.audit import AuditLog +from repl_sandbox.config import SandboxConfig +from repl_sandbox.errors import DeniedError, SandboxError +from repl_sandbox.launcher import KataLauncher, vmm_pids_carrying +from repl_sandbox.transport import HybridVsockListener, hybrid_socket_path, serve_forever + +#: Schema version for a persisted manifest. A workspace written by a later +#: Trellis must not be silently half-read by an earlier one. +MANIFEST_SCHEMA_VERSION = 1 + +#: How long a stopping listener thread is given before teardown stops waiting. +LISTENER_JOIN_TIMEOUT_S = 5.0 + + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +def mint_session_id(user_id: str, workspace_id: str, *, today: date | None = None) -> str: + """One session's identifier: stable-workspace, dated, and unique. + + **Two identifiers, not one, and collapsing them is the trap.** A workspace + id is *stable* — the physics workspace is the same workspace next Tuesday, + which is what makes it lockable and what makes a manifest findable. A session + id is *unique per opening*, because it keys the ledgers, the audit trail and + the CID binding. A date belongs in the second and would silently break the + first. + + The user component is deliberately **operational identity only**: it names + who holds a lease on this machine, it lives in configuration, and it is never + written to the substrate. `FEATURE_LIST.md` 1.6 is closed on the ground that + nothing in the store names an owner and nothing needs to; a user id that + reached the store would revise that ruling rather than apply it. + + Entropy is not decoration either. One user opening two sessions on one day is + ordinary, so `user + date` alone collides — and a collision here is two + sessions sharing a ledger. + """ + for name, value in (("user_id", user_id), ("workspace_id", workspace_id)): + if not isinstance(value, str) or not value.strip(): + raise SandboxError(f"{name} must be a non-empty string") + stamp = (today or date.today()).isoformat() + return f"{_slug(user_id)}-{_slug(workspace_id)}-{stamp}-{uuid.uuid4().hex[:8]}" + + +def _slug(raw: str) -> str: + """A filesystem- and log-safe rendering of a caller-supplied identifier.""" + cleaned = "".join(char if char.isalnum() else "-" for char in raw).strip("-") + return (cleaned or "x")[:32] + + +# --------------------------------------------------------------------------- +# The manifest +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class WorkspaceManifest: + """What a workspace *is*, apart from its content. + + **It ships empty and a session clones it into the form it takes.** That is + the whole reason it exists now rather than later: an empty manifest makes + reconstruction a no-op, so the first real session is correct without a + retrofit, and the shape is fixed before anything depends on its absence. + + What it deliberately does **not** hold: the namespace. A workspace does not + remember by snapshotting live Python objects — those are model-authored, and + deserialising them outside the sandbox would be an arbitrary-code-execution + primitive running with the host's privileges, which inverts the boundary the + microVM exists to provide. It also has no content identity, cannot be sliced + by address, and cannot be the size a workspace actually is. What persists is + the store; what this names is how to find the way back into it. + + So a turn's durable output is the filed artifact, never the namespace, and + the artifact loop is not a feature sitting on top of persistence — it *is* + the persistence mechanism. + """ + + workspace_id: str + schema_version: int = MANIFEST_SCHEMA_VERSION + #: Document versions that are live for this workspace. Addresses, never bytes. + live_documents: tuple[str, ...] = () + #: Root handles pre-allocated at session open (facts, beliefs, doubts). + root_handles: tuple[str, ...] = () + #: Artifacts previous turns filed, by address, with the standing they carry. + artifacts: tuple[dict, ...] = () + + @property + def is_empty(self) -> bool: + """True when there is nothing to reconstruct — the day-one state.""" + return not (self.live_documents or self.root_handles or self.artifacts) + + def to_json(self) -> str: + return json.dumps( + { + "workspace_id": self.workspace_id, + "schema_version": self.schema_version, + "live_documents": list(self.live_documents), + "root_handles": list(self.root_handles), + "artifacts": list(self.artifacts), + }, + indent=2, + sort_keys=True, + ) + + @classmethod + def empty(cls, workspace_id: str) -> "WorkspaceManifest": + return cls(workspace_id=workspace_id) + + @classmethod + def from_json(cls, raw: str, workspace_id: str) -> "WorkspaceManifest": + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise SandboxError(f"manifest for {workspace_id!r} is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise SandboxError(f"manifest for {workspace_id!r} must be a JSON object") + + version = data.get("schema_version") + if version != MANIFEST_SCHEMA_VERSION: + # Refused rather than best-effort read: a manifest from a later + # Trellis names things this one does not understand, and a partial + # reconstruction is a workspace that looks restored and is not. + raise SandboxError( + f"manifest for {workspace_id!r} is schema version {version!r}; " + f"this Trellis reads version {MANIFEST_SCHEMA_VERSION}" + ) + return cls( + workspace_id=data.get("workspace_id", workspace_id), + schema_version=version, + live_documents=tuple(data.get("live_documents", ())), + root_handles=tuple(data.get("root_handles", ())), + artifacts=tuple(data.get("artifacts", ())), + ) + + +# --------------------------------------------------------------------------- +# The lease +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class LeaseRecord: + """Who holds a workspace, and what to check to find out if they still do.""" + + workspace_id: str + session_id: str + sandbox_name: str + pid: int + acquired_at: float + + +class WorkspaceLease: + """Checkout: one workspace, one holder, no concurrency by design. + + Instances run in parallel — a machine may serve inventory, billing and parts + at once, each its own Trellis with its own database. **Workspaces are what + must not be shared**, and this is the lock that says so. + + **Auto-reclaim, on an observation rather than a timeout.** A held lease names + the sandbox its holder booted, so liveness is answerable directly: walk + `/proc` and ask whether a real Cloud Hypervisor still carries that name. If + none does, the holder is gone and the lease is reclaimed. That is the same + exe-verified check the launcher uses to refuse a boot that produced no VM, + which is worth noting — the primitive built to catch a phantom VM is exactly + the one that proves a real one is gone. + + A timeout would have been the wrong instrument twice over: too short locks a + user out of their own data during a long turn, too long leaves a crashed + workspace unopenable, and neither answers the question actually being asked. + """ + + def __init__( + self, + root: str, + workspace_id: str, + *, + liveness: Callable[[str], list[int]] | None = None, + clock: Callable[[], float] = time.time, + ) -> None: + self.root = root + self.workspace_id = workspace_id + self.path = os.path.join(root, f"{_slug(workspace_id)}.lease") + self._liveness = liveness if liveness is not None else vmm_pids_carrying + self._clock = clock + self.reclaimed_from: LeaseRecord | None = None + + def read(self) -> LeaseRecord | None: + try: + with open(self.path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError): + # An unreadable lease is treated as no lease: it cannot name a live + # holder, so honouring it would lock a workspace on the strength of + # bytes nobody can interpret. + return None + try: + return LeaseRecord(**data) + except TypeError: + return None + + def acquire(self, session_id: str, sandbox_name: str) -> LeaseRecord: + """Take the lease, reclaiming a dead holder's automatically.""" + held = self.read() + if held is not None: + survivors = self._liveness(held.sandbox_name) + if survivors: + raise DeniedError( + f"workspace {self.workspace_id!r} is checked out by session " + f"{held.session_id!r} (sandbox {held.sandbox_name}, live VMM pids " + f"{survivors}). One workspace, one session." + ) + # The holder is gone: no VMM carries the sandbox it booted. + self.reclaimed_from = held + + os.makedirs(self.root, exist_ok=True) + record = LeaseRecord( + workspace_id=self.workspace_id, + session_id=session_id, + sandbox_name=sandbox_name, + pid=os.getpid(), + acquired_at=self._clock(), + ) + with open(self.path, "w", encoding="utf-8") as handle: + json.dump(record.__dict__, handle, indent=2, sort_keys=True) + return record + + def release(self) -> None: + """Drop the lease. Idempotent; never raises.""" + try: + os.unlink(self.path) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# The bridge +# --------------------------------------------------------------------------- + + +class SessionBridge: + """The host end of `LM_PORT` and `DB_PORT`, for one sandbox. + + This is the eighth item of BUILD_PLAN section 5.6, and the reason it is here + rather than in `KataLauncher` or `KataREPL` is ownership of failure. Binding + the second listener can fail after the first is bound and after a microVM is + running. Only a component holding the listeners *and* the guest handle can + unwind both in the right order — the launcher has no host, and the backend + would be part-way through `setup()` with sockets open and no handle assigned. + + Each listener binds at `_`: the per-sandbox socket path the + hypervisor created, which is what carries session identity now that the host + side is `AF_UNIX` and `accept()` reports no CID (INTERFACES section 3.1a). + """ + + def __init__( + self, + uds_path: str, + cid: int, + config: SandboxConfig, + ports: Sequence[tuple[int, Callable[[int, dict], dict]]], + audit: AuditLog | None = None, + ) -> None: + self.uds_path = uds_path + self.cid = cid + self.config = config + self._ports = list(ports) + self.audit = audit + self._stop = threading.Event() + self._listeners: list[HybridVsockListener] = [] + self._threads: list[threading.Thread] = [] + self.bound: tuple[str, ...] = () + + def start(self) -> None: + """Bind every granted port, or bind none and leave nothing behind.""" + bound: list[str] = [] + try: + for port, handler in self._ports: + listener = HybridVsockListener(self.uds_path, port, self.cid) + self._listeners.append(listener) + thread = threading.Thread( + target=serve_forever, + args=(listener, handler, self.config.max_frame_len, None, self._stop), + name=f"trellis-bridge-{self.cid}-{port}", + daemon=True, + ) + thread.start() + self._threads.append(thread) + bound.append(hybrid_socket_path(self.uds_path, port)) + except BaseException: + self.stop() + raise + self.bound = tuple(bound) + + def stop(self) -> None: + """Close every listener and join its thread. Idempotent; never raises.""" + self._stop.set() + for listener in self._listeners: + try: + listener.close() + except OSError: + pass + self._listeners.clear() + for thread in self._threads: + thread.join(timeout=LISTENER_JOIN_TIMEOUT_S) + self._threads.clear() + + +# --------------------------------------------------------------------------- +# The session +# --------------------------------------------------------------------------- + + +@dataclass +class KataSession: + """One open workspace session: everything the caller needs, already wired.""" + + workspace_id: str + session_id: str + cid: int + manifest: WorkspaceManifest + host: Any + guest: Any + bridge: SessionBridge + backend: Any = None + reclaimed_from: LeaseRecord | None = None + reconstructed: dict = field(default_factory=dict) + + +def reconstruct(manifest: WorkspaceManifest) -> dict: + """Rebuild what a workspace knows, from its manifest. + + A no-op on an empty manifest, which is the day-one state and the reason the + manifest ships now: the first real session is already correct, and no later + session has to retrofit a mechanism around an absence. + + What reconstruction is *not*: restoring a namespace. Handles are re-issued + and `context` re-bound from addresses the store can still resolve. Nothing + model-authored comes back as a code object. + """ + return { + "documents": len(manifest.live_documents), + "handles": len(manifest.root_handles), + "artifacts": len(manifest.artifacts), + "empty": manifest.is_empty, + } + + +@contextmanager +def open_workspace_session( + config: SandboxConfig, + workspace_id: str, + *, + user_id: str, + lease_root: str, + host_factory: Callable[[], Any], + launcher: KataLauncher | None = None, + manifest_store: Callable[[str], WorkspaceManifest] | None = None, + ops: Sequence[str] = (), + lm: bool = True, + audit: AuditLog | None = None, +) -> Iterator[KataSession]: + """Open one workspace in one microVM, and release everything on the way out. + + The six ordered steps and why the order is forced are in this module's + header. `host_factory` is a callable rather than a host because the host + holds credentials and the caller decides where those come from; this module + never reads one. + """ + session_id = mint_session_id(user_id, workspace_id) + launcher = launcher if launcher is not None else KataLauncher(config, audit=audit) + load_manifest = manifest_store if manifest_store is not None else WorkspaceManifest.empty + + lease = WorkspaceLease(lease_root, workspace_id) + sandbox_name = launcher.mint_sandbox_name(session_id) + lease.acquire(session_id, sandbox_name) + + host = None + guest = None + bridge = None + cid = None + try: + manifest = load_manifest(workspace_id) + host = host_factory() + cid = launcher.mint_cid() + opened = host.open_session(cid, session_id, ops=tuple(ops), lm=lm) + + guest = launcher.boot(session_id, sandbox_name=sandbox_name) + + ports: list[tuple[int, Callable[[int, dict], dict]]] = [] + if lm: + ports.append((config.ports.lm, host.lm_handler)) + if ops: + ports.append((config.ports.db, host.broker_handler)) + bridge = SessionBridge(guest.uds_path, cid, config, ports, audit=audit) + guest.attach_bridge(bridge) + + yield KataSession( + workspace_id=workspace_id, + session_id=session_id, + cid=cid, + manifest=manifest, + host=host, + guest=guest, + bridge=bridge, + reclaimed_from=lease.reclaimed_from, + reconstructed=reconstruct(manifest), + ) + finally: + # Reverse order, and each step independently guarded: a failure while + # releasing one resource must not strand the ones behind it. The lease + # goes last, because a workspace is only free once its VM is gone. + if bridge is not None: + bridge.stop() + if guest is not None: + try: + guest.shutdown() + except SandboxError: + pass + if host is not None and cid is not None: + try: + host.close_session(cid) + except (DeniedError, SandboxError): + pass + lease.release() diff --git a/src/repl_sandbox/tests/test_session_host.py b/src/repl_sandbox/tests/test_session_host.py new file mode 100644 index 0000000..fd2dedf --- /dev/null +++ b/src/repl_sandbox/tests/test_session_host.py @@ -0,0 +1,280 @@ +"""The composition layer: identity, the manifest, the lease, and the unwind. + +The properties under test are the ones that fail silently if they break: two +identifiers collapsed into one, a lease honoured on the strength of a dead +holder, and a partially-allocated session left standing after a failure. + +No `ctr`, no VMM, no socket is created here. Everything the layer touches is +injected, which is what lets the ordering be asserted on a machine with no KVM. +""" + +from __future__ import annotations + +import json +import os +from datetime import date + +import pytest + +from repl_sandbox.config import SandboxConfig +from repl_sandbox.errors import DeniedError, SandboxError +from repl_sandbox.session_host import ( + MANIFEST_SCHEMA_VERSION, + KataSession, + WorkspaceLease, + WorkspaceManifest, + mint_session_id, + open_workspace_session, + reconstruct, +) + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +def test_a_session_id_is_unique_per_opening_not_per_day() -> None: + """Two sessions on one day is ordinary; a collision would share a ledger.""" + day = date(2026, 7, 25) + first = mint_session_id("cnid", "physics", today=day) + second = mint_session_id("cnid", "physics", today=day) + assert first != second + assert first.startswith("cnid-physics-2026-07-25-") + + +def test_the_workspace_component_is_stable_across_days() -> None: + """A workspace is the same workspace next Tuesday, so its name cannot move. + + The date belongs to the session identifier and would silently break the + workspace one -- which is the whole reason these are two values. + """ + tuesday = mint_session_id("cnid", "physics", today=date(2026, 7, 21)) + next_tuesday = mint_session_id("cnid", "physics", today=date(2026, 7, 28)) + assert tuesday.split("-2026-")[0] == next_tuesday.split("-2026-")[0] == "cnid-physics" + + +def test_an_empty_identifier_is_refused() -> None: + for bad in ("", " "): + with pytest.raises(SandboxError): + mint_session_id(bad, "physics") + with pytest.raises(SandboxError): + mint_session_id("cnid", bad) + + +# --------------------------------------------------------------------------- +# The manifest +# --------------------------------------------------------------------------- + + +def test_a_new_workspace_ships_an_empty_manifest_and_reconstruction_is_a_no_op() -> None: + """Day one: the form exists, so nothing has to be retrofitted around it.""" + manifest = WorkspaceManifest.empty("physics") + assert manifest.is_empty is True + assert reconstruct(manifest) == {"documents": 0, "handles": 0, "artifacts": 0, "empty": True} + + +def test_a_manifest_round_trips_through_json() -> None: + filled = WorkspaceManifest( + workspace_id="physics", + live_documents=("doc:a", "doc:b"), + root_handles=("h:facts",), + artifacts=({"address": "ast:1", "standing": "belief"},), + ) + restored = WorkspaceManifest.from_json(filled.to_json(), "physics") + assert restored == filled + assert restored.is_empty is False + + +def test_a_manifest_from_a_later_trellis_is_refused_not_half_read() -> None: + """A partial reconstruction is a workspace that looks restored and is not.""" + raw = json.dumps({"workspace_id": "physics", "schema_version": MANIFEST_SCHEMA_VERSION + 1}) + with pytest.raises(SandboxError) as raised: + WorkspaceManifest.from_json(raw, "physics") + assert "schema version" in str(raised.value) + + +def test_a_manifest_holds_addresses_never_a_namespace() -> None: + """The field set is the guarantee: nothing here can carry a pickled object. + + Restoring model-authored objects outside the sandbox would be an + arbitrary-code-execution primitive running with the host's privileges, so the + absence of any such field is a property worth pinning rather than assuming. + """ + fields = set(WorkspaceManifest.empty("physics").__dataclass_fields__) + assert fields == { + "workspace_id", "schema_version", "live_documents", "root_handles", "artifacts", + } + + +# --------------------------------------------------------------------------- +# The lease +# --------------------------------------------------------------------------- + + +def test_a_live_holder_keeps_the_workspace(tmp_path) -> None: + """One workspace, one session -- the whole point of checkout.""" + lease = WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: [4242]) + lease.acquire("session-a", "trellis-a-0001") + + second = WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: [4242]) + with pytest.raises(DeniedError) as raised: + second.acquire("session-b", "trellis-b-0002") + assert "checked out" in str(raised.value) + assert second.reclaimed_from is None + + +def test_a_dead_holder_is_reclaimed_automatically(tmp_path) -> None: + """Auto-reclaim rests on an observation of the VM, never on a timeout. + + The lease names the sandbox its holder booted, so liveness is answerable + directly -- and a timeout would be wrong in both directions: too short locks + a user out mid-turn, too long strands a crashed workspace. + """ + WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: [1]).acquire( + "session-a", "trellis-a-0001" + ) + + survivor = WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: []) + record = survivor.acquire("session-b", "trellis-b-0002") + assert record.session_id == "session-b" + assert survivor.reclaimed_from is not None + assert survivor.reclaimed_from.session_id == "session-a" + + +def test_the_liveness_check_is_asked_about_the_holders_sandbox(tmp_path) -> None: + """Not about the claimant's -- asking the wrong one always reclaims.""" + asked: list[str] = [] + WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: []).acquire( + "session-a", "trellis-holder-0001" + ) + WorkspaceLease( + str(tmp_path), "physics", liveness=lambda name: asked.append(name) or [] + ).acquire("session-b", "trellis-claimant-0002") + assert asked == ["trellis-holder-0001"] + + +def test_an_unreadable_lease_does_not_lock_a_workspace_forever(tmp_path) -> None: + path = tmp_path / "physics.lease" + path.write_text("{ this is not json", encoding="utf-8") + lease = WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: [1]) + assert lease.acquire("session-a", "trellis-a-0001").session_id == "session-a" + + +def test_release_is_idempotent(tmp_path) -> None: + lease = WorkspaceLease(str(tmp_path), "physics", liveness=lambda name: []) + lease.acquire("session-a", "trellis-a-0001") + lease.release() + lease.release() + assert lease.read() is None + + +# --------------------------------------------------------------------------- +# The ordering and the unwind +# --------------------------------------------------------------------------- + + +class FakeGuest: + def __init__(self, name: str, log: list[str]) -> None: + self.sandbox_name = name + self.uds_path = "/run/vc/vm/%s/clh.sock" % name + self._log = log + self.bridge = None + + def attach_bridge(self, bridge) -> None: + self.bridge = bridge + + def shutdown(self) -> None: + self._log.append("guest.shutdown") + + +class FakeLauncher: + def __init__(self, log: list[str], fail_at_boot: bool = False) -> None: + self._log = log + self._fail = fail_at_boot + self._cid = 16 + + def mint_sandbox_name(self, session_id: str) -> str: + return "trellis-%s-abcdef" % session_id[:8] + + def mint_cid(self) -> int: + value, self._cid = self._cid, self._cid + 1 + return value + + def boot(self, session_id: str, *, sandbox_name=None): + self._log.append("boot") + if self._fail: + raise SandboxError("planted: the boot failed") + return FakeGuest(sandbox_name or "trellis-x", self._log) + + +class FakeHost: + def __init__(self, log: list[str]) -> None: + self._log = log + self.lm_handler = lambda cid, request: {} + self.broker_handler = lambda cid, request: {} + + def open_session(self, cid, session_id, *, ops=(), lm=True): + self._log.append("open_session") + return object() + + def close_session(self, cid) -> None: + self._log.append("close_session") + + +def _session(tmp_path, log, **kwargs): + return open_workspace_session( + SandboxConfig(), + "physics", + user_id="cnid", + lease_root=str(tmp_path), + host_factory=lambda: FakeHost(log), + launcher=FakeLauncher(log, **kwargs), + ) + + +def test_the_session_opens_in_the_forced_order(tmp_path, monkeypatch) -> None: + """The boot must precede the bridge: the socket path does not exist before it.""" + monkeypatch.setattr("repl_sandbox.session_host.SessionBridge.start", lambda self: None) + log: list[str] = [] + with _session(tmp_path, log) as session: + assert isinstance(session, KataSession) + assert session.workspace_id == "physics" + assert session.reconstructed["empty"] is True + assert log.index("open_session") < log.index("boot") + assert log[-1] == "close_session" + + +def test_the_lease_is_released_when_the_scope_ends(tmp_path, monkeypatch) -> None: + """The unmissable half: a scope that closes is a VM released.""" + monkeypatch.setattr("repl_sandbox.session_host.SessionBridge.start", lambda self: None) + log: list[str] = [] + with _session(tmp_path, log): + assert os.path.exists(os.path.join(str(tmp_path), "physics.lease")) + assert not os.path.exists(os.path.join(str(tmp_path), "physics.lease")) + + +def test_a_body_that_raises_still_unwinds_everything(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("repl_sandbox.session_host.SessionBridge.start", lambda self: None) + log: list[str] = [] + with pytest.raises(RuntimeError): + with _session(tmp_path, log): + raise RuntimeError("planted: the caller failed") + assert "guest.shutdown" in log + assert "close_session" in log + assert not os.path.exists(os.path.join(str(tmp_path), "physics.lease")) + + +def test_a_boot_failure_releases_the_lease_it_had_already_taken(tmp_path) -> None: + """A failure at step n unwinds n-1..1 and strands nothing. + + The lease is taken before the boot -- it has to be, since it records the + sandbox name -- so a boot that raises is exactly the case where a workspace + could be left locked by a session that never existed. + """ + log: list[str] = [] + with pytest.raises(SandboxError): + with _session(tmp_path, log, fail_at_boot=True): + pass + assert not os.path.exists(os.path.join(str(tmp_path), "physics.lease")) + assert "close_session" in log + assert "guest.shutdown" not in log From 948b863c69dc2ff6d5ff5b51463a7585ab20e366 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Fri, 24 Jul 2026 23:53:35 -0500 Subject: [PATCH 11/13] The snapshot glitch, and the reconciliation it forced A live Kata guest was paused and snapshotted through Cloud Hypervisor's own API socket. Every step reported success; afterwards the container could not be re-entered and teardown could not reap the VMM. The cause is that a Kata sandbox has two control planes reaching one virtual machine and neither knows about the other. containerd's authority runs through kata-agent, a process *inside* the guest; pausing the VM freezes that process, the shim's RPC times out, and the channel is torn down. Resuming brings the guest back and re-establishes nothing. The mistake errored at no point -- reaching past containerd did not do something it forbade, it did something it could not see. That is the fifth instance of this program's recurring shape, and the first stated as a rule about operations rather than about enforcing surfaces: an operation is only as safe as the layer it is issued at. The reconciliation is recorded because it looked like a fork and was not. Workspace persistence appeared to want VM snapshots while the launch path is containerd. The owner's ruling that the manifest ships empty settles it at the application layer instead: the VM returns to being disposable, which is what the context-manager lifetime already assumed, containerd stays the launch path so nothing measured on hardware is invalidated, and snapshot becomes a future warm-start optimisation rather than the foundation of persistence. Also records the four rulings the next build carries, and flags that the workspace-identity composition is proposed rather than ratified -- Matt raised it as a revision to the standing plan and deferred to Cnid, and that agreement is not on the record yet. --- docs/architecture/REPL_SYSTEM_VIEW.md | 84 ++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/docs/architecture/REPL_SYSTEM_VIEW.md b/docs/architecture/REPL_SYSTEM_VIEW.md index 3b6c194..c93b94d 100644 --- a/docs/architecture/REPL_SYSTEM_VIEW.md +++ b/docs/architecture/REPL_SYSTEM_VIEW.md @@ -86,6 +86,62 @@ a large derived frame — which is exactly the state a long-running workspace ac namespace is authored by untrusted model code, so the bytes are attacker-influenced, and `pickle.load` executes them. Also cannot carry live sockets or clients. +### 3a. The glitch, plainly — two control planes over one VM + +**What was observed.** A live Kata guest was paused and snapshotted through Cloud Hypervisor's own +API socket. Every step reported success. After `resume`, the container could not be re-entered: +`ctr task exec` returned `DeadlineExceeded`, and ordinary teardown could not reap the VMM. + +**Why, mechanically.** A Kata sandbox has **two control planes reaching the same virtual machine, +and neither knows about the other**: + + containerd → containerd-shim-kata-v2 → kata-agent (ttrpc over vsock) ← lifecycle, exec + ch-remote → clh-api.sock → Cloud Hypervisor ← the VMM itself + +The shim's authority runs *through a process inside the guest*. Pausing the VM freezes that process, +so the agent stops answering; the shim's RPC to it times out and the channel is torn down. Resuming +the VM brings the guest back, but nothing re-establishes what the shim gave up — and the guest's +clock has jumped besides. The VM is alive and the thing containerd uses to talk to it is not. + +**The generalisation, which is this program's recurring shape for the fifth time.** *An operation is +only as safe as the layer it is issued at.* Reaching past containerd to the VMM did not do something +containerd forbade; it did something containerd could not see, and invalidated state containerd was +holding. Nothing errored at the moment of the mistake. Compare: the vsock peer CID (a claim about +one kernel feature carried as a claim about virtualisation), the in-guest cgroups, the reserved-names +channel, and `pgrep` (a pattern match trusted as an identity). + +**The check that caught it.** `KataGuestHandle.shutdown` re-checks reality after teardown and raises +when a VMM survives, rather than swallowing every error the way the probes' `destroy` does. That +distinction was written for a hypothetical and met a real one within a day: the failure surfaced as a +named error rather than as a quietly leaked VM on a metered host. + +**A sanctioned path exists.** `ctr tasks pause` / `resume` drive the same operation *through* +containerd, so the shim participates rather than being bypassed. Whether Kata's Cloud Hypervisor +configuration carries pause through to a usable snapshot is unmeasured; the point is that the layer +is available and the direct-to-VMM route is the one that was wrong. + +### 3b. The reconciliation + +The tension looked like this: **workspace persistence seemed to want VM snapshots, and the launch +path is containerd — and the two fight.** Three ways to settle it: + +| | approach | what it costs | +|---|---|---| +| **1** | snapshot through `ctr tasks pause`, so the shim participates | unmeasured; keeps both planes, still couples persistence to the VMM | +| **2** | drop containerd, drive Cloud Hypervisor directly | one control plane, no conflict — but forfeits image management, the shim, and the agent, and invalidates the launch path already proven on hardware | +| **3** | persist at the **application** layer; keep the VM disposable | the workspace is a manifest plus substrate content, so nothing needs to be frozen | + +**Option 3 is what the owner's ruling already chose** (§6 item 1: ship with an empty manifest the +session clones into). That reconciles everything at once. The VM goes back to being genuinely +disposable, which is what the context-manager lifetime already assumed. containerd stays the launch +path, so nothing measured on hardware is invalidated. And the snapshot becomes a **future +optimisation for warm start** rather than the foundation of persistence — which is the right place +for a mechanism whose caveats are still unmeasured. + +The measurement was still worth taking. It establishes that snapshot is real, fast (0.2 s) and cheap +(174 MB for a 2 GiB guest) *if* it is ever wanted, and it converted an open architectural question +into a scoping fact with numbers attached. What it must not become is the persistence design. + **(c) VM snapshot — measured 2026-07-25, and it works.** Cloud Hypervisor v52.0 exposes `pause` / `snapshot` / `restore` / `resume` through `ch-remote` against the API socket Kata already creates, which `KataLauncher` already discovers (it parses `--api-socket` from the VMM's argv). @@ -130,7 +186,33 @@ false across instances.** Rule 19(a) is the reason that property was wanted, and being module constants. Recorded here rather than fixed in passing, because it changes a deployment-visible default. -## 5. What a later session should check rather than trust +## 5. Rulings of 2026-07-25 that the next build carries + +Four answers from Matt and Cnid, recorded here because each one settles something a build would +otherwise have to guess. + +1. **The manifest ships empty.** `session_host.py` is built against today's reality — a session + opens, handles are issued fresh, nothing is restored — carrying an **empty manifest the session + clones into the form it takes.** Reconstruction arrives later against a shape that already + exists. This is also what settles §3b: persistence is an application-layer artifact, so the VM + stays disposable. +2. **A workspace's identity is composed, not arbitrary** — a unique Trellis user id plus + environmental parameters such as the date. It is the same value that must appear in + `open_session`, in the ledgers, and in the backend, which is why one component must mint it. + **Matt raised this as worth revising the standing plan and deferred to Cnid; that agreement is + not recorded yet**, so the composition rule below is proposed, not ratified. +3. **A checked-out workspace auto-reclaims after a crash**, after inspecting the state of the VM it + is recovering. Not an operator ceremony. +4. **A repo-as-workspace is written by the harness, not by the model.** The model calls a + programmatic file edit; bytes reach disk through an engine-performed operation, which is + [code-mediated text](CODE_MEDIATED_TEXT.md) applied to self-modification rather than an exception + to it. + +**Consequence worth stating once:** items 2 and 3 together are what make "no concurrency by design" +enforceable rather than conventional. A composed identity gives the lock a name; a liveness-checked +reclaim keeps a crash from locking a user out of their own data. Neither needs a snapshot. + +## 6. What a later session should check rather than trust - That one workspace per session still holds, before building anything that assumes a VM per workspace swap. From 700acfd50bb4f8c30370a9816d06c9b6ed1cf343 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Sat, 25 Jul 2026 00:09:18 -0500 Subject: [PATCH 12/13] Identity is minted once, and the diagonal runs Owner direction (Matt, 2026-07-25): a workspace identifier carries a timestamp at the finest granularity the system offers plus a UUID, and the human-facing label moves to metadata. That dissolved the ambiguity rather than managing it. Deriving an identifier from a workspace's name and the current date forces a choice between stable and unique -- a date breaks it as a lock key, no date makes two workspaces called "physics" the same workspace. Minting once at creation gives both. "physics" is now a display name: no lease, ledger, manifest or audit line refers to it, so a rename is a one-field edit. The user component stays operational identity only -- it names a lease holder on this machine and never reaches the substrate, where FEATURE_LIST 1.6 is closed on the ground that nothing names an owner. A stored Trellis user id is a live proposal needing its own dated entry; this build forecloses neither choice. Then the diagonal ran on the AX41 for the first time: the real backend composition against the real microVM boundary, rather than either half against a stand-in. 7/7 on the second attempt -- a real VMM carrying the session's sandbox, the host end bound at _5001, an empty manifest reconstructing to a no-op, the workspace checked out for the life of the scope, a second session on the same live workspace refused, the scope closing to zero VMMs, containers, VM directories and no lease, and a crashed holder's lease auto-reclaimed against a real liveness check. The first attempt returned 6/7 and found what no off-host test could see. The module header lists step 5 as "bind the listeners"; the code attached the bridge and left starting it to KataREPL.setup(). Every off-host test passed, because none of them opens a session without also driving a backend -- so a session composed without one came up with no host end at all, and the guest's first tool call would have met a closed connection. A step described in prose and merely prepared in code reads as done; third instance of that shape here. The repair makes SessionBridge.start idempotent by design rather than defensively. The composition layer binds at step 5 because it owns the failure -- a second listener can fail after the first is bound and a VM is running, and only the party holding both can unwind them -- while KataREPL.setup is equally right to assert the bridge is up before any untrusted worker. Both callers are correct, so the second call confirms rather than rebinding a path already held. pytest 1067 -> 1071, vitest 1416/119, repo-surface PASS, density-trellis PASS. --- docs/architecture/THE_REPL_IN_TRELLIS.md | 72 ++++++++++++- docs/density-chain/DENSITY-CHAIN.md | 2 +- .../repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md | 34 +++++- src/repl_sandbox/session_host.py | 100 +++++++++++++++--- src/repl_sandbox/tests/test_session_host.py | 92 +++++++++++++--- 5 files changed, 262 insertions(+), 38 deletions(-) diff --git a/docs/architecture/THE_REPL_IN_TRELLIS.md b/docs/architecture/THE_REPL_IN_TRELLIS.md index b2997ab..a0b907a 100644 --- a/docs/architecture/THE_REPL_IN_TRELLIS.md +++ b/docs/architecture/THE_REPL_IN_TRELLIS.md @@ -171,7 +171,77 @@ which is a local lock rather than a distributed one — the difference between a file lease and a consensus problem. A design that assumed roaming devices editing the same workspace would have bought the hard version of this for no reason. -## 6. What is not settled +## 6. Identity — minted once, named separately + +**Owner direction (Matt, 2026-07-25), and it dissolved an ambiguity rather than +managing it.** A workspace identifier carries a timestamp at the finest +granularity the system offers plus a UUID, and the human-facing label lives in +metadata as `human_readable_name`. + +The session that raised the ambiguity had proposed composing an identifier from +the workspace's *name* and the *current date*, which forces a choice between two +properties a workspace needs at once: a date makes the identifier unique per +opening and useless as a lock key, and no date makes two workspaces called +"physics" the same workspace. **Minting once at creation gives both at no cost** +— it never moves, and it never collides. + +The consequence is worth stating because it is what makes renaming safe: +**"physics" is a display name, not an identity.** No lease, no ledger, no +manifest and no audit line ever refers to it, so a rename is a one-field edit. + +Two identifiers, not one: + +| | minted | shape | keys | +|---|---|---|---| +| **workspace id** | once, at creation | `ws--` | the lease, the manifest | +| **session id** | once per opening | `--` | ledgers, audit, the CID binding | + +The user component is **operational identity only**: it names who holds a lease +on this machine, lives in configuration, and never reaches the substrate. +`FEATURE_LIST.md` 1.6 is closed on the ground that nothing in the store names an +owner; a user id written there would revise that ruling rather than apply it. +Matt flagged that a stored Trellis user id is worth revisiting as part of the +repo address-code idea — that is a live proposal needing a dated entry, and this +build is deliberately on the safe side of it, foreclosing neither choice. + +## 7. What running it changed + +The composition landed as `src/repl_sandbox/session_host.py` and was driven +against the AX41 on 2026-07-25 — **the first execution of the diagonal**: the +real backend composition against the real microVM boundary, rather than either +half against a stand-in for the other. Seven claims, all holding on the second +attempt. + +**The defect the first attempt found, which no off-host test could have.** The +module's own header lists six ordered steps, step 5 being *bind the listeners*. +The code attached the bridge and left starting it to `KataREPL.setup()`. Every +off-host test passed, because **none of them opens a session without also +driving a backend** — so a session composed without one came up with no host end +at all, and the guest's first tool call would have met a closed connection. On +hardware the bound-socket list was simply empty, which is unmissable. + +Generalisable, and now the third instance in this program: *a step described in +prose and merely prepared in code reads as done.* The earlier two were an +enforcing surface named on a mechanism that could not carry it, and a check +written to verify a rule while itself breaking that rule. + +The repair also names a real subtlety: `SessionBridge.start()` is **idempotent +by design, not defensively**. The composition layer binds at step 5 because it +owns the failure — a second listener can fail after the first is bound and a +microVM is running, and only the party holding both can unwind them. But +`KataREPL.setup()` is equally right to assert the bridge is up before any +untrusted worker. Both callers are correct, so the second call confirms rather +than rebinds, which would fail on the socket path the first already holds. + +**Observed, with the boundary crossed:** a real VMM carrying the session's +sandbox; the host end bound at `_5001`; an empty manifest reconstructing to +a no-op with `human_readable_name` intact; the workspace checked out for the +life of the scope; **a second session on the same live workspace refused**; the +scope closing to zero VMMs, zero containers, zero VM directories and no lease; +and **a crashed holder's lease auto-reclaimed against a real liveness check**, +which is the mechanism §4 specifies rather than a stand-in for it. + +## 8. What is not settled - **The manifest's shape and where it lives.** §3 says a workspace is its store plus a manifest; nothing in the tree implements one yet. diff --git a/docs/density-chain/DENSITY-CHAIN.md b/docs/density-chain/DENSITY-CHAIN.md index cb50ac4..3635828 100644 --- a/docs/density-chain/DENSITY-CHAIN.md +++ b/docs/density-chain/DENSITY-CHAIN.md @@ -134,7 +134,7 @@ across for one subsystem's arc. | **C9 Mechinterp sidecar** | read and steer a served model's functional-affect state in the residual stream | *(nothing)* — one 288-line docs-only record | entirely prerequisite: hosted arm → local backend → sidecar, and step one is a proposal | instrument/actuator/mixture ladder M1–M4; percolative-Ising controller; the judge-actuation hazard, held outside the repo | | **C10 Benchmarks & evidence** | a capability claim is a hypothesis until a dated report retires it | OOLONG v1, update/poison/scale drills, effective-context rounds, citation A/B, wall-clock — all dated | anti-shortcut corpus v2 pinned zero-paid with **no paid run**; the uncommitted nine-refusal sandbox drill is **[R]**-only, outside CI | real TREC import; adversarial corpora; 10k sweeps; multi-run variance replacing n=1; consensus writes | | **C11 Serving & governance** | narrow, authenticated, admission-bounded doors; a written contract about which record wins | HTTP/SSE API, A2A server, outbound MCP client (byte-identical when off); AGENTS.md, session governance, the root contract | the surface checker is **green again** (`20e94ae` restored the density-chain links); `KNOWN_ROUTES` mislabels two routes | inbound MCP server surface with five open decisions; OAuth posture; the dual client+server role | -| **C12 REPL sandbox** | treat model-authored Python as hostile and own the boundary between it and the operator's secrets | the host-independent control plane, merged with CI and npm callers; on one Hetzner AX41: **G1, S2, S3 `[R]`+`[A]`, S4 `[R]`+`[A]`, S5 `[R]`**, and **2026-07-25 a production launch path** — a microVM boots, a frame crosses, a real model drives `llm_query` and composes the `run_query` facade against a real Postgres holding only a handle, Tier-0 caps a fork bomb and denies a syscall and a write while both channels still cross, and `KataLauncher.boot` now claims a real VMM in its own containerd namespace, refuses a shim that exited 0 without one, and releases everything it allocated when it does | **still not a sandbox and must not be read as one**: the NIC egress policy is absent, and the launch path stops at `start_bridge`, which **refuses** — the guest needs no in-guest forwarder (no rlms there), but **who owns a session's `LM_PORT`/`DB_PORT` listeners is unsettled**: `kata_repl` assigns them to the LM handler and broker, `KataLauncher` takes no host, and `KataREPL`-with-a-host and real-Kata-transport have only ever been proven separately, each substituting for the other half. Egress self-labels **weak**; the spend cap is between-calls; the watchdog is unproven against real shim wedges | that listener-ownership decision, which S6's `[A]` gate needs and no build item named; then S6's equivalence harness against its stated target (twelve clauses predicted FALSE); GB, GA-eq, GA-rt; doubt-filter Layers 1–2; warm pool; `max_depth` 2; a paramstyle line in the `run_query` doc; the remaining **[A]** halves (S6, GB, GA-eq), ≤$5, unspent | +| **C12 REPL sandbox** | treat model-authored Python as hostile and own the boundary between it and the operator's secrets | the host-independent control plane, merged with CI and npm callers; on one Hetzner AX41: **G1, S2, S3 `[R]`+`[A]`, S4 `[R]`+`[A]`, S5 `[R]`**, and **2026-07-25 a production launch path** — a microVM boots, a frame crosses, a real model drives `llm_query` and composes the `run_query` facade against a real Postgres holding only a handle, Tier-0 caps a fork bomb and denies a syscall and a write while both channels still cross, and `KataLauncher.boot` now claims a real VMM in its own containerd namespace, refuses a shim that exited 0 without one, and releases everything it allocated when it does | **still not a sandbox and must not be read as one**: the NIC egress policy is absent, and the **[A] halves are unspent** — the composition layer closed the listener-ownership seam on 2026-07-25 (`session_host.open_workspace_session`, a context manager over lease/host/session/boot/listeners/scaffold), and the *diagonal* ran on the AX41 7/7: real backend composition against a real microVM, host end bound at `_5001`, a second session on a live workspace refused, a crashed holder's lease auto-reclaimed on a real liveness check, and the scope closing to zero residue. Egress self-labels **weak**; the spend cap is between-calls; the watchdog is unproven against real shim wedges | that listener-ownership decision, which S6's `[A]` gate needs and no build item named; then S6's equivalence harness against its stated target (twelve clauses predicted FALSE); GB, GA-eq, GA-rt; doubt-filter Layers 1–2; warm pool; `max_depth` 2; a paramstyle line in the `run_query` doc; the remaining **[A]** halves (S6, GB, GA-eq), ≤$5, unspent | | **C13 Self-describing surfaces** | the account a system gives of itself must be derived from whatever enforces its behavior | the root contract, its machine twin, and the deterministic surface checker in CI; the first shipped descriptor — `trellis_textedit`'s addendum composes, byte-identity pinned per arm | the record↔twin asymmetry is structural — the checker proves twin↔tree only; Phase 0 **falsified its own specification**; a newline-free bijection orphan is pinned in the guarded arm; `llm_help` stays **authorized and unbuilt** | `llm_help`; the surface registry and its coverage diagnostic; the remaining eight descriptors (no pin ceremony owed); a human-doc generator; the self-play discrimination gate | --- diff --git a/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md b/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md index e27b527..7f320c8 100644 --- a/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md +++ b/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md @@ -806,8 +806,38 @@ Three corrections the build owes to running rather than reading: already exist (first poll at 15 ms found both). An absent VMM is *never booted*, not *not booted yet*, so waiting would convert a clean refusal into a timeout. -**The eighth item, and the one this build stopped at.** `start_bridge()` raises rather than -passing. What is settled: the guest needs no in-guest loopback→vsock forwarder, because §3.3's +**THE EIGHTH ITEM IS CLOSED — 2026-07-25, `src/repl_sandbox/session_host.py`.** The owner ruled +option A (a composition layer) on the ground that its only cost was naming a seam. Building it +established that the seam was not new: **the block already worked in five places** — every probe +and the CLI selftest each construct a host, open a session, bind the listeners and drive the +backend — but each proved one half by substituting a stand-in for the other, so the *diagonal* +(real backend, real boundary) had never run. This promoted it out of `scripts/` and named it. + +`open_workspace_session` is a context manager, and that was never really a trade: the durable +thing and the executing thing are different objects, so a closing scope is a VM released rather +than a session lost, and rlms already ends a non-persistent run with `cleanup()`. Its six steps +are forced — lease, host, `open_session`, boot, listeners, scaffold — because the vsock socket +path does not exist until the VMM does, and the guest dials outward on its first tool call. +Teardown reverses them, and a failure at step *n* unwinds *n-1 … 1*. + +**Observed on the AX41, 2026-07-25, 7/7 claims:** a real VMM carrying the session's sandbox; the +host end bound at `_5001`; an empty manifest reconstructing to a no-op; the workspace checked +out for the life of the scope; a second session on the same live workspace refused; the scope +closing to zero VMMs, containers, VM directories and no lease; and a crashed holder's lease +auto-reclaimed against a real liveness check. Identity, manifest and lease design are recorded in +[THE_REPL_IN_TRELLIS.md](../../architecture/THE_REPL_IN_TRELLIS.md) §6–§7. + +The first attempt returned 6/7 and found a defect no off-host test could: step 5 was described in +the module header and only *prepared* in the code, so a session composed without a backend came up +with no host end at all. **A step described in prose and merely prepared in code reads as done** — +the third instance of that shape here. + +**What this leaves for S6.** The `[R]` half now has its enforcing surface. The equivalence harness +against `CONFORMANCE §6`'s twelve predicted-FALSE clauses is unbuilt, and the `[A]` half — the +metered real-model equivalence run, ≤ $5 — has not been proposed or spent. + +**Superseded, kept because the reasoning still holds.** Before the ruling, `start_bridge()` raised +unconditionally. What is settled: the guest needs no in-guest loopback→vsock forwarder, because §3.3's Option A presupposes an rlms client in the guest speaking `AF_INET` and there is no rlms in the guest — `guest_main.build_rpc_hook` dials `AF_VSOCK` directly. What is **not** settled, and no item 1–7 names: **who stands up a session's `LM_PORT`/`DB_PORT` listeners.** `kata_repl.py`'s step-2 diff --git a/src/repl_sandbox/session_host.py b/src/repl_sandbox/session_host.py index b9a6823..b1d24a2 100644 --- a/src/repl_sandbox/session_host.py +++ b/src/repl_sandbox/session_host.py @@ -52,7 +52,7 @@ import uuid from contextlib import contextmanager from dataclasses import dataclass, field, replace -from datetime import date +from datetime import datetime, timezone from typing import Any, Callable, Iterator, Sequence from repl_sandbox.audit import AuditLog @@ -74,15 +74,52 @@ # --------------------------------------------------------------------------- -def mint_session_id(user_id: str, workspace_id: str, *, today: date | None = None) -> str: - """One session's identifier: stable-workspace, dated, and unique. +def _stamp(now: datetime | None = None) -> str: + """UTC, to the finest granularity the platform clock offers, sortable. - **Two identifiers, not one, and collapsing them is the trap.** A workspace - id is *stable* — the physics workspace is the same workspace next Tuesday, - which is what makes it lockable and what makes a manifest findable. A session - id is *unique per opening*, because it keys the ledgers, the audit trail and - the CID binding. A date belongs in the second and would silently break the - first. + `datetime` carries microseconds, which is the smallest unit that survives + into a filename without inventing precision the clock does not have. UTC + rather than local time so two Trellises in different zones on one host still + sort correctly against each other. + """ + moment = now if now is not None else datetime.now(timezone.utc) + return moment.strftime("%Y%m%dT%H%M%S.%f") + + +def mint_workspace_id(*, now: datetime | None = None) -> str: + """A workspace's permanent identifier, minted **once, at creation**. + + Owner direction (Matt, 2026-07-25): the timestamp goes to the finest + granularity the system offers and a UUID keeps it unique, with the + human-facing label held as metadata instead. + + This is what dissolves the naming ambiguity rather than managing it. The + earlier shape derived an identifier from the workspace's *name* and the + *current* date, which forced a choice between stable and unique: a date made + it unique per session and broke it as a lock key, and no date made two + workspaces called "physics" the same workspace. Minting once at creation + gives both properties at no cost — it never moves, and it never collides. + + The consequence worth stating: **"physics" is a display name, not an + identity.** Renaming a workspace touches one metadata field and breaks + nothing, because no lease, no manifest and no ledger ever referred to the + name. + """ + return f"ws-{_stamp(now)}-{uuid.uuid4().hex}" + + +def mint_session_id( + user_id: str, + workspace_id: str, + *, + now: datetime | None = None, +) -> str: + """One session's identifier: unique per opening, and traceable to both ends. + + **Two identifiers, not one, and collapsing them is the trap.** A workspace id + is permanent (`mint_workspace_id`) — it is what a lease locks and what a + manifest is found by. A session id is *unique per opening*, because it keys + the ledgers, the audit trail and the CID binding. The user component is deliberately **operational identity only**: it names who holds a lease on this machine, it lives in configuration, and it is never @@ -90,15 +127,14 @@ def mint_session_id(user_id: str, workspace_id: str, *, today: date | None = Non nothing in the store names an owner and nothing needs to; a user id that reached the store would revise that ruling rather than apply it. - Entropy is not decoration either. One user opening two sessions on one day is - ordinary, so `user + date` alone collides — and a collision here is two - sessions sharing a ledger. + Entropy is not decoration. One user opening two sessions inside the same + clock tick is ordinary under automation, and a collision here is two sessions + sharing a ledger. """ for name, value in (("user_id", user_id), ("workspace_id", workspace_id)): if not isinstance(value, str) or not value.strip(): raise SandboxError(f"{name} must be a non-empty string") - stamp = (today or date.today()).isoformat() - return f"{_slug(user_id)}-{_slug(workspace_id)}-{stamp}-{uuid.uuid4().hex[:8]}" + return f"{_slug(user_id)}-{_stamp(now)}-{uuid.uuid4().hex[:12]}" def _slug(raw: str) -> str: @@ -136,6 +172,13 @@ class WorkspaceManifest: workspace_id: str schema_version: int = MANIFEST_SCHEMA_VERSION + #: What a person calls this workspace — "physics", "parts inventory". + #: + #: Metadata, deliberately, and never an identifier (owner direction, Matt, + #: 2026-07-25). Nothing resolves it, nothing locks on it, and nothing joins + #: by it, so renaming a workspace is a one-field edit that breaks no lease, + #: no ledger and no manifest. It is also the only field here a user writes. + human_readable_name: str = "" #: Document versions that are live for this workspace. Addresses, never bytes. live_documents: tuple[str, ...] = () #: Root handles pre-allocated at session open (facts, beliefs, doubts). @@ -153,6 +196,7 @@ def to_json(self) -> str: { "workspace_id": self.workspace_id, "schema_version": self.schema_version, + "human_readable_name": self.human_readable_name, "live_documents": list(self.live_documents), "root_handles": list(self.root_handles), "artifacts": list(self.artifacts), @@ -162,8 +206,8 @@ def to_json(self) -> str: ) @classmethod - def empty(cls, workspace_id: str) -> "WorkspaceManifest": - return cls(workspace_id=workspace_id) + def empty(cls, workspace_id: str, human_readable_name: str = "") -> "WorkspaceManifest": + return cls(workspace_id=workspace_id, human_readable_name=human_readable_name) @classmethod def from_json(cls, raw: str, workspace_id: str) -> "WorkspaceManifest": @@ -186,6 +230,7 @@ def from_json(cls, raw: str, workspace_id: str) -> "WorkspaceManifest": return cls( workspace_id=data.get("workspace_id", workspace_id), schema_version=version, + human_readable_name=data.get("human_readable_name", ""), live_documents=tuple(data.get("live_documents", ())), root_handles=tuple(data.get("root_handles", ())), artifacts=tuple(data.get("artifacts", ())), @@ -332,7 +377,20 @@ def __init__( self.bound: tuple[str, ...] = () def start(self) -> None: - """Bind every granted port, or bind none and leave nothing behind.""" + """Bind every granted port, or bind none and leave nothing behind. + + **Idempotent, and that is load-bearing rather than defensive.** The + composition layer binds at its step 5, because it owns the failure: a + second listener can fail after the first is bound and a microVM is + already running, and only the party holding both can unwind them. But + `KataREPL.setup()` also calls `start_bridge()`, because from the + backend's side "the bridge is up before any untrusted worker" is a + precondition it is right to assert. Both callers are correct, so the + second call confirms rather than rebinds — a re-bind would fail on the + socket path the first one already holds. + """ + if self.bound: + return bound: list[str] = [] try: for port, handler in self._ports: @@ -453,6 +511,14 @@ def open_workspace_session( if ops: ports.append((config.ports.db, host.broker_handler)) bridge = SessionBridge(guest.uds_path, cid, config, ports, audit=audit) + # Step 5, performed rather than merely prepared. An earlier draft only + # attached the bridge and left starting it to `KataREPL.setup()`, which + # meant a session composed without a backend came up with no host end at + # all — the guest's first tool call would have met a closed connection. + # Found by running the diagonal on real hardware, where `bound` was + # empty; no off-host test could see it, because none of them opens a + # session without also driving a backend. + bridge.start() guest.attach_bridge(bridge) yield KataSession( diff --git a/src/repl_sandbox/tests/test_session_host.py b/src/repl_sandbox/tests/test_session_host.py index fd2dedf..ffc9179 100644 --- a/src/repl_sandbox/tests/test_session_host.py +++ b/src/repl_sandbox/tests/test_session_host.py @@ -12,7 +12,7 @@ import json import os -from datetime import date +from datetime import datetime, timezone import pytest @@ -20,6 +20,7 @@ from repl_sandbox.errors import DeniedError, SandboxError from repl_sandbox.session_host import ( MANIFEST_SCHEMA_VERSION, + mint_workspace_id, KataSession, WorkspaceLease, WorkspaceManifest, @@ -33,30 +34,56 @@ # --------------------------------------------------------------------------- -def test_a_session_id_is_unique_per_opening_not_per_day() -> None: - """Two sessions on one day is ordinary; a collision would share a ledger.""" - day = date(2026, 7, 25) - first = mint_session_id("cnid", "physics", today=day) - second = mint_session_id("cnid", "physics", today=day) +def test_a_session_id_is_unique_even_inside_one_clock_tick() -> None: + """Automation opens sessions faster than a clock ticks; a collision shares a ledger.""" + frozen = datetime(2026, 7, 25, 12, 0, 0, 123456, tzinfo=timezone.utc) + ws = mint_workspace_id(now=frozen) + first = mint_session_id("cnid", ws, now=frozen) + second = mint_session_id("cnid", ws, now=frozen) assert first != second - assert first.startswith("cnid-physics-2026-07-25-") + assert first.startswith("cnid-20260725T120000.123456-") -def test_the_workspace_component_is_stable_across_days() -> None: - """A workspace is the same workspace next Tuesday, so its name cannot move. +def test_a_workspace_id_is_minted_once_and_never_derived_from_a_name() -> None: + """Owner direction (Matt): timestamp to finest granularity, plus a UUID. - The date belongs to the session identifier and would silently break the - workspace one -- which is the whole reason these are two values. + This is what makes the identifier stable AND unique at the same time. An id + derived from the workspace's name and the current date could only be one or + the other -- and the human-facing label moves to metadata, so a rename + touches no lease, no ledger and no manifest. """ - tuesday = mint_session_id("cnid", "physics", today=date(2026, 7, 21)) - next_tuesday = mint_session_id("cnid", "physics", today=date(2026, 7, 28)) - assert tuesday.split("-2026-")[0] == next_tuesday.split("-2026-")[0] == "cnid-physics" + frozen = datetime(2026, 7, 25, 12, 0, 0, 123456, tzinfo=timezone.utc) + first = mint_workspace_id(now=frozen) + second = mint_workspace_id(now=frozen) + assert first != second + assert first.startswith("ws-20260725T120000.123456-") + # Nothing about the human name appears in it. + assert "physics" not in WorkspaceManifest.empty(first, "physics").workspace_id + + +def test_the_stamp_carries_sub_second_granularity_and_sorts( ) -> None: + """Lexicographic order must equal chronological order for a filename.""" + early = mint_workspace_id(now=datetime(2026, 7, 25, 12, 0, 0, 100000, tzinfo=timezone.utc)) + late = mint_workspace_id(now=datetime(2026, 7, 25, 12, 0, 0, 900000, tzinfo=timezone.utc)) + assert early < late + assert ".100000-" in early and ".900000-" in late + + +def test_a_rename_changes_metadata_and_nothing_else() -> None: + ws = mint_workspace_id() + before = WorkspaceManifest.empty(ws, "physics") + after = WorkspaceManifest.from_json(before.to_json(), ws) + renamed = WorkspaceManifest( + workspace_id=after.workspace_id, human_readable_name="astrophysics" + ) + assert renamed.workspace_id == before.workspace_id + assert renamed.human_readable_name != before.human_readable_name def test_an_empty_identifier_is_refused() -> None: for bad in ("", " "): with pytest.raises(SandboxError): - mint_session_id(bad, "physics") + mint_session_id(bad, "ws-1") with pytest.raises(SandboxError): mint_session_id("cnid", bad) @@ -100,9 +127,10 @@ def test_a_manifest_holds_addresses_never_a_namespace() -> None: arbitrary-code-execution primitive running with the host's privileges, so the absence of any such field is a property worth pinning rather than assuming. """ - fields = set(WorkspaceManifest.empty("physics").__dataclass_fields__) + fields = set(WorkspaceManifest.empty("ws-1").__dataclass_fields__) assert fields == { - "workspace_id", "schema_version", "live_documents", "root_handles", "artifacts", + "workspace_id", "schema_version", "human_readable_name", + "live_documents", "root_handles", "artifacts", } @@ -278,3 +306,33 @@ def test_a_boot_failure_releases_the_lease_it_had_already_taken(tmp_path) -> Non assert not os.path.exists(os.path.join(str(tmp_path), "physics.lease")) assert "close_session" in log assert "guest.shutdown" not in log + + +def test_the_session_starts_the_bridge_rather_than_only_attaching_it(tmp_path) -> None: + """Found on hardware: a session composed without a backend had no host end. + + An earlier draft attached the bridge and left starting it to + `KataREPL.setup()`. Every off-host test passed, because none of them opens a + session without also driving a backend -- so the guest's first tool call + would have met a closed connection and nothing here would have said so. + """ + started: list[str] = [] + log: list[str] = [] + from repl_sandbox import session_host as sh + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sh.SessionBridge, "start", lambda self: started.append("start")) + with _session(tmp_path, log) as session: + assert started == ["start"], "the composition layer must perform step 5, not defer it" + assert session.guest.bridge is not None, "and the backend must still find it attached" + + +def test_starting_a_bound_bridge_again_is_a_confirmation_not_a_rebind() -> None: + """Both callers are correct; a re-bind would fail on the path already held.""" + from repl_sandbox.session_host import SessionBridge + + bridge = SessionBridge("/nonexistent/clh.sock", 16, SandboxConfig(), []) + bridge.start() # no ports granted: binds nothing, sets bound to () + bridge.bound = ("/already/bound_5001",) + bridge.start() # must not attempt a second bind + assert bridge.bound == ("/already/bound_5001",) From f209011e22fd5bf3d847fc6541f7f871e0aed7e3 Mon Sep 17 00:00:00 2001 From: Darian Ngo Date: Sat, 25 Jul 2026 00:17:37 -0500 Subject: [PATCH 13/13] Ignore the preview tooling's per-worktree dev-server config .claude/launch.json is scaffolded by the Claude Code preview tooling when the browser pane is used, not authored. It appeared mid-session in one worktree, had never been in git history, and was not ignored -- so a blanket `git add -A` swept it into a commit, which would have made one session's incidental choice of dev server the default every later session loads. The repo already states this convention at the line above: committed harness config is deliberate (.claude/settings.json, rules, skills, ceremonies) and per-machine harness state is ignored (.claude/settings.local.json). This is the second kind and had no rule yet. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 29faad9..d845219 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ benchmark_logs/ # Personal Claude Code overrides; the committed harness config is .claude/settings.json .claude/settings.local.json + +# Dev-server config the Claude Code preview tooling scaffolds per worktree. It is +# generated rather than authored, so committing it would make one session's +# incidental choice of server the default every later session loads. +.claude/launch.json