carlos / platform Public

Isolated exec: implementation plan 1 of 4 (foundation)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev 96622c7e576ceee41787a32a65899f9c942512a0 parent 0a69448
1 file changed, +1679 −0
  • docs/superpowers/plans/2026-09-11-isolated-exec-1-foundation.md +1679 −0
diff --git a/docs/superpowers/plans/2026-09-11-isolated-exec-1-foundation.md b/docs/superpowers/plans/2026-09-11-isolated-exec-1-foundation.md
new file mode 100644
index 000000000..715a9eec4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-isolated-exec-1-foundation.md
@@ -0,0 +1,1679 @@
+# Isolated Exec, Plan 1 of 4: Foundation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Land every building block of the isolated-exec design that changes nothing on a flat fleet: the path helpers and instance identifier, the symlink-refusing file helper, the unix-dial checks, the slice and mount units, and the bootstrap wiring for them.
+
+**Architecture:** Four self-contained pieces that later plans consume by name: `provision.InstanceID` and the nested path helpers (§3.1), the `instancefs` package built on `openat2` (§3.2), `unixdial.Checked` used by every unix dial the edge and agent make (§5.4, §6.1), and the `carlos.slice` / `carlos-instances.slice` / three `nosymfollow` mount units with their tmpfiles lines and bootstrap arming (§3.2, §4.3). The edge unit moves into `carlos.slice` and loses its own memory lines. No spawn path changes in this plan.
+
+**Tech Stack:** Go 1.26, `golang.org/x/sys/unix` (already in go.mod), systemd 257 unit files embedded via `embed.FS`, the repo's existing test style (`testing`, no assertion library).
+
+**Spec:** `docs/superpowers/specs/2026-09-11-isolated-exec-design.md` (revision 12). Read §2, §3, §4.3, §5.4, §6.1 and §9 before starting. §13 records every codex ruling; none may be reopened.
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean before every commit (AGENTS.md). Use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; socket-binding tests need `TMPDIR=/tmp/g` (create it) and may need the sandbox off.
+- CI cross-compiles `darwin`, `windows` and `linux-arm64`: every Linux-only syscall lives in a `_linux.go` file with a `//go:build linux` tag and has a `!linux` stub that returns `errors.ErrUnsupported`.
+- `InstanceID` is at most 40 bytes; a hashed identifier starts with `_`; the longest socket address the helpers produce is under 108 bytes (spec §3.1).
+- Unit files are the source of truth in `internal/host/units/` and are embedded; nothing is applied by drop-in.
+- `carlos-edge.service` keeps every hardening line it has today; only `Slice=`, `Requires=`/`After=` for the mounts, and the three memory lines change (spec §4.3).
+- Copy rules: no em dashes in any user-facing string; log lines use the existing `verb: subject: detail` shape.
+- Commit after every task with the attribution trailer the session gives you. Never push to `main`. The branch is `worktree-isolated-exec-spec` (worktree `.claude/worktrees/isolated-exec-spec`).
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/provision/provision.go` (modify, after line 1012) | `InstanceID`, `InstanceDir`, `NestedDBPath`, `InstanceRunDir`, `NestedSocket`, `LogSocket`, `ControlSocketDir`, `ControlSocketPath`, `IsControlEndpoint`, `MaxUnixPath`, `LongestSocketAddress` |
+| `internal/provision/provision_test.go` (modify) | tests for the above |
+| `internal/instancefs/instancefs.go` (create) | `OpenBeneath`, `ReadBeneath`, `WriteAtomic`, `MountNoSymfollow`; documentation of the contract |
+| `internal/instancefs/instancefs_linux.go` (create) | the `openat2` and `renameat` implementations |
+| `internal/instancefs/instancefs_other.go` (create) | `!linux` stubs |
+| `internal/instancefs/instancefs_linux_test.go` (create) | tests |
+| `internal/unixdial/unixdial.go` (create) | `Checked(ctx, dialer, path)`: refuse the control endpoint, dial, verify `getpeername` |
+| `internal/unixdial/unixdial_test.go` (create) | real socket-pair tests |
+| `internal/edge/edge.go:2647` (modify) | `dialUnixWithRetry` uses `unixdial.Checked` |
+| `internal/activator/backend_exec.go:916` (modify) | `socketClient` uses `unixdial.Checked` |
+| `internal/release/versions.go:364` (modify) | `unixHTTPClient` uses `unixdial.Checked` |
+| `internal/host/socket_advice.go:48` (modify) | `socketServing` uses `unixdial.Checked` |
+| `internal/host/units/carlos.slice` (create) | outer memory wall |
+| `internal/host/units/carlos-instances.slice` (create) | parent of per-host slices |
+| `internal/host/units/data-instances.mount` (create) | `/data/.instances` → `/data/instances`, nosymfollow |
+| `internal/host/units/run-carlos-instances.mount` (create) | `/run/carlos/.instances` → `/run/carlos/instances` |
+| `internal/host/units/run-carlos-logs.mount` (create) | `/run/carlos/.logs` → `/run/carlos/logs` |
+| `internal/host/units/carlos-edge.service` (modify) | `Slice=carlos.slice`; `Requires=`/`After=` the mounts; drop `MemoryHigh=`/`MemoryMax=` |
+| `internal/host/units/carlos-litestream.service` (modify) | `After=data-instances.mount` |
+| `internal/host/bootstrap.go` (modify) | embed glob, tmpfiles lines, dirs, `enabledUnits`, `systemctlPlan` |
+| `internal/host/units_isolation_test.go` (create) | unit-file assertions for this plan |
+| `internal/host/host_test.go:1195` (modify) | the plan test learns about sockets and mounts |
+
+---
+
+### Task 1: Instance identifier and nested path helpers
+
+**Files:**
+- Modify: `internal/provision/provision.go` (append after `DefaultDBPath`, line 1012)
+- Test: `internal/provision/provision_test.go` (append)
+
+**Interfaces:**
+- Consumes: `SocketDir` (`"/run/carlos"`), `DefaultSocket`, `DefaultDBPath` (unchanged: they keep their flat meaning in this release, spec §3.1).
+- Produces:
+ ```go
+ const MaxInstanceID = 40
+ const MaxUnixPath = 107 // sockaddr_un sun_path minus the NUL
+ const ControlSocketDir = "/run/carlos-control"
+ const ControlSocketPath = "/run/carlos-control/control.sock"
+ func InstanceID(host string) string
+ func InstanceDir(dataDir, host string) string
+ func NestedDBPath(dataDir, host string) string
+ func InstanceRunDir(host string) string
+ func NestedSocket(host string) string
+ func LogSocket(host, role string) string
+ func IsControlEndpoint(path string) bool // true for anything under ControlSocketDir
+ func LongestSocketAddress() int // the worst case over every helper, for the pinned test
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/provision/provision_test.go`:
+
+```go
+func TestInstanceIDShortHostsAreThemselves(t *testing.T) {
+ for _, h := range []string{"h", "bill.go.tito.io", strings.Repeat("a", 40)} {
+ if got := InstanceID(h); got != h {
+ t.Errorf("InstanceID(%q) = %q, want the host itself", h, got)
+ }
+ }
+}
+
+func TestInstanceIDLongHostsAreBoundedAndPrefixed(t *testing.T) {
+ long := strings.Repeat("a", 60) + ".example.com"
+ got := InstanceID(long)
+ if len(got) != MaxInstanceID {
+ t.Fatalf("InstanceID(long) = %q (%d bytes), want %d", got, len(got), MaxInstanceID)
+ }
+ if got[0] != '_' {
+ t.Errorf("InstanceID(long) = %q, want a leading underscore: no valid host has one", got)
+ }
+ if got[1:23] != long[:22] {
+ t.Errorf("InstanceID(long) = %q, want the host's first 22 bytes after the underscore", got)
+ }
+ if got[23] != '-' {
+ t.Errorf("InstanceID(long) = %q, want a dash before the hash", got)
+ }
+ if got != InstanceID(long) {
+ t.Error("InstanceID is not stable")
+ }
+ // Two long hosts sharing a 22-byte prefix get different identifiers.
+ other := strings.Repeat("a", 60) + ".example.org"
+ if InstanceID(other) == got {
+ t.Errorf("InstanceID(%q) == InstanceID(%q); the hash must separate them", long, other)
+ }
+}
+
+func TestInstanceIDDomainsNeverMeet(t *testing.T) {
+ // codex R6-1: the hashed form of one host must never equal any valid
+ // short host. A leading underscore is not a valid host label.
+ long := strings.Repeat("a", 31) + ".example.com"
+ if ValidHostShape(InstanceID(long)) {
+ t.Errorf("InstanceID(%q) = %q is a valid host shape; the domains overlap", long, InstanceID(long))
+ }
+}
+
+func TestNestedHelpersAreKeyedByInstanceID(t *testing.T) {
+ long := strings.Repeat("b", 50) + ".example.com"
+ iid := InstanceID(long)
+ if got, want := InstanceDir("/data", long), "/data/instances/"+iid; got != want {
+ t.Errorf("InstanceDir = %q, want %q", got, want)
+ }
+ if got, want := NestedDBPath("/data/", long), "/data/instances/"+iid+"/"+long+".db"; got != want {
+ t.Errorf("NestedDBPath = %q, want %q (file names keep the full host)", got, want)
+ }
+ if got, want := InstanceRunDir(long), "/run/carlos/instances/"+iid; got != want {
+ t.Errorf("InstanceRunDir = %q, want %q", got, want)
+ }
+ if got, want := NestedSocket(long), "/run/carlos/instances/"+iid+"/app.sock"; got != want {
+ t.Errorf("NestedSocket = %q, want %q", got, want)
+ }
+ if got, want := LogSocket(long, "sidecar"), "/run/carlos/logs/"+iid+".sidecar.sock"; got != want {
+ t.Errorf("LogSocket = %q, want %q", got, want)
+ }
+}
+
+func TestFlatDefaultsUnchangedThisRelease(t *testing.T) {
+ // Spec §8.1: nothing mints nested in this release. The flat helpers
+ // are what routeFor and `carlos add` still use.
+ if got := DefaultSocket("h"); got != "/run/carlos/h.sock" {
+ t.Errorf("DefaultSocket = %q", got)
+ }
+ if got := DefaultDBPath("/data", "h"); got != "/data/h.db" {
+ t.Errorf("DefaultDBPath = %q", got)
+ }
+}
+
+func TestLongestSocketAddressFitsSockaddrUn(t *testing.T) {
+ // codex R5-3: an 80-byte host overflowed the first draft's paths. The
+ // bound is computed, not assumed, over a 253-byte host.
+ if got := LongestSocketAddress(); got > MaxUnixPath {
+ t.Fatalf("longest socket address is %d bytes, over the %d-byte limit", got, MaxUnixPath)
+ }
+ host := strings.Repeat("c", 253)
+ for _, p := range []string{NestedSocket(host), NestedSocket(host) + ".control", LogSocket(host, "sidecar")} {
+ if len(p) > MaxUnixPath {
+ t.Errorf("%q is %d bytes", p, len(p))
+ }
+ }
+}
+
+func TestIsControlEndpoint(t *testing.T) {
+ for path, want := range map[string]bool{
+ ControlSocketPath: true,
+ "/run/carlos-control/anything": true,
+ "/run/carlos-control": true,
+ "/run/carlos/h.sock": false,
+ "/run/carlos/instances/h/app.sock": false,
+ "/run/carlos-controlx/control.sock": false,
+ "/run/titogo/example.sock": false,
+ } {
+ if got := IsControlEndpoint(path); got != want {
+ t.Errorf("IsControlEndpoint(%q) = %v, want %v", path, got, want)
+ }
+ }
+}
+```
+
+Add `"strings"` to the test file's imports if it is not already there (it is used by other tests in that file; check with `grep -n '"strings"' internal/provision/provision_test.go`).
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision -run 'TestInstanceID|TestNestedHelpers|TestFlatDefaults|TestLongestSocket|TestIsControlEndpoint' -count=1`
+Expected: FAIL with `undefined: InstanceID` (and the others).
+
+- [ ] **Step 3: Implement the helpers**
+
+Append to `internal/provision/provision.go` after `DefaultDBPath` (line 1012). Add `"crypto/sha256"` and `"encoding/hex"` to the import block.
+
+```go
+// MaxInstanceID bounds InstanceID. A unix socket address holds MaxUnixPath
+// bytes of path and a host may be 253 bytes, so the directories a socket
+// lives under are keyed by a bounded identifier, never by the host itself
+// (isolated-exec design §3.1, codex R5-3).
+const MaxInstanceID = 40
+
+// MaxUnixPath is sockaddr_un's sun_path (108 bytes) minus the NUL Go
+// insists on room for.
+const MaxUnixPath = 107
+
+// ControlSocketDir and ControlSocketPath are the root instance-control
+// channel (design §5). Deliberately NOT under SocketDir: an instance's
+// namespace replaces /run/carlos with an empty tmpfs, and nothing an
+// instance can reach must resolve to this path.
+const (
+ ControlSocketDir = "/run/carlos-control"
+ ControlSocketPath = ControlSocketDir + "/control.sock"
+)
+
+// InstanceID is the bounded per-host identifier every isolated-exec
+// directory, socket, unit instance and slice is keyed by. A host of at
+// most MaxInstanceID bytes is its own identifier. A longer host becomes
+// "_" + its first 22 bytes + "-" + 16 hex digits of its sha256, also
+// MaxInstanceID bytes.
+//
+// The underscore is the whole reason the two forms can share one
+// namespace: no valid host contains one (controlname labels are
+// [a-z0-9-]), so a hashed identifier can never equal any short host, and
+// two hashed identifiers collide only on a 64-bit hash collision between
+// two long hosts with the same 22-byte prefix (codex R6-1). Anything that
+// finds two rows with one identifier treats them as a conflict, never as
+// the same host.
+func InstanceID(host string) string {
+ if len(host) <= MaxInstanceID {
+ return host
+ }
+ sum := sha256.Sum256([]byte(host))
+ return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8])
+}
+
+// InstanceDir is the per-host data directory of the nested layout:
+// <dataDir>/instances/<InstanceID(host)>. File names INSIDE it keep the
+// full host (a file name may be 255 bytes; a socket path may not).
+func InstanceDir(dataDir, host string) string {
+ return filepath.Join(dataDir, "instances", InstanceID(host))
+}
+
+// NestedDBPath is the database under InstanceDir. It is what the layout
+// pass writes into a migrated row's DBPath; DefaultDBPath stays flat in
+// this release (design §8.1).
+func NestedDBPath(dataDir, host string) string {
+ return filepath.Join(InstanceDir(dataDir, host), host+".db")
+}
+
+// InstanceRunDir is the per-host socket directory of the nested layout.
+func InstanceRunDir(host string) string {
+ return filepath.Join(SocketDir, "instances", InstanceID(host))
+}
+
+// NestedSocket is the instance's listener under InstanceRunDir; its
+// control socket is NestedSocket + ".control", as for every exec route.
+func NestedSocket(host string) string {
+ return filepath.Join(InstanceRunDir(host), "app.sock")
+}
+
+// LogSocket is the agent's per-role log listener for a host (design
+// §6.4): outside every tenant mount, keyed by the identifier.
+func LogSocket(host, role string) string {
+ return filepath.Join(SocketDir, "logs", InstanceID(host)+"."+role+".sock")
+}
+
+// IsControlEndpoint reports whether path is the control channel or
+// anything else under its directory. The edge and the agent refuse to
+// dial such a path as an upstream (design §5.4): a registry row pointing
+// at it is refused before connect().
+func IsControlEndpoint(path string) bool {
+ clean := filepath.Clean(path)
+ return clean == ControlSocketDir || strings.HasPrefix(clean, ControlSocketDir+"/")
+}
+
+// LongestSocketAddress is the longest unix socket path any helper here
+// can produce, over the longest valid host, so a test can pin it under
+// MaxUnixPath rather than trusting arithmetic in a comment.
+func LongestSocketAddress() int {
+ host := strings.Repeat("x", 253)
+ longest := 0
+ for _, p := range []string{
+ NestedSocket(host),
+ NestedSocket(host) + ".control",
+ LogSocket(host, "sidecar"), // the longest role name
+ } {
+ if len(p) > longest {
+ longest = len(p)
+ }
+ }
+ return longest
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision -count=1`
+Expected: PASS (the whole package, including `TestDefaults`).
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/provision
+git add internal/provision/provision.go internal/provision/provision_test.go
+git commit -m "provision: instance identifier and nested path helpers (isolated exec plan 1, task 1)"
+```
+
+---
+
+### Task 2: The `instancefs` package
+
+**Files:**
+- Create: `internal/instancefs/instancefs.go`
+- Create: `internal/instancefs/instancefs_linux.go`
+- Create: `internal/instancefs/instancefs_other.go`
+- Create: `internal/instancefs/instancefs_linux_test.go`
+
+**Interfaces:**
+- Consumes: `golang.org/x/sys/unix` (`Openat2`, `OpenHow`, `RESOLVE_BENEATH`, `RESOLVE_NO_SYMLINKS`, `RESOLVE_NO_MAGICLINKS`, `Renameat`), `crypto/rand`.
+- Produces:
+ ```go
+ package instancefs
+ var ErrSymlink = errors.New("instancefs: path contains a symlink")
+ var ErrEscapes = errors.New("instancefs: path escapes its directory")
+ // OpenBeneath opens name (a relative path with no "..") under dir, following no symlink at any component.
+ func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error)
+ // ReadBeneath is OpenBeneath + io.ReadAll.
+ func ReadBeneath(dir, name string) ([]byte, error)
+ // WriteAtomic writes data to a random O_EXCL temp file in dir and renames it over name. Never follows a symlink at the destination.
+ func WriteAtomic(dir, name string, data []byte, perm os.FileMode) error
+ // MountNoSymfollow reports whether mountpoint is a mount whose options include nosymfollow, per /proc/self/mountinfo.
+ func MountNoSymfollow(mountpoint string) (bool, error)
+ ```
+ Every function returns `errors.ErrUnsupported` on non-Linux.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/instancefs/instancefs_linux_test.go`:
+
+```go
+//go:build linux
+
+package instancefs
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestOpenBeneathReadsAPlainFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "h.env"), []byte("A=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadBeneath(dir, "h.env")
+ if err != nil {
+ t.Fatalf("ReadBeneath: %v", err)
+ }
+ if string(got) != "A=1\n" {
+ t.Errorf("got %q", got)
+ }
+}
+
+func TestOpenBeneathRefusesASymlinkAtTheLeaf(t *testing.T) {
+ dir := t.TempDir()
+ secret := filepath.Join(t.TempDir(), "secret")
+ if err := os.WriteFile(secret, []byte("token"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(secret, filepath.Join(dir, "h.env")); err != nil {
+ t.Fatal(err)
+ }
+ _, err := ReadBeneath(dir, "h.env")
+ if !errors.Is(err, ErrSymlink) {
+ t.Fatalf("ReadBeneath through a symlink: err = %v, want ErrSymlink", err)
+ }
+}
+
+func TestOpenBeneathRefusesASymlinkedDirectory(t *testing.T) {
+ dir := t.TempDir()
+ other := t.TempDir()
+ if err := os.WriteFile(filepath.Join(other, "x"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(other, filepath.Join(dir, "sub")); err != nil {
+ t.Fatal(err)
+ }
+ _, err := ReadBeneath(dir, "sub/x")
+ if !errors.Is(err, ErrSymlink) {
+ t.Fatalf("err = %v, want ErrSymlink", err)
+ }
+}
+
+func TestOpenBeneathRefusesDotDotAndAbsolute(t *testing.T) {
+ dir := t.TempDir()
+ for _, name := range []string{"../x", "a/../../x", "/etc/passwd"} {
+ _, err := ReadBeneath(dir, name)
+ if !errors.Is(err, ErrEscapes) {
+ t.Errorf("ReadBeneath(%q): err = %v, want ErrEscapes", name, err)
+ }
+ }
+}
+
+func TestWriteAtomicReplacesAndNeverFollows(t *testing.T) {
+ dir := t.TempDir()
+ if err := WriteAtomic(dir, "h.config.env", []byte("one\n"), 0o600); err != nil {
+ t.Fatalf("WriteAtomic: %v", err)
+ }
+ if err := WriteAtomic(dir, "h.config.env", []byte("two\n"), 0o600); err != nil {
+ t.Fatalf("WriteAtomic (replace): %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(dir, "h.config.env"))
+ if err != nil || string(got) != "two\n" {
+ t.Fatalf("got %q, %v", got, err)
+ }
+ fi, err := os.Stat(filepath.Join(dir, "h.config.env"))
+ if err != nil || fi.Mode().Perm() != 0o600 {
+ t.Errorf("mode = %v, %v", fi.Mode(), err)
+ }
+ // A symlink planted at the destination is REPLACED as a directory
+ // entry; its target is untouched.
+ victim := filepath.Join(t.TempDir(), "victim")
+ if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(dir, "h.config.env")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(victim, filepath.Join(dir, "h.config.env")); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteAtomic(dir, "h.config.env", []byte("three\n"), 0o600); err != nil {
+ t.Fatalf("WriteAtomic over a symlink: %v", err)
+ }
+ if b, _ := os.ReadFile(victim); string(b) != "keep" {
+ t.Errorf("symlink target was written through: %q", b)
+ }
+ if fi, err := os.Lstat(filepath.Join(dir, "h.config.env")); err != nil || fi.Mode()&os.ModeSymlink != 0 {
+ t.Errorf("destination is still a symlink: %v %v", fi, err)
+ }
+ // No temp file is left behind.
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ if strings.Contains(e.Name(), ".tmp-") {
+ t.Errorf("temp file left behind: %s", e.Name())
+ }
+ }
+}
+
+func TestMountNoSymfollowReadsMountinfo(t *testing.T) {
+ // / is a mount and does not carry nosymfollow on any ordinary box; a
+ // path that is not a mountpoint is reported as not a nosymfollow mount
+ // with a nil error (the caller's message names the path).
+ got, err := MountNoSymfollow("/")
+ if err != nil {
+ t.Fatalf("MountNoSymfollow(/): %v", err)
+ }
+ if got {
+ t.Error("/ reported as nosymfollow")
+ }
+ got, err = MountNoSymfollow(t.TempDir())
+ if err != nil || got {
+ t.Errorf("temp dir: %v %v", got, err)
+ }
+ // The parser, against a fixture line, since a real nosymfollow mount
+ // needs root to create.
+ line := "36 35 98:0 /.instances /data/instances rw,nosuid,nodev,noexec,nosymfollow,relatime - ext4 /dev/root rw"
+ if !mountinfoLineHas(line, "/data/instances", "nosymfollow") {
+ t.Error("parser missed nosymfollow on a fixture line")
+ }
+ if mountinfoLineHas(line, "/data", "nosymfollow") {
+ t.Error("parser matched the wrong mountpoint")
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/instancefs -count=1`
+Expected: FAIL to build (`undefined: ReadBeneath` and so on; or "no Go files").
+
+- [ ] **Step 3: Write the package**
+
+Create `internal/instancefs/instancefs.go`:
+
+```go
+// Package instancefs is how the agent opens anything inside an instance's
+// own directory. Every exec-backed instance runs as the agent's uid, and
+// its directory is writable by it, so any path the agent opens there is a
+// request the instance makes on the agent's behalf: a symlink at
+// <host>.env pointing at /data/litestream.env would hand the instance the
+// bucket credentials as its own environment (isolated-exec design §1.3).
+//
+// The kernel closes that for every reader at once with the nosymfollow
+// mount option on the instance trees (design §3.2). This package is the
+// belt to that brace: it opens with openat2(RESOLVE_BENEATH |
+// RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS), so a symlink is refused
+// with a named error even on a box whose mount is missing the option, and
+// it writes through O_EXCL temp files and rename, so a planted symlink at
+// a destination is replaced as a directory entry rather than written
+// through.
+//
+// Nothing here is Linux-specific in its contract; the implementations
+// are, and the !linux build returns errors.ErrUnsupported so the CLI still
+// builds on a Mac (AGENTS.md's cross-compile gate).
+package instancefs
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ErrSymlink is returned when any component of the path is a symlink.
+var ErrSymlink = errors.New("instancefs: path contains a symlink")
+
+// ErrEscapes is returned for a name that is absolute or reaches above dir.
+var ErrEscapes = errors.New("instancefs: path escapes its directory")
+
+// cleanRel validates name as a relative path that stays inside dir.
+func cleanRel(name string) (string, error) {
+ if name == "" || filepath.IsAbs(name) {
+ return "", ErrEscapes
+ }
+ c := filepath.Clean(name)
+ if c == ".." || strings.HasPrefix(c, "../") {
+ return "", ErrEscapes
+ }
+ return c, nil
+}
+
+// ReadBeneath is OpenBeneath followed by reading the whole file.
+func ReadBeneath(dir, name string) ([]byte, error) {
+ f, err := OpenBeneath(dir, name, os.O_RDONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return readAll(f)
+}
+
+func readAll(f *os.File) ([]byte, error) {
+ var out []byte
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := f.Read(buf)
+ out = append(out, buf[:n]...)
+ if err != nil {
+ if err.Error() == "EOF" {
+ return out, nil
+ }
+ return out, err
+ }
+ if n == 0 {
+ return out, nil
+ }
+ }
+}
+```
+
+Replace the hand-rolled `readAll` with `io.ReadAll` (add `"io"` to imports and delete the helper). It is shown expanded only so the file compiles if `io` is forgotten; the committed version must use `io.ReadAll`.
+
+Create `internal/instancefs/instancefs_linux.go`:
+
+```go
+//go:build linux
+
+package instancefs
+
+import (
+ "bufio"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+const resolveFlags = unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS
+
+// openDir opens dir itself as a directory fd, also refusing symlinks in
+// dir's own components: the instance directory's parent is the agent's,
+// but the instance directory itself could have been swapped for a
+// symlink by a same-uid process before the mount option landed.
+func openDir(dir string) (int, error) {
+ fd, err := unix.Open(dir, unix.O_PATH|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ if errors.Is(err, unix.ELOOP) {
+ return -1, fmt.Errorf("%w: %s", ErrSymlink, dir)
+ }
+ return -1, &os.PathError{Op: "open", Path: dir, Err: err}
+ }
+ return fd, nil
+}
+
+// OpenBeneath opens name under dir with openat2, following no symlink at
+// any component and never leaving dir.
+func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) {
+ rel, err := cleanRel(name)
+ if err != nil {
+ return nil, err
+ }
+ dfd, err := openDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close(dfd)
+ how := unix.OpenHow{
+ Flags: uint64(flag) | unix.O_CLOEXEC,
+ Mode: uint64(perm.Perm()),
+ Resolve: resolveFlags,
+ }
+ fd, err := unix.Openat2(dfd, rel, &how)
+ if err != nil {
+ switch {
+ case errors.Is(err, unix.ELOOP):
+ return nil, fmt.Errorf("%w: %s", ErrSymlink, filepath.Join(dir, rel))
+ case errors.Is(err, unix.EXDEV):
+ return nil, fmt.Errorf("%w: %s", ErrEscapes, filepath.Join(dir, rel))
+ }
+ return nil, &os.PathError{Op: "openat2", Path: filepath.Join(dir, rel), Err: err}
+ }
+ return os.NewFile(uintptr(fd), filepath.Join(dir, rel)), nil
+}
+
+// WriteAtomic writes data to a fresh O_EXCL temp file beside name and
+// renames it over name. rename(2) replaces the directory entry and never
+// follows a symlink at the destination, so a planted link is displaced,
+// not written through. The temp name is random, so no same-uid process
+// can pre-plant it (the O_EXCL refuses it if one does).
+func WriteAtomic(dir, name string, data []byte, perm os.FileMode) error {
+ rel, err := cleanRel(name)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(rel, "/") {
+ return fmt.Errorf("%w: WriteAtomic takes a bare file name, got %q", ErrEscapes, name)
+ }
+ dfd, err := openDir(dir)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(dfd)
+ var rnd [8]byte
+ if _, err := rand.Read(rnd[:]); err != nil {
+ return err
+ }
+ tmp := "." + rel + ".tmp-" + hex.EncodeToString(rnd[:])
+ how := unix.OpenHow{
+ Flags: unix.O_WRONLY | unix.O_CREAT | unix.O_EXCL | unix.O_CLOEXEC,
+ Mode: uint64(perm.Perm()),
+ Resolve: resolveFlags,
+ }
+ fd, err := unix.Openat2(dfd, tmp, &how)
+ if err != nil {
+ return &os.PathError{Op: "openat2", Path: filepath.Join(dir, tmp), Err: err}
+ }
+ f := os.NewFile(uintptr(fd), filepath.Join(dir, tmp))
+ cleanup := func() { f.Close(); _ = unix.Unlinkat(dfd, tmp, 0) }
+ if _, err := f.Write(data); err != nil {
+ cleanup()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ cleanup()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ _ = unix.Unlinkat(dfd, tmp, 0)
+ return err
+ }
+ if err := unix.Renameat(dfd, tmp, dfd, rel); err != nil {
+ _ = unix.Unlinkat(dfd, tmp, 0)
+ return &os.PathError{Op: "renameat", Path: filepath.Join(dir, rel), Err: err}
+ }
+ return nil
+}
+
+// MountNoSymfollow reports whether mountpoint is a mountpoint whose
+// options include nosymfollow, by reading /proc/self/mountinfo. A path
+// that is not a mountpoint is (false, nil).
+func MountNoSymfollow(mountpoint string) (bool, error) {
+ f, err := os.Open("/proc/self/mountinfo")
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+ want := filepath.Clean(mountpoint)
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ if mountinfoLineHas(sc.Text(), want, "nosymfollow") {
+ return true, nil
+ }
+ }
+ return false, sc.Err()
+}
+
+// mountinfoLineHas parses one mountinfo line: field 5 is the mountpoint
+// (octal-escaped), field 6 the per-mount options.
+func mountinfoLineHas(line, mountpoint, option string) bool {
+ fields := strings.Fields(line)
+ if len(fields) < 6 {
+ return false
+ }
+ if unescapeMountinfo(fields[4]) != mountpoint {
+ return false
+ }
+ for _, o := range strings.Split(fields[5], ",") {
+ if o == option {
+ return true
+ }
+ }
+ return false
+}
+
+// unescapeMountinfo undoes the \040-style escapes mountinfo applies to
+// spaces, tabs, newlines and backslashes in paths.
+func unescapeMountinfo(s string) string {
+ if !strings.Contains(s, `\`) {
+ return s
+ }
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\\' && i+3 < len(s) {
+ var v int
+ if _, err := fmt.Sscanf(s[i+1:i+4], "%o", &v); err == nil {
+ b.WriteByte(byte(v))
+ i += 3
+ continue
+ }
+ }
+ b.WriteByte(s[i])
+ }
+ return b.String()
+}
+```
+
+Create `internal/instancefs/instancefs_other.go`:
+
+```go
+//go:build !linux
+
+package instancefs
+
+import (
+ "errors"
+ "os"
+)
+
+func OpenBeneath(dir, name string, flag int, perm os.FileMode) (*os.File, error) {
+ if _, err := cleanRel(name); err != nil {
+ return nil, err
+ }
+ return nil, errors.ErrUnsupported
+}
+
+func WriteAtomic(dir, name string, data []byte, perm os.FileMode) error {
+ if _, err := cleanRel(name); err != nil {
+ return err
+ }
+ return errors.ErrUnsupported
+}
+
+func MountNoSymfollow(mountpoint string) (bool, error) { return false, errors.ErrUnsupported }
+
+func mountinfoLineHas(line, mountpoint, option string) bool { return false }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass, and cross-compile**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/instancefs -count=1`
+Expected: PASS.
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./internal/instancefs && GOOS=windows go build ./internal/instancefs`
+Expected: both build. (`RESOLVE_*` and `OpenHow` exist in x/sys v0.46.0 for linux; the other file never references them.)
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/instancefs
+git add internal/instancefs
+git commit -m "instancefs: symlink-refusing opens and atomic writes for instance directories (plan 1, task 2)"
+```
+
+---
+
+### Task 3: Checked unix dials in the edge and the agent
+
+**Files:**
+- Create: `internal/unixdial/unixdial.go`
+- Create: `internal/unixdial/unixdial_test.go`
+- Modify: `internal/edge/edge.go:2647-2664` (`dialUnixWithRetry`)
+- Modify: `internal/activator/backend_exec.go:916-925` (`socketClient`)
+- Modify: `internal/release/versions.go:364-374` (`unixHTTPClient`)
+- Modify: `internal/host/socket_advice.go:48-55` (`socketServing`)
+
+**Interfaces:**
+- Consumes: `provision.IsControlEndpoint`.
+- Produces:
+ ```go
+ package unixdial
+ var ErrControlEndpoint = errors.New("unixdial: refusing to dial the instance control channel as an upstream")
+ var ErrPeerMismatch = errors.New("unixdial: listener is bound to a different path than the one dialled")
+ // Checked dials path as a unix stream socket and returns the connection only if
+ // (1) path is not under provision.ControlSocketDir and (2) getpeername on the
+ // connected socket equals filepath.Clean(path). Otherwise the connection is
+ // closed and the error names which check failed.
+ func Checked(ctx context.Context, d *net.Dialer, path string) (net.Conn, error)
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/unixdial/unixdial_test.go`:
+
+```go
+package unixdial
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// short returns a socket path short enough for sockaddr_un under the
+// session's long TMPDIR (SDD lessons: run with TMPDIR=/tmp/g).
+func short(t *testing.T, name string) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "ud")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return filepath.Join(dir, name)
+}
+
+func listen(t *testing.T, path string) net.Listener {
+ t.Helper()
+ ln, err := net.Listen("unix", path)
+ if err != nil {
+ t.Skipf("cannot bind a unix socket here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ c.Close()
+ }
+ }()
+ return ln
+}
+
+func TestCheckedAcceptsTheListenerItDialled(t *testing.T) {
+ path := short(t, "app.sock")
+ listen(t, path)
+ c, err := Checked(context.Background(), &net.Dialer{}, path)
+ if err != nil {
+ t.Fatalf("Checked: %v", err)
+ }
+ c.Close()
+}
+
+func TestCheckedRefusesASymlinkToAnotherListener(t *testing.T) {
+ real := short(t, "other.sock")
+ listen(t, real)
+ link := short(t, "app.sock")
+ if err := os.Symlink(real, link); err != nil {
+ t.Fatal(err)
+ }
+ c, err := Checked(context.Background(), &net.Dialer{}, link)
+ if c != nil {
+ c.Close()
+ }
+ if !errors.Is(err, ErrPeerMismatch) {
+ t.Fatalf("err = %v, want ErrPeerMismatch", err)
+ }
+}
+
+func TestCheckedRefusesTheControlEndpointBeforeDialling(t *testing.T) {
+ // No listener exists; the refusal must come from the path check, not
+ // from a connect error.
+ _, err := Checked(context.Background(), &net.Dialer{}, "/run/carlos-control/control.sock")
+ if !errors.Is(err, ErrControlEndpoint) {
+ t.Fatalf("err = %v, want ErrControlEndpoint", err)
+ }
+}
+
+func TestCheckedAcceptsAListenerCreatedByAnotherProcessShape(t *testing.T) {
+ // The carlos-app@.socket shape: the listener's creator is not the
+ // process that serves it. Credentials are never consulted, only the
+ // bound path, so this passes as long as the path matches.
+ path := short(t, "apps.sock")
+ listen(t, path)
+ c, err := Checked(context.Background(), &net.Dialer{}, path)
+ if err != nil {
+ t.Fatalf("Checked: %v", err)
+ }
+ c.Close()
+}
+
+func TestCheckedAcceptsACustomPathOutsideRunCarlos(t *testing.T) {
+ // codex R5-1: an operator's --socket /run/titogo/example.sock keeps
+ // working; only the control endpoint is refused by path.
+ path := short(t, "example.sock")
+ listen(t, path)
+ c, err := Checked(context.Background(), &net.Dialer{}, path)
+ if err != nil {
+ t.Fatalf("Checked: %v", err)
+ }
+ c.Close()
+}
+
+func TestCheckedPassesConnectErrorsThrough(t *testing.T) {
+ path := short(t, "absent.sock")
+ _, err := Checked(context.Background(), &net.Dialer{}, path)
+ if err == nil || errors.Is(err, ErrPeerMismatch) || errors.Is(err, ErrControlEndpoint) {
+ t.Fatalf("err = %v, want the raw connect error (ENOENT) so retry logic still sees it", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `mkdir -p /tmp/g; TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/unixdial -count=1`
+Expected: build failure, `undefined: Checked`.
+
+- [ ] **Step 3: Write the package**
+
+Create `internal/unixdial/unixdial.go`:
+
+```go
+// Package unixdial is the one way the edge and the agent dial a unix
+// socket. Two checks ride on every dial (isolated-exec design §5.4):
+//
+// - the reserved control endpoint is refused BEFORE connect(): a registry
+// row pointing rt.Socket at the root control channel must never be
+// proxied to, however it got there;
+// - the listener must answer to the path that was dialled: after
+// connect() and before a byte is sent, getpeername() names the path
+// the LISTENER bound, which is its real path however it was reached.
+// A symlink at /run/carlos/<host>.sock pointing at a sibling's socket
+// or at the control socket therefore yields a mismatch, and the
+// connection is closed unused.
+//
+// Credentials are deliberately not consulted: a listener systemd created
+// and passed to a DynamicUser service reports root in SO_PEERCRED, and
+// that shape (carlos-app@.socket) must keep working (codex R4-1).
+package unixdial
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// ErrControlEndpoint is returned, before any connect, for a path under
+// provision.ControlSocketDir.
+var ErrControlEndpoint = errors.New("unixdial: refusing to dial the instance control channel as an upstream")
+
+// ErrPeerMismatch is returned, after connect and before any byte is sent,
+// when the listener's bound path is not the path that was dialled.
+var ErrPeerMismatch = errors.New("unixdial: listener is bound to a different path than the one dialled")
+
+// Checked dials path as a unix stream socket with d and applies both
+// checks. Connect errors (ENOENT, ECONNREFUSED) are returned unwrapped so
+// callers' retry logic keeps seeing them.
+func Checked(ctx context.Context, d *net.Dialer, path string) (net.Conn, error) {
+ if provision.IsControlEndpoint(path) {
+ return nil, fmt.Errorf("%w: %s", ErrControlEndpoint, path)
+ }
+ if d == nil {
+ d = &net.Dialer{}
+ }
+ conn, err := d.DialContext(ctx, "unix", path)
+ if err != nil {
+ return nil, err
+ }
+ if err := verifyPeer(conn, path); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return conn, nil
+}
+
+// verifyPeer compares the peer address of a connected unix socket with the
+// dialled path. net's UnixConn fills RemoteAddr from getpeername(2) at
+// connect time, which is the listener's bound address.
+func verifyPeer(conn net.Conn, dialled string) error {
+ ra := conn.RemoteAddr()
+ ua, ok := ra.(*net.UnixAddr)
+ if !ok || ua == nil {
+ return fmt.Errorf("%w: peer address %v is not a unix address", ErrPeerMismatch, ra)
+ }
+ want := filepath.Clean(dialled)
+ got := ua.Name
+ if got == "" || got[0] == '@' || filepath.Clean(got) != want {
+ return fmt.Errorf("%w: dialled %s, listener is bound to %q", ErrPeerMismatch, want, got)
+ }
+ return nil
+}
+```
+
+Note for the implementer: Go's `net` package sets a dialed `UnixConn`'s remote address from `getpeername(2)` after `connect(2)` succeeds (`netFD.connect` returns the peer sockaddr and `dial` installs it). If a test shows `RemoteAddr()` echoing the dialled symlink path instead of the bound path, replace `verifyPeer`'s body with an explicit `unix.Getpeername` through `conn.(*net.UnixConn).SyscallConn().Control(...)` in a `_linux.go` file with a `!linux` stub that returns nil, and keep the tests as they are. The tests are the contract.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/unixdial -count=1 -v`
+Expected: PASS for all six (they skip, not fail, where the sandbox refuses `socket()`; run outside the sandbox to see them pass).
+
+- [ ] **Step 5: Wire the four dial sites**
+
+`internal/edge/edge.go:2647`: replace the body of `dialUnixWithRetry`'s dial line.
+
+```go
+func dialUnixWithRetry(ctx context.Context, d *net.Dialer, socketPath string) (net.Conn, error) {
+ const window = 13 * time.Second
+ deadline := time.Now().Add(window)
+ for {
+ conn, err := unixdial.Checked(ctx, d, socketPath)
+ if err == nil {
+ return conn, nil
+ }
+ if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) {
+ return nil, err
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+}
+```
+
+`isRetryableDialErr` is unchanged: `ErrControlEndpoint` and `ErrPeerMismatch` are not `ECONNREFUSED`/`ENOENT`, so they are not retried. Add the import `"github.com/carlosframework/platform/internal/unixdial"`.
+
+`internal/activator/backend_exec.go:916`:
+
+```go
+func socketClient(socket string) *http.Client {
+ return &http.Client{
+ Timeout: 3 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return unixdial.Checked(ctx, &net.Dialer{}, socket)
+ },
+ },
+ }
+}
+```
+
+`internal/release/versions.go:364`:
+
+```go
+func unixHTTPClient(socket string) *http.Client {
+ return &http.Client{
+ Timeout: 3 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return unixdial.Checked(ctx, &net.Dialer{}, socket)
+ },
+ },
+ }
+}
+```
+
+`internal/host/socket_advice.go:48`:
+
+```go
+func socketServing(socket string) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+ c, err := unixdial.Checked(ctx, &net.Dialer{}, socket)
+ if err != nil {
+ return false
+ }
+ c.Close()
+ return true
+}
+```
+
+Add `"context"` and the `unixdial` import there if missing. Check for an import cycle: `unixdial` imports `provision`; `provision` must not import `edge`, `activator`, `release` or `host` (it does not today: `grep -n '"github.com/carlosframework/platform/internal/' internal/provision/*.go`). If `release` is imported by `provision` (it is, for the store), that is fine: `release` importing `unixdial` importing `provision` importing `release` WOULD be a cycle. **Check `go build ./...` after wiring `release/versions.go`; if it reports an import cycle, move `IsControlEndpoint`, `ControlSocketDir` and `ControlSocketPath` out of `provision` into a new leaf package `internal/controlsock` with no imports, have `provision` re-export them as constants (`const ControlSocketDir = controlsock.Dir`), and have `unixdial` import `controlsock` only.** Update Task 1's tests only if the names move; the values do not.
+
+- [ ] **Step 6: Build, vet, run the touched packages**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./internal/edge ./internal/activator ./internal/release ./internal/host ./internal/unixdial`
+Expected: clean.
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge ./internal/activator ./internal/release ./internal/host -count=1`
+Expected: PASS (these bind sockets; run outside the sandbox with `TMPDIR=/tmp/g`).
+
+- [ ] **Step 7: gofmt and commit**
+
+```bash
+gofmt -l internal
+git add internal/unixdial internal/edge/edge.go internal/activator/backend_exec.go internal/release/versions.go internal/host/socket_advice.go
+git commit -m "unixdial: refuse the control endpoint and verify the listener's bound path on every unix dial (plan 1, task 3)"
+```
+
+---
+
+### Task 4: Slice and mount units, and the edge unit's move into `carlos.slice`
+
+**Files:**
+- Create: `internal/host/units/carlos.slice`
+- Create: `internal/host/units/carlos-instances.slice`
+- Create: `internal/host/units/data-instances.mount`
+- Create: `internal/host/units/run-carlos-instances.mount`
+- Create: `internal/host/units/run-carlos-logs.mount`
+- Modify: `internal/host/units/carlos-edge.service` (`[Unit]` lines 6-9; `[Service]` lines 71-79)
+- Modify: `internal/host/units/carlos-litestream.service` (`[Unit]`, line 12)
+- Modify: `internal/host/bootstrap.go:33` (embed glob)
+- Test: `internal/host/units_isolation_test.go` (create)
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: the unit names `carlos.slice`, `carlos-instances.slice`, `data-instances.mount`, `run-carlos-instances.mount`, `run-carlos-logs.mount`, which Task 5 enables, and which plan 2's templates `Requires=`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/host/units_isolation_test.go`:
+
+```go
+package host
+
+import (
+ "strings"
+ "testing"
+)
+
+func readUnit(t *testing.T, name string) string {
+ t.Helper()
+ b, err := unitsFS.ReadFile("units/" + name)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ return string(b)
+}
+
+// The outer memory wall moves from the edge unit to carlos.slice
+// (isolated-exec design §4.3): it bounds the agent plus every awake
+// instance as before, but instances are now sibling units, not children.
+func TestCarlosSliceCarriesTheOuterWall(t *testing.T) {
+ s := readUnit(t, "carlos.slice")
+ for _, want := range []string{"[Slice]", "MemoryHigh=70%", "MemoryMax=80%"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("carlos.slice lacks %q", want)
+ }
+ }
+ e := readUnit(t, "carlos-edge.service")
+ if !strings.Contains(e, "Slice=carlos.slice") {
+ t.Error("carlos-edge.service is not in carlos.slice")
+ }
+ for _, never := range []string{"MemoryHigh=", "MemoryMax="} {
+ if strings.Contains(e, never) {
+ t.Errorf("carlos-edge.service still carries %q; the wall is the slice's now", never)
+ }
+ }
+ // What must NOT change on the edge unit.
+ for _, keep := range []string{"OOMPolicy=continue", "Delegate=cpu memory io pids", "User=carlos",
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE", "ReadWritePaths=/data /run/carlos", "RestrictNamespaces=yes"} {
+ if !strings.Contains(e, keep) {
+ t.Errorf("carlos-edge.service lost %q", keep)
+ }
+ }
+ i := readUnit(t, "carlos-instances.slice")
+ if !strings.Contains(i, "[Slice]") {
+ t.Error("carlos-instances.slice has no [Slice] section")
+ }
+ for _, never := range []string{"MemoryHigh=", "MemoryMax="} {
+ if strings.Contains(i, never) {
+ t.Errorf("carlos-instances.slice carries %q; the walls are carlos.slice and the per-host slices", never)
+ }
+ }
+}
+
+// The three mounts: a bind of a raw directory onto its protected view,
+// nosymfollow, with DefaultDependencies=no and explicit ordering, because
+// the default ordering (before local-fs.target) and tmpfiles (after it)
+// would form a cycle systemd breaks by dropping the mount (codex R2-2).
+func TestInstanceMountsAreShapedForNosymfollow(t *testing.T) {
+ cases := map[string][2]string{
+ "data-instances.mount": {"/data/.instances", "/data/instances"},
+ "run-carlos-instances.mount": {"/run/carlos/.instances", "/run/carlos/instances"},
+ "run-carlos-logs.mount": {"/run/carlos/.logs", "/run/carlos/logs"},
+ }
+ for name, ww := range cases {
+ m := readUnit(t, name)
+ for _, want := range []string{
+ "What=" + ww[0], "Where=" + ww[1], "Type=none",
+ "Options=bind,nosymfollow,nosuid,nodev,noexec",
+ "DefaultDependencies=no",
+ "After=", "systemd-tmpfiles-setup.service",
+ "Before=", "carlos-edge.service",
+ "Conflicts=umount.target", "Before=umount.target",
+ "WantedBy=multi-user.target",
+ } {
+ if !strings.Contains(m, want) {
+ t.Errorf("%s lacks %q", name, want)
+ }
+ }
+ if ww[0] == ww[1] {
+ t.Errorf("%s binds a directory onto itself; the migration rename would fail with EXDEV (codex R4-2)", name)
+ }
+ }
+ d := readUnit(t, "data-instances.mount")
+ for _, want := range []string{"RequiresMountsFor=/data", "local-fs.target", "carlos-litestream.service"} {
+ if !strings.Contains(d, want) {
+ t.Errorf("data-instances.mount lacks %q", want)
+ }
+ }
+ e := readUnit(t, "carlos-edge.service")
+ for _, want := range []string{"Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount",
+ "After=network-online.target data-instances.mount run-carlos-instances.mount run-carlos-logs.mount"} {
+ if !strings.Contains(e, want) {
+ t.Errorf("carlos-edge.service lacks %q: an edge that starts without the mounts must not start", want)
+ }
+ }
+ l := readUnit(t, "carlos-litestream.service")
+ if !strings.Contains(l, "After=network-online.target data-instances.mount") {
+ t.Error("carlos-litestream.service is not ordered after data-instances.mount")
+ }
+}
+
+// Every unit file, whatever its suffix, is embedded: a .slice or .mount
+// left out of the glob would be silently absent from every box.
+func TestEmbeddedUnitsIncludeSlicesAndMounts(t *testing.T) {
+ names, err := unitNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ have := map[string]bool{}
+ for _, n := range names {
+ have[n] = true
+ }
+ for _, want := range []string{"carlos.slice", "carlos-instances.slice", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} {
+ if !have[want] {
+ t.Errorf("%s is not embedded (bootstrap.go's go:embed glob)", want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -run 'TestCarlosSlice|TestInstanceMounts|TestEmbeddedUnits' -count=1`
+Expected: FAIL (`carlos.slice: open units/carlos.slice: file does not exist`, and so on).
+
+- [ ] **Step 3: Write the unit files**
+
+`internal/host/units/carlos.slice`:
+
+```ini
+# The outer memory wall of the memory-pressure containment design
+# (2026-08-28 §2.2), moved here from carlos-edge.service by the isolated-exec
+# design (§4.3): exec-backed instances used to be the edge's children, so a
+# ceiling on the edge unit bounded the agent plus every awake instance at
+# once. They are sibling units now, so the ceiling lives one level up, on
+# a slice that holds the edge AND carlos-instances.slice, and bounds exactly
+# what it bounded before. Borrowing between agent and tenants and the OOM
+# candidate set (a tenant at OOMScoreAdjust=500 is picked before the agent)
+# are unchanged.
+#
+# Percentages so one file fits a t4g.micro and a t4g.medium; 80% leaves what
+# the box's other units (keymail, console, ssm, litestream, journald) use.
+[Unit]
+Description=carlos (edge, activator and every exec-backed instance)
+
+[Slice]
+MemoryHigh=70%
+MemoryMax=80%
+```
+
+`internal/host/units/carlos-instances.slice`:
+
+```ini
+# The parent of every per-host slice (carlos-instances-<iid>.slice, created
+# on demand by the first carlos-instance*@ unit that names it; isolated-exec
+# design §4.3). No cap of its own: the outer wall is carlos.slice, the inner
+# walls are the per-host slices the control service sets the tier on. This
+# level exists so the sampler has one root to scan and so an operator can
+# `systemctl status carlos-instances.slice`.
+[Unit]
+Description=carlos exec-backed instances
+
+[Slice]
+```
+
+`internal/host/units/data-instances.mount`:
+
+```ini
+# The protected view of the instance data tree (isolated-exec design §3.2).
+#
+# /data/.instances is the RAW directory, opened by the layout pass only;
+# this bind of it onto /data/instances is the view everything else uses.
+# nosymfollow is the isolation's belt: path resolution under this mount
+# returns ELOOP at any symlink, for every process on the box, in every
+# namespace that sees the mount, with no check-then-use race. An instance
+# runs as the agent's uid and can write its own directory, so any symlink
+# it plants would otherwise be followed by the agent (reading a sibling's
+# env file as this instance's), by the edge (dialling a sibling's socket
+# as this host) and by PID 1. Here none of them follow it.
+#
+# It is a bind of a DIFFERENT directory, not of itself, because rename(2)
+# refuses to cross a mount boundary (EXDEV) even on one filesystem, and
+# the migration moves /data/<host>.db into this tree by rename (codex R4-2).
+#
+# DefaultDependencies=no, with the ordering written out: a mount unit's
+# default place is before local-fs.target, and systemd-tmpfiles-setup,
+# which creates the two directories, runs after it, so the defaults form a
+# cycle systemd breaks by dropping this mount (codex R2-2). Shutdown
+# ordering is written out for the same reason.
+[Unit]
+Description=carlos instance data (bind, nosymfollow)
+DefaultDependencies=no
+RequiresMountsFor=/data
+After=local-fs.target systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service carlos-litestream.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/data/.instances
+Where=/data/instances
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`internal/host/units/run-carlos-instances.mount`:
+
+```ini
+# The protected view of the instance socket tree (isolated-exec design
+# §3.2): /run/carlos/.instances bound onto /run/carlos/instances with
+# nosymfollow. See data-instances.mount for why a bind, why of a different
+# directory, and why DefaultDependencies=no. Nothing is ever renamed into
+# this tree, so the raw directory has no caller at all. /run is tmpfs, so
+# the two directories come from the tmpfiles line every boot, which is why
+# this is ordered after systemd-tmpfiles-setup.service.
+[Unit]
+Description=carlos instance sockets (bind, nosymfollow)
+DefaultDependencies=no
+After=systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/run/carlos/.instances
+Where=/run/carlos/instances
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`internal/host/units/run-carlos-logs.mount`:
+
+```ini
+# The protected view of the agent's per-role log sockets (isolated-exec
+# design §3.2, §6.4): /run/carlos/.logs bound onto /run/carlos/logs with
+# nosymfollow. This directory is bound into NO instance namespace; the
+# mount option is for the transition release, in which unisolated flat
+# tenants still share the agent's uid and could otherwise turn a log
+# socket path into a symlink that PID 1 opens as root for some unit's
+# stdout (codex R3-1). See data-instances.mount for the ordering.
+[Unit]
+Description=carlos instance log sockets (bind, nosymfollow)
+DefaultDependencies=no
+After=systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/run/carlos/.logs
+Where=/run/carlos/logs
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
+```
+
+- [ ] **Step 4: Edit the edge and litestream units**
+
+`internal/host/units/carlos-edge.service`, `[Unit]` section (lines 6-9) becomes:
+
+```ini
+[Unit]
+Description=carlos edge (TLS terminator, host router, activator, release adoption)
+# The three nosymfollow mounts of the isolated-exec design (§3.2) are hard
+# requirements: an edge that starts without them would start unprotected,
+# so it does not start at all.
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=network-online.target data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+Wants=network-online.target
+```
+
+In `[Service]`, replace lines 71-72 (`MemoryHigh=70%` and `MemoryMax=80%`) and the comment block above them (the block that begins `# The outer wall (memory-pressure spec 2026-08-28 §2.2)`) with:
+
+```ini
+# The outer wall (memory-pressure spec 2026-08-28 §2.2) used to be two
+# MemoryHigh/MemoryMax lines here, bounding the agent plus its exec
+# children. Instances are sibling units now (isolated-exec design §4.3), so
+# the wall is carlos.slice's, which holds this unit and every instance and
+# bounds exactly what it bounded before. The oom_score_adj story is
+# unchanged: tenants carry OOMScoreAdjust=500 in their template.
+Slice=carlos.slice
+```
+
+Keep `OOMPolicy=continue` and its comment, `TasksMax=1024`, and `Delegate=` exactly as they are.
+
+`internal/host/units/carlos-litestream.service`, line 12: `After=network-online.target` becomes `After=network-online.target data-instances.mount` (the replicator reads nested databases through the protected view; it must see the mount).
+
+- [ ] **Step 5: Widen the embed glob**
+
+`internal/host/bootstrap.go:33`:
+
+```go
+//go:embed units/*.service units/*.timer units/*.socket units/*.path units/*.slice units/*.mount
+var unitsFS embed.FS
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -run 'TestCarlosSlice|TestInstanceMounts|TestEmbeddedUnits|TestBootstrap|TestSystemUnits|TestAppTemplate|TestVerifyDrills' -count=1`
+Expected: PASS. If an existing test asserts the edge unit's `MemoryMax=80%` (search: `grep -n 'MemoryMax=80' internal/host/*_test.go internal/caps/*_test.go`), move that assertion to `carlos.slice` in that test and keep the rest of it.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/host/units/carlos.slice internal/host/units/carlos-instances.slice internal/host/units/data-instances.mount internal/host/units/run-carlos-instances.mount internal/host/units/run-carlos-logs.mount internal/host/units/carlos-edge.service internal/host/units/carlos-litestream.service internal/host/bootstrap.go internal/host/units_isolation_test.go
+git commit -m "units: carlos.slice outer wall and the three nosymfollow instance mounts (plan 1, task 4)"
+```
+
+---
+
+### Task 5: Bootstrap wiring: tmpfiles lines, directories, arming
+
+**Files:**
+- Modify: `internal/host/bootstrap.go` (`tmpfilesConf` at line 61; `enabledUnits` at line 116; `dirs` at line 296; `systemctlPlan` at line 165)
+- Modify: `internal/host/host_test.go:1195` (`TestBootstrapPlanJSONNamesWhatALiveRunArms`)
+- Test: `internal/host/units_isolation_test.go` (append)
+
+**Interfaces:**
+- Consumes: the unit names from Task 4.
+- Produces: `tmpfilesConf` lines for `/data/.instances`, `/data/instances`, `/data/layout`, `/run/carlos/.instances`, `/run/carlos/instances`, `/run/carlos/.logs`, `/run/carlos/logs`, `/run/carlos-control`, and the probe fixture; `systemctlPlan` starting `.socket` and `.mount` units; the mounts in `enabledUnits`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/host/units_isolation_test.go`:
+
+```go
+// tmpfiles creates the raw directories and the mountpoints every boot
+// (/run is tmpfs), with the modes the design gives them (§3.1, §3.2):
+// the socket trees keep the carlos-sockets group and setgid (issue #103),
+// the log tree and data trees are the agent's alone, and the control
+// directory is root's so no carlos process can create anything in it.
+func TestTmpfilesCreatesTheInstanceTrees(t *testing.T) {
+ for _, want := range []string{
+ "d /data/.instances 0700 carlos carlos -",
+ "d /data/instances 0755 carlos carlos -",
+ "d /data/layout 0700 carlos carlos -",
+ "d /run/carlos/.instances 2750 carlos carlos-sockets -",
+ "d /run/carlos/instances 2750 carlos carlos-sockets -",
+ "d /run/carlos/.logs 0700 carlos carlos -",
+ "d /run/carlos/logs 0700 carlos carlos -",
+ "d /run/carlos-control 0750 root carlos -",
+ "f /etc/carlos/probe-secret 0640 root carlos - probe",
+ } {
+ if !strings.Contains(tmpfilesConf, want+"\n") {
+ t.Errorf("tmpfilesConf lacks %q", want)
+ }
+ }
+ // The existing lines stay: the flat socket dir and the apps subtree.
+ for _, keep := range []string{"d /run/carlos 2750 carlos carlos-sockets -", "d /run/carlos/apps 0750 carlos carlos -"} {
+ if !strings.Contains(tmpfilesConf, keep+"\n") {
+ t.Errorf("tmpfilesConf lost %q", keep)
+ }
+ }
+}
+
+// bootstrap -root <dir> creates the same directories in the staging tree
+// (a live run's chown -R covers them), so a fresh box has them before the
+// mounts try to bind them.
+func TestBootstrapCreatesTheInstanceDirectories(t *testing.T) {
+ root := t.TempDir()
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatalf("bootstrap: %v", err)
+ }
+ for _, d := range []string{"data/.instances", "data/instances", "data/layout", "run/carlos/.instances", "run/carlos/instances", "run/carlos/.logs", "run/carlos/logs", "run/carlos-control"} {
+ if fi, err := os.Stat(filepath.Join(root, d)); err != nil || !fi.IsDir() {
+ t.Errorf("%s: %v", d, err)
+ }
+ }
+}
+
+// The mounts are enabled AND started by the plan: `systemctl enable` on a
+// mount unit writes a symlink for the next boot and nothing else, the same
+// trap timers and path units fell into (codex R1-14). A socket unit the
+// same. The relay is still the only service the plan starts.
+func TestSystemctlPlanStartsMountsAndSockets(t *testing.T) {
+ plan := systemctlPlan([]string{"carlos-edge.service", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"})
+ var started []string
+ for _, args := range plan {
+ if args[0] == "start" {
+ started = append(started, args[1:]...)
+ }
+ }
+ got := strings.Join(started, " ")
+ for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("plan does not start %s: %v", want, plan)
+ }
+ }
+ if strings.Contains(got, "carlos-edge.service") {
+ t.Errorf("plan starts the edge: %v", plan)
+ }
+}
+
+func TestEnabledUnitsIncludeTheMounts(t *testing.T) {
+ have := map[string]bool{}
+ for _, u := range enabledUnits {
+ have[u] = true
+ }
+ for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} {
+ if !have[want] {
+ t.Errorf("enabledUnits lacks %s", want)
+ }
+ }
+}
+```
+
+Add `"os"` and `"path/filepath"` to that test file's imports.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -run 'TestTmpfiles|TestBootstrapCreatesTheInstance|TestSystemctlPlanStartsMounts|TestEnabledUnitsInclude' -count=1`
+Expected: FAIL on every one.
+
+- [ ] **Step 3: Extend `tmpfilesConf`, the directory list, `enabledUnits` and `systemctlPlan`**
+
+`internal/host/bootstrap.go:61`, replace the constant:
+
+```go
+const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" +
+ "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" +
+ // The isolated-exec trees (design §3.1, §3.2). Each protected view is
+ // a nosymfollow bind of the dotted raw directory beside it (the mount
+ // units); tmpfiles creates both every boot because /run is tmpfs and
+ // because the mounts are ordered after it. The socket trees keep the
+ // consumers group and setgid, exactly like /run/carlos above, so an
+ // enrolled router can still dial a migrated instance (issue #103).
+ "d /data/.instances 0700 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /data/instances 0755 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
+ "d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
+ "d /run/carlos/.logs 0700 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /run/carlos/logs 0700 " + serviceUser + " " + serviceUser + " -\n" +
+ // The root instance-control channel's directory (design §5.2): root's,
+ // so no carlos process can create, rename or replace anything in it;
+ // group carlos with search so the agent can connect.
+ "d /run/carlos-control 0750 root " + serviceUser + " -\n" +
+ // The readiness probe's fixture (design §8.5, codex R11-1): a file
+ // root:carlos 0640 that an isolated instance must NOT be able to read.
+ // Written here, not by the control service, whose ProtectSystem=strict
+ // makes /etc read-only to it.
+ "f /etc/carlos/probe-secret 0640 root " + serviceUser + " - probe\n"
+```
+
+In `bootstrapWith`'s `dirs` slice (line 296 onward), add after the `{filepath.Join(runDir, "apps"), 0o750},` entry:
+
+```go
+ // The isolated-exec trees (design §3.1). The dotted names are the
+ // raw directories the mount units bind onto the undotted views;
+ // see tmpfilesConf for the modes and why both exist.
+ {filepath.Join(dataDir, ".instances"), 0o700},
+ {filepath.Join(dataDir, "instances"), 0o755},
+ {filepath.Join(dataDir, "layout"), 0o700},
+ {filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid},
+ {filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid},
+ {filepath.Join(runDir, ".logs"), 0o700},
+ {filepath.Join(runDir, "logs"), 0o700},
+ // Root-owned on a live box (privilegedPlan chowns /run/carlos
+ // recursively to the service user; this one sits BESIDE it, not
+ // under it, precisely so that chown never reaches it).
+ {filepath.Join(root, "run", "carlos-control"), 0o750},
+```
+
+`enabledUnits` (line 116):
+
+```go
+var enabledUnits = []string{"carlos-edge.service", relayUnitName, "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path",
+ "carlos-system-update.path", "carlos-system-recover.service",
+ // The isolated-exec mounts (design §3.2): the edge Requires= them.
+ "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"}
+```
+
+`systemctlPlan` (line 165): the `armed` predicate becomes
+
+```go
+ for _, u := range units {
+ if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") ||
+ strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName {
+ armed = append(armed, u)
+ }
+ }
+```
+
+and extend the function's doc comment with one paragraph:
+
+```go
+// Socket and mount units are armed the same way (isolated-exec design
+// §8.6, codex R1-14): an enabled-but-unstarted socket is a control channel
+// nobody is listening on, and an enabled-but-unmounted bind is an instance
+// tree with no protection, until the next reboot. Starting either is
+// idempotent.
+```
+
+Update `TestBootstrapPlanJSONNamesWhatALiveRunArms` (`host_test.go:1195`): add the three mounts to its `want` map. Its "starts no service but the relay" loop is unchanged and still passes.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host -count=1`
+Expected: PASS for the whole package. (`TestBootstrapWritesJournaldCap` and the other bootstrap tests run `bootstrap(root, nil)` against a temp root and are unaffected by the new directories.)
+
+- [ ] **Step 5: Full gate**
+
+Run, outside the sandbox with `TMPDIR=/tmp/g`:
+
+```bash
+export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g
+go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1
+GOOS=darwin go build ./... && GOOS=windows go build ./... && GOARCH=arm64 go build ./...
+```
+
+Expected: every package passes, `gofmt -l .` prints nothing, the three cross-compiles build.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/host/bootstrap.go internal/host/host_test.go internal/host/units_isolation_test.go
+git commit -m "bootstrap: create and arm the isolated-exec trees, mounts and control directory (plan 1, task 5)"
+```
+
+---
+
+## Self-review
+
+**Spec coverage for this plan's scope.** §3.1 helpers: Task 1. §3.1 `InstanceID` bound and the `_` domain rule: Task 1. §3.2 mounts, tmpfiles, `instancefs`, `O_EXCL` writes: Tasks 2, 4, 5. §4.3 `carlos.slice`, `carlos-instances.slice`, edge unit changes: Task 4. §5.4 dial checks on every dial the edge and agent make: Task 3 (edge `unixProxy` via `dialUnixWithRetry`, activator `socketClient`, release `unixHTTPClient`, host `socketServing`; `schedule_wiring.proxyFor` uses `edge.UnixProxy` and is covered; `internal/vet/vet.go` is a local harness dialling a socket it created itself and is deliberately left alone). §8.6 planner starts sockets and mounts: Task 5. §9 units and installers, tmpfiles: Tasks 4, 5. Deferred to later plans, by design: the three templates, the probe, the control units and verbs (plan 2); the activator unit path and every consumer of the nested paths (plan 3); the layout verb, journal, status field, downgrade guards and `docs/site/cli.md` (plan 4). `update-platform-binary.sh` in pinfra (enable the new units by name) is a pinfra change noted for the roll, outside this repo.
+
+**Placeholder scan.** None; every step carries its code. The one conditional in Task 3 step 5 (an import cycle through `release`) names the exact fallback.
+
+**Type consistency.** `InstanceID`, `InstanceDir`, `NestedDBPath`, `InstanceRunDir`, `NestedSocket`, `LogSocket`, `IsControlEndpoint`, `ControlSocketDir`, `ControlSocketPath`, `MaxInstanceID`, `MaxUnixPath`, `LongestSocketAddress` are spelled identically in Tasks 1 and 3. `unixdial.Checked(ctx, d, path)` is spelled identically at all four call sites. `unitNames()` exists (`bootstrap.go:482`) and is used unchanged in Task 4's test.