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 diff --git a/docs/architecture/REPL_SYSTEM_VIEW.md b/docs/architecture/REPL_SYSTEM_VIEW.md new file mode 100644 index 0000000..c93b94d --- /dev/null +++ b/docs/architecture/REPL_SYSTEM_VIEW.md @@ -0,0 +1,223 @@ +# 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. + +### 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). +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. 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. +- 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. diff --git a/docs/architecture/THE_REPL_IN_TRELLIS.md b/docs/architecture/THE_REPL_IN_TRELLIS.md new file mode 100644 index 0000000..a0b907a --- /dev/null +++ b/docs/architecture/THE_REPL_IN_TRELLIS.md @@ -0,0 +1,256 @@ +# 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. 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. +- **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/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. + 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..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]`** — 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 **[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 | --- @@ -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 diff --git a/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md b/docs/product/repl-sandbox/REPL_SANDBOX_BUILD_PLAN.md index b777e88..7f320c8 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,88 @@ 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 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 +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 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/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/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/launcher.py b/src/repl_sandbox/launcher.py index 6320821..9b1e066 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 typing import Any, 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 @@ -54,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 @@ -117,6 +133,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 +490,75 @@ 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. + + 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 + 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'`). + """ + return _pids_by_executable(CLOUD_HYPERVISOR_BIN, carrying=name, proc_root=proc_root) + + 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 +587,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 +622,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 +641,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,9 +817,78 @@ def _check_acceleration(self, failures: list[str]) -> dict: # -- boot -------------------------------------------------------------- - def boot(self, session_id: str) -> GuestHandle: + 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, *, 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. @@ -651,23 +903,294 @@ 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 = sandbox_name if sandbox_name is not None else 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.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: + """Bring the host end up, or refuse if nobody supplied one. + + `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 this step actually needs is an owner for the per-sandbox + `LM_PORT`/`DB_PORT` listeners, which is `session_host`'s. + """ + 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.""" + 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. + + 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(pid, 9) + except (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() # --------------------------------------------------------------------------- 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/session_host.py b/src/repl_sandbox/session_host.py new file mode 100644 index 0000000..b1d24a2 --- /dev/null +++ b/src/repl_sandbox/session_host.py @@ -0,0 +1,551 @@ +"""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 datetime, timezone +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 _stamp(now: datetime | None = None) -> str: + """UTC, to the finest granularity the platform clock offers, sortable. + + `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 + 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. 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") + return f"{_slug(user_id)}-{_stamp(now)}-{uuid.uuid4().hex[:12]}" + + +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 + #: 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). + 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, + "human_readable_name": self.human_readable_name, + "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, 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": + 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, + 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", ())), + ) + + +# --------------------------------------------------------------------------- +# 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. + + **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: + 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) + # 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( + 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_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_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. diff --git a/src/repl_sandbox/tests/test_launcher.py b/src/repl_sandbox/tests/test_launcher.py index 53ad96a..ff10303 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, ) @@ -340,15 +343,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: @@ -656,3 +719,66 @@ 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", + "/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"]), + }) + 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)", + "/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] 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 ------------------------------------------------------------------ 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..ffc9179 --- /dev/null +++ b/src/repl_sandbox/tests/test_session_host.py @@ -0,0 +1,338 @@ +"""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 datetime, timezone + +import pytest + +from repl_sandbox.config import SandboxConfig +from repl_sandbox.errors import DeniedError, SandboxError +from repl_sandbox.session_host import ( + MANIFEST_SCHEMA_VERSION, + mint_workspace_id, + KataSession, + WorkspaceLease, + WorkspaceManifest, + mint_session_id, + open_workspace_session, + reconstruct, +) + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +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-20260725T120000.123456-") + + +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. + + 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. + """ + 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, "ws-1") + 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("ws-1").__dataclass_fields__) + assert fields == { + "workspace_id", "schema_version", "human_readable_name", + "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 + + +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",)