carlos / platform Public

units and verbs: the instance templates, the probe, the control socket and service, and their docs (plan 2, task 6)

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 c15a7b8eb91a550c504465d1c262034683d51244 parent 7a1e717
13 files changed, +656 −8
  • docs/site/cli.md +33 −0
  • internal/host/bootstrap.go +14 −1
  • internal/host/host_test.go +6 −0
  • internal/host/systemunits.go +17 −5
  • internal/host/systemunits_test.go +4 −2
  • internal/host/units/carlos-instance-control.service +32 −0
  • internal/host/units/carlos-instance-control.socket +18 −0
  • internal/host/units/carlos-instance-probe.service +71 −0
  • internal/host/units/carlos-instance-seed@.service +99 −0
  • internal/host/units/carlos-instance-sidecar@.service +99 −0
  • internal/host/units/carlos-instance@.service +99 −0
  • internal/host/units_isolation_test.go +154 −0
  • main.go +10 −0

CI passed — run details

diff --git a/docs/site/cli.md b/docs/site/cli.md
index 393fe19d9..0963b364d 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1288,6 +1288,39 @@ build the process is not serving.
root-side unit to run for it, since the agent can request a pass but cannot
write to `/etc` or restart units itself.
+### carlos instance-control
+
+The root half of isolated exec. `carlos-instance-control.socket` activates
+it when the agent connects; it starts, stops and sweeps the
+`carlos-instance*@` units for the agent, sets each host's memory tier on
+its slice before a start, and runs the isolation probe at start and every
+five minutes. It accepts a connection only from the edge unit's own main
+process. Not for operators: there is nothing to type.
+
+### carlos instance-exec
+
+The first thing an isolated instance unit runs. It reads the launch file
+the agent wrote into the instance's directory and execs the host's live
+link with exactly the arguments and environment in it, refusing anything
+that is not the platform's own live link. `ExecStart=` of the three
+instance templates.
+
+### carlos instance-exit
+
+`ExecStopPost=` of the three instance templates. It leaves an advisory
+exit record in the instance's run directory from the four variables
+systemd provides; the agent proves termination from the unit's state and
+its cgroup, not from this file.
+
+### carlos instance-probe
+
+The isolation probe, run under the same namespace text as the instance
+templates. From inside it checks what a tenant would see: only its own
+directories, only its own pid, a read-only cgroupfs, no `/etc/carlos`, a
+symlink that is not followed, and a metadata service that a packet filter
+refuses. It prints a verdict; `carlos-instance-control` reads it and
+refuses to start isolated instances until every line passes.
+
### carlos ops
Box operations, run by a timer or by an operator standing on the box.
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 2b7435ab3..39aead0ff 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -76,6 +76,11 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup +
"d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" +
+ // The readiness probe's own instance directory (design §8.5): a name
+ // no host can have, created every boot so the probe unit's bind
+ // sources exist before it starts.
+ "d /data/.instances/_probe 0700 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /run/carlos/.instances/_probe 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,
@@ -143,7 +148,10 @@ const relayUnitName = "carlos-relay.service"
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"}
+ "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount",
+ // The root instance-control channel (design §5.2); socket-activated, so
+ // only the socket is enabled and started.
+ "carlos-instance-control.socket"}
// inertUnits are installed everywhere and deliberately never armed by
// bootstrap or by a self-update apply: restore-verify until an operator
@@ -353,6 +361,11 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error {
{filepath.Join(dataDir, "layout"), 0o700},
{filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid},
{filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid},
+ // The readiness probe's own instance directory (design §8.5): a
+ // name no host can have, created here so a staging root has it
+ // too (see tmpfilesConf for the modes and why both exist).
+ {filepath.Join(dataDir, ".instances", "_probe"), 0o700},
+ {filepath.Join(runDir, ".instances", "_probe"), 0o750 | os.ModeSetgid},
{filepath.Join(runDir, ".logs"), 0o700},
{filepath.Join(runDir, "logs"), 0o700},
// Root-owned on a live box (privilegedPlan chowns /run/carlos
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 37558c533..09ad50cc6 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -36,6 +36,12 @@ var expectedUnits = []string{
"carlos-edge.service",
"carlos-hibernate-verify.service",
"carlos-hibernate-verify.timer",
+ "carlos-instance-control.service",
+ "carlos-instance-control.socket",
+ "carlos-instance-probe.service",
+ "carlos-instance-seed@.service",
+ "carlos-instance-sidecar@.service",
+ "carlos-instance@.service",
"carlos-instances.slice",
"carlos-litestream.service",
"carlos-relay.service",
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go
index c84e70561..4c5c41d7f 100644
--- a/internal/host/systemunits.go
+++ b/internal/host/systemunits.go
@@ -11,7 +11,14 @@ import (
// rather than repeated as a string literal at each call site below.
const systemRecoverUnit = "carlos-system-recover.service"
-var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit}
+// instanceControlSocket is the root instance-control channel's socket
+// (design §5.2). ensureSystemUnits arms it the same way it arms the
+// self-update units: this pass is the second belt for any unit a roll
+// script forgot, and a box with the socket unarmed has no isolated-exec
+// control channel at all.
+const instanceControlSocket = "carlos-instance-control.socket"
+
+var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit, instanceControlSocket, "carlos-instance-control.service"}
// ensureSystemUnits is the second belt of spec §7: a box the roll script
// forgot to arm is armed within the hour by the root adopt pass. It
@@ -61,6 +68,10 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st
// with no systemctl at all, so the printed word is the answer and the
// exit status is ignored, exactly as probes.go has always done it.
active, activeErr := probe("is-active", systemUpdatePathUnit)
+ // The control socket's own is-active check, alongside the self-update
+ // path unit's: the same silent-systemctl-vs-unarmed-unit question, one
+ // more unit wide (design §5.2).
+ ctlActiveOut, ctlActiveErr := probe("is-active", instanceControlSocket)
// The is-enabled error is dropped on purpose: its cases are the words
// parseIsEnabled reads, and "no such unit file" is a perfectly good
// "not enabled" on the box we are here to fix.
@@ -70,8 +81,9 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st
// of, so no word at all means the question could not be asked — no
// binary, or an exec that failed outright.
isActive := parseIsActive(active)
- if isActive == nil {
- logf("adopt: system units: systemctl said nothing; not touching the self-update units: %v", activeErr)
+ ctlActive := parseIsActive(ctlActiveOut)
+ if isActive == nil || ctlActive == nil {
+ logf("adopt: system units: systemctl said nothing; not touching the self-update units: %v / %v", activeErr, ctlActiveErr)
return
}
isEnabled := parseIsEnabled(enabled)
@@ -81,7 +93,7 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st
missing = true
}
}
- if !missing && *isActive && isEnabled != nil && *isEnabled {
+ if !missing && *isActive && isEnabled != nil && *isEnabled && *ctlActive {
return
}
if err := os.MkdirAll(unitDir, 0o755); err != nil {
@@ -99,7 +111,7 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st
return
}
}
- for _, args := range systemctlPlan([]string{systemUpdatePathUnit, systemRecoverUnit}) {
+ for _, args := range systemctlPlan([]string{systemUpdatePathUnit, systemRecoverUnit, instanceControlSocket}) {
if _, err := systemctl(args...); err != nil {
logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err)
return
diff --git a/internal/host/systemunits_test.go b/internal/host/systemunits_test.go
index 4947d965d..eb003b870 100644
--- a/internal/host/systemunits_test.go
+++ b/internal/host/systemunits_test.go
@@ -51,7 +51,9 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) {
if strings.Contains(joined, "start carlos-system-recover.service") || strings.Contains(joined, "start carlos-system-update.service") {
t.Fatal("a service was started")
}
- // All present and armed: no writes, no systemctl beyond the two checks.
+ // All present and armed: no writes, no systemctl beyond the three checks
+ // (is-active x2 — the self-update path unit and the control socket —
+ // plus is-enabled).
calls = nil
probe2 := func(args ...string) (string, error) {
calls = append(calls, strings.Join(args, " "))
@@ -64,7 +66,7 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) {
return "", nil
}
ensureSystemUnits(etc, lockPath, systemctl, probe2, t.Logf)
- if len(calls) != 2 {
+ if len(calls) != 3 {
t.Fatalf("an armed box got %v", calls)
}
}
diff --git a/internal/host/units/carlos-instance-control.service b/internal/host/units/carlos-instance-control.service
new file mode 100644
index 000000000..364de03cb
--- /dev/null
+++ b/internal/host/units/carlos-instance-control.service
@@ -0,0 +1,32 @@
+# The root instance-control service (isolated-exec design §5). Socket-
+# activated, so it runs only while there is work. Its whole job is
+# systemctl start/stop/set-property on the three instance templates' units
+# and their host slices, on the agent's request, plus running the readiness
+# probe. Every unit name it passes to systemctl is built from a validated
+# host by the platform's own naming; nothing from a request reaches
+# systemctl as a string.
+#
+# Runs as ROOT deliberately, with no new-privileges lockout and no syscall
+# filter, for the systemctl child (the same rationale as
+# carlos-stage.service). It accepts a connection only from the edge unit's
+# main process (SO_PEERCRED uid carlos, pid == MainPID of
+# carlos-edge.service): a flat tenant is a child of that pid, never the pid
+# itself, and a nested tenant cannot reach the socket at all.
+[Unit]
+Description=carlos instance control (root: start/stop carlos-instance*@ units for the agent)
+Requires=carlos-instance-control.socket data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+
+[Service]
+ExecStart=/opt/carlos/carlos instance-control
+ProtectSystem=strict
+ReadWritePaths=/run/carlos-control
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+RestrictAddressFamilies=AF_UNIX
+MemoryDenyWriteExecute=yes
+MemoryMax=64M
diff --git a/internal/host/units/carlos-instance-control.socket b/internal/host/units/carlos-instance-control.socket
new file mode 100644
index 000000000..1835c41a0
--- /dev/null
+++ b/internal/host/units/carlos-instance-control.socket
@@ -0,0 +1,18 @@
+# The root instance-control channel's socket (isolated-exec design §5.2).
+# In /run/carlos-control, a root-owned directory beside /run/carlos, never
+# under it: an instance's namespace replaces /run/carlos with an empty
+# tmpfs, and nothing an instance can reach must resolve to this path. Owned
+# by carlos so the agent can connect; the service authenticates the peer
+# process itself (§5.4).
+[Unit]
+Description=carlos instance control socket (agent to root)
+
+[Socket]
+ListenStream=/run/carlos-control/control.sock
+SocketUser=carlos
+SocketGroup=carlos
+SocketMode=0600
+Accept=no
+
+[Install]
+WantedBy=sockets.target
diff --git a/internal/host/units/carlos-instance-probe.service b/internal/host/units/carlos-instance-probe.service
new file mode 100644
index 000000000..5ab462f10
--- /dev/null
+++ b/internal/host/units/carlos-instance-probe.service
@@ -0,0 +1,71 @@
+# The readiness probe (isolated-exec design §8.5): a oneshot under the SAME
+# namespace text as the tenant templates (the test pins the block byte for
+# byte), run by carlos-instance-control at its start and every five
+# minutes. From inside it asserts what a tenant would see and prints a
+# verdict; the unit's StandardOutput=truncate: lands it where the control
+# service reads it. It exits 0 whatever the verdict: the verdict is the
+# output. Its instance directory is _probe, a name no host can have; the
+# tmpfiles lines create it.
+[Unit]
+Description=carlos instance isolation probe
+Requires=data-instances.mount run-carlos-instances.mount
+After=data-instances.mount run-carlos-instances.mount
+
+[Service]
+Type=oneshot
+ExecStart=/opt/carlos/carlos instance-probe
+User=carlos
+Group=carlos
+Slice=carlos-instances.slice
+StandardOutput=truncate:/run/carlos-control/probe.out
+StandardError=journal
+TimeoutStartSec=30
+# The instance's own two directories, the only writable data paths.
+BindPaths=/data/instances/_probe /run/carlos/instances/_probe
+ReadWritePaths=/data/instances/_probe /run/carlos/instances/_probe
+
+# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
+ProtectSystem=strict
+TemporaryFileSystem=/data:ro
+TemporaryFileSystem=/run/carlos:ro
+# Every other tree a carlos-uid unit writes on the box is hidden the same
+# way (codex R9-2): the restore drills' cache under /var/cache, and
+# /var/lib as a class. An instance needs nothing from either.
+TemporaryFileSystem=/var/cache:ro
+TemporaryFileSystem=/var/lib:ro
+# Root-written credentials a carlos-GROUP process may read live here:
+# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
+# and an instance's Group=carlos would read it too (codex R10-1). An
+# instance gets its configuration through its launch file and needs
+# nothing from /etc/carlos.
+InaccessiblePaths=/etc/carlos
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
+# and its cgroup.kill / cgroup.freeze would otherwise be one write away
+# for a same-uid tenant (codex R9-1).
+ProtectControlGroups=yes
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=64
+MemoryMax=64M
diff --git a/internal/host/units/carlos-instance-seed@.service b/internal/host/units/carlos-instance-seed@.service
new file mode 100644
index 000000000..50a15df7e
--- /dev/null
+++ b/internal/host/units/carlos-instance-seed@.service
@@ -0,0 +1,99 @@
+# One isolated exec-backed instance (isolated-exec design §4.1). The
+# activator owns it exactly as it owned the exec child this replaces: lease,
+# restore, seed, replication, hibernation, control socket, logs. What
+# changed is that the process is a unit, still uid carlos, inside a mount
+# namespace in which nothing but the instance's own two directories exists,
+# on mounts where no symlink is ever followed. The instance name is the
+# escaped bounded identifier (%i); paths use the unescaped one (%I).
+[Unit]
+Description=carlos exec-backed instance %I (seed)
+# Stop-only coupling: when the edge stops (including the stop half of a
+# restart) this unit stops, and nothing starts it again but the agent.
+# PartOf= would propagate the RESTART and race the agent's own startup
+# (codex R1-7).
+BindsTo=carlos-edge.service
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+# After ExecStart exits the unit stays active (exited) or failed, loaded,
+# with its InvocationID, Result and ExecMainStatus retained until the
+# control service stops it, so the seed's result can never be garbage-
+# collected before it is read (codex R2-4).
+TimeoutStartSec=120
+# Today's seed budget; a oneshot has no start timeout by default (codex R1-5).
+ExecStart=/opt/carlos/carlos instance-exec seed %I
+# Runs inside the same namespace, as carlos, after this invocation's
+# processes have been signalled: leaves an ADVISORY exit record in the
+# instance's own run directory (§6.5). Not "+": the "+" prefix drops User=
+# and the Private*/Protect* settings but keeps the explicit
+# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from
+# here can see the host's /run/carlos anyway.
+ExecStopPost=/opt/carlos/carlos instance-exit seed %I
+User=carlos
+Group=carlos
+# One slice per host holds the instance, its seed and its sidecar, and
+# carries the tier (§4.3, §6.6). Slices referenced here are created on
+# demand; no per-host unit file exists.
+Slice=carlos-instances-%i.slice
+# The log stream: stdout and stderr connect to the agent's per-role
+# listener (§6.4). PID 1 opens this as root, outside the namespace, on the
+# nosymfollow logs mount, at a path no tenant mount contains.
+StandardOutput=file:/run/carlos/logs/%I.seed.sock
+StandardError=inherit
+# The instance's own two directories, the only writable data paths.
+BindPaths=/data/instances/%I /run/carlos/instances/%I
+ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
+
+# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
+ProtectSystem=strict
+TemporaryFileSystem=/data:ro
+TemporaryFileSystem=/run/carlos:ro
+# Every other tree a carlos-uid unit writes on the box is hidden the same
+# way (codex R9-2): the restore drills' cache under /var/cache, and
+# /var/lib as a class. An instance needs nothing from either.
+TemporaryFileSystem=/var/cache:ro
+TemporaryFileSystem=/var/lib:ro
+# Root-written credentials a carlos-GROUP process may read live here:
+# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
+# and an instance's Group=carlos would read it too (codex R10-1). An
+# instance gets its configuration through its launch file and needs
+# nothing from /etc/carlos.
+InaccessiblePaths=/etc/carlos
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
+# and its cgroup.kill / cgroup.freeze would otherwise be one write away
+# for a same-uid tenant (codex R9-1).
+ProtectControlGroups=yes
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=256
+# The kernel picks a tenant before it picks anything else in carlos.slice.
+OOMScoreAdjust=500
+# One OOM kill takes every process of THIS service (systemd sets
+# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
+# is a sibling service with its own family rule (§4.3).
+OOMPolicy=kill
diff --git a/internal/host/units/carlos-instance-sidecar@.service b/internal/host/units/carlos-instance-sidecar@.service
new file mode 100644
index 000000000..711336448
--- /dev/null
+++ b/internal/host/units/carlos-instance-sidecar@.service
@@ -0,0 +1,99 @@
+# One isolated exec-backed instance (isolated-exec design §4.1). The
+# activator owns it exactly as it owned the exec child this replaces: lease,
+# restore, seed, replication, hibernation, control socket, logs. What
+# changed is that the process is a unit, still uid carlos, inside a mount
+# namespace in which nothing but the instance's own two directories exists,
+# on mounts where no symlink is ever followed. The instance name is the
+# escaped bounded identifier (%i); paths use the unescaped one (%I).
+[Unit]
+Description=carlos exec-backed instance %I (sidecar)
+# Stop-only coupling: when the edge stops (including the stop half of a
+# restart) this unit stops, and nothing starts it again but the agent.
+# PartOf= would propagate the RESTART and race the agent's own startup
+# (codex R1-7).
+BindsTo=carlos-edge.service
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+
+[Service]
+Type=simple
+ExecStart=/opt/carlos/carlos instance-exec sidecar %I
+# Runs inside the same namespace, as carlos, after this invocation's
+# processes have been signalled: leaves an ADVISORY exit record in the
+# instance's own run directory (§6.5). Not "+": the "+" prefix drops User=
+# and the Private*/Protect* settings but keeps the explicit
+# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from
+# here can see the host's /run/carlos anyway.
+ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I
+User=carlos
+Group=carlos
+# One slice per host holds the instance, its seed and its sidecar, and
+# carries the tier (§4.3, §6.6). Slices referenced here are created on
+# demand; no per-host unit file exists.
+Slice=carlos-instances-%i.slice
+# The activator owns recycling: a crash stands the entry down and the next
+# request wakes it. systemd must not race that.
+Restart=no
+# SIGTERM to the app, SIGKILL to anything it left behind after
+# TimeoutStopSec; the activator's stop budget is 20s.
+KillMode=mixed
+TimeoutStopSec=20
+# The log stream: stdout and stderr connect to the agent's per-role
+# listener (§6.4). PID 1 opens this as root, outside the namespace, on the
+# nosymfollow logs mount, at a path no tenant mount contains.
+StandardOutput=file:/run/carlos/logs/%I.sidecar.sock
+StandardError=inherit
+# The instance's own two directories, the only writable data paths.
+BindPaths=/data/instances/%I /run/carlos/instances/%I
+ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
+
+# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
+ProtectSystem=strict
+TemporaryFileSystem=/data:ro
+TemporaryFileSystem=/run/carlos:ro
+# Every other tree a carlos-uid unit writes on the box is hidden the same
+# way (codex R9-2): the restore drills' cache under /var/cache, and
+# /var/lib as a class. An instance needs nothing from either.
+TemporaryFileSystem=/var/cache:ro
+TemporaryFileSystem=/var/lib:ro
+# Root-written credentials a carlos-GROUP process may read live here:
+# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
+# and an instance's Group=carlos would read it too (codex R10-1). An
+# instance gets its configuration through its launch file and needs
+# nothing from /etc/carlos.
+InaccessiblePaths=/etc/carlos
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
+# and its cgroup.kill / cgroup.freeze would otherwise be one write away
+# for a same-uid tenant (codex R9-1).
+ProtectControlGroups=yes
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=256
+# The kernel picks a tenant before it picks anything else in carlos.slice.
+OOMScoreAdjust=500
+# One OOM kill takes every process of THIS service (systemd sets
+# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
+# is a sibling service with its own family rule (§4.3).
+OOMPolicy=kill
diff --git a/internal/host/units/carlos-instance@.service b/internal/host/units/carlos-instance@.service
new file mode 100644
index 000000000..38ade8b5b
--- /dev/null
+++ b/internal/host/units/carlos-instance@.service
@@ -0,0 +1,99 @@
+# One isolated exec-backed instance (isolated-exec design §4.1). The
+# activator owns it exactly as it owned the exec child this replaces: lease,
+# restore, seed, replication, hibernation, control socket, logs. What
+# changed is that the process is a unit, still uid carlos, inside a mount
+# namespace in which nothing but the instance's own two directories exists,
+# on mounts where no symlink is ever followed. The instance name is the
+# escaped bounded identifier (%i); paths use the unescaped one (%I).
+[Unit]
+Description=carlos exec-backed instance %I
+# Stop-only coupling: when the edge stops (including the stop half of a
+# restart) this unit stops, and nothing starts it again but the agent.
+# PartOf= would propagate the RESTART and race the agent's own startup
+# (codex R1-7).
+BindsTo=carlos-edge.service
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+
+[Service]
+Type=simple
+ExecStart=/opt/carlos/carlos instance-exec run %I
+# Runs inside the same namespace, as carlos, after this invocation's
+# processes have been signalled: leaves an ADVISORY exit record in the
+# instance's own run directory (§6.5). Not "+": the "+" prefix drops User=
+# and the Private*/Protect* settings but keeps the explicit
+# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from
+# here can see the host's /run/carlos anyway.
+ExecStopPost=/opt/carlos/carlos instance-exit run %I
+User=carlos
+Group=carlos
+# One slice per host holds the instance, its seed and its sidecar, and
+# carries the tier (§4.3, §6.6). Slices referenced here are created on
+# demand; no per-host unit file exists.
+Slice=carlos-instances-%i.slice
+# The activator owns recycling: a crash stands the entry down and the next
+# request wakes it. systemd must not race that.
+Restart=no
+# SIGTERM to the app, SIGKILL to anything it left behind after
+# TimeoutStopSec; the activator's stop budget is 20s.
+KillMode=mixed
+TimeoutStopSec=20
+# The log stream: stdout and stderr connect to the agent's per-role
+# listener (§6.4). PID 1 opens this as root, outside the namespace, on the
+# nosymfollow logs mount, at a path no tenant mount contains.
+StandardOutput=file:/run/carlos/logs/%I.run.sock
+StandardError=inherit
+# The instance's own two directories, the only writable data paths.
+BindPaths=/data/instances/%I /run/carlos/instances/%I
+ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
+
+# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
+ProtectSystem=strict
+TemporaryFileSystem=/data:ro
+TemporaryFileSystem=/run/carlos:ro
+# Every other tree a carlos-uid unit writes on the box is hidden the same
+# way (codex R9-2): the restore drills' cache under /var/cache, and
+# /var/lib as a class. An instance needs nothing from either.
+TemporaryFileSystem=/var/cache:ro
+TemporaryFileSystem=/var/lib:ro
+# Root-written credentials a carlos-GROUP process may read live here:
+# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
+# and an instance's Group=carlos would read it too (codex R10-1). An
+# instance gets its configuration through its launch file and needs
+# nothing from /etc/carlos.
+InaccessiblePaths=/etc/carlos
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
+# and its cgroup.kill / cgroup.freeze would otherwise be one write away
+# for a same-uid tenant (codex R9-1).
+ProtectControlGroups=yes
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=256
+# The kernel picks a tenant before it picks anything else in carlos.slice.
+OOMScoreAdjust=500
+# One OOM kill takes every process of THIS service (systemd sets
+# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
+# is a sibling service with its own family rule (§4.3).
+OOMPolicy=kill
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index 12a4ea4c8..1fd4a43b2 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -197,3 +197,157 @@ func TestEnabledUnitsIncludeTheMounts(t *testing.T) {
}
}
}
+
+// The three tenant templates and the probe carry one namespace: byte-
+// identical hardening blocks, so a property proven by the probe is the
+// property a tenant gets (design §4.2, §8.5).
+func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
+ block := func(name string) string {
+ u := readUnit(t, name)
+ start := strings.Index(u, "# --- the namespace")
+ end := strings.Index(u, "# --- per-service resources")
+ if start < 0 || end < 0 || end < start {
+ t.Fatalf("%s: namespace block markers missing", name)
+ }
+ return u[start:end]
+ }
+ ref := block("carlos-instance@.service")
+ for _, name := range []string{"carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
+ if block(name) != ref {
+ t.Errorf("%s namespace block differs from carlos-instance@.service", name)
+ }
+ }
+ for _, want := range []string{
+ "ProtectSystem=strict", "TemporaryFileSystem=/data:ro", "TemporaryFileSystem=/run/carlos:ro",
+ "TemporaryFileSystem=/var/cache:ro", "TemporaryFileSystem=/var/lib:ro", "InaccessiblePaths=/etc/carlos",
+ "ProtectControlGroups=yes", "ProtectProc=invisible", "ProcSubset=pid", "PrivatePIDs=yes",
+ "RestrictNamespaces=yes", "NoNewPrivileges=yes", "CapabilityBoundingSet=",
+ "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128", "MemoryDenyWriteExecute=yes",
+ } {
+ if !strings.Contains(ref, want) {
+ t.Errorf("namespace block lacks %q", want)
+ }
+ }
+ for name, wants := range map[string][]string{
+ "carlos-instance@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I",
+ "BindPaths=/data/instances/%I /run/carlos/instances/%I", "StandardOutput=file:/run/carlos/logs/%I.run.sock",
+ "Slice=carlos-instances-%i.slice", "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service",
+ "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple"},
+ "carlos-instance-seed@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=120", "ExecStart=/opt/carlos/carlos instance-exec seed %I",
+ "ExecStopPost=/opt/carlos/carlos instance-exit seed %I", "StandardOutput=file:/run/carlos/logs/%I.seed.sock"},
+ "carlos-instance-sidecar@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec sidecar %I",
+ "ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I", "StandardOutput=file:/run/carlos/logs/%I.sidecar.sock"},
+ "carlos-instance-probe.service": {"Type=oneshot", "ExecStart=/opt/carlos/carlos instance-probe",
+ "BindPaths=/data/instances/_probe /run/carlos/instances/_probe", "StandardOutput=truncate:/run/carlos-control/probe.out"},
+ } {
+ u := readUnit(t, name)
+ for _, w := range wants {
+ if !strings.Contains(u, w) {
+ t.Errorf("%s lacks %q", name, w)
+ }
+ }
+ if strings.Contains(u, "ExecStopPost=+") {
+ t.Errorf("%s uses ExecStopPost=+: the plus prefix keeps the unit's tmpfs (codex R3-2)", name)
+ }
+ if name != "carlos-instance-probe.service" && !strings.Contains(u, "User=carlos") {
+ t.Errorf("%s does not run as carlos", name)
+ }
+ }
+}
+
+func TestControlUnitsShape(t *testing.T) {
+ s := readUnit(t, "carlos-instance-control.socket")
+ for _, want := range []string{"ListenStream=/run/carlos-control/control.sock", "SocketUser=carlos", "SocketGroup=carlos", "SocketMode=0600", "Accept=no", "WantedBy=sockets.target"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("socket unit lacks %q", want)
+ }
+ }
+ u := readUnit(t, "carlos-instance-control.service")
+ for _, want := range []string{"ExecStart=/opt/carlos/carlos instance-control", "Requires=carlos-instance-control.socket",
+ "data-instances.mount", "ProtectSystem=strict", "ReadWritePaths=/run/carlos-control", "RestrictAddressFamilies=AF_UNIX", "MemoryMax=64M"} {
+ if !strings.Contains(u, want) {
+ t.Errorf("control service lacks %q", want)
+ }
+ }
+ for _, never := range []string{"User=", "NoNewPrivileges"} {
+ if strings.Contains(u, never) {
+ t.Errorf("control service must run as root for systemctl; found %q", never)
+ }
+ }
+ have := map[string]bool{}
+ for _, e := range enabledUnits {
+ have[e] = true
+ }
+ if !have["carlos-instance-control.socket"] {
+ t.Error("enabledUnits lacks the control socket")
+ }
+ if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] {
+ t.Error("a template, the probe or the socket-activated service must not be enabled")
+ }
+ for _, want := range []string{"d /data/.instances/_probe 0700 carlos carlos -", "d /run/carlos/.instances/_probe 2750 carlos carlos-sockets -"} {
+ if !strings.Contains(tmpfilesConf, want+"\n") {
+ t.Errorf("tmpfilesConf lacks %q", want)
+ }
+ }
+}
+
+// The same-uid audit (design §9, codex R9-1, R9-2, R10-1): every tree a
+// carlos-uid unit writes, every carlos-owned cgroup, and every credential a
+// carlos-group process reads must be hidden from the tenant templates.
+func TestSameUIDAuditIsCoveredByTheTemplates(t *testing.T) {
+ ns := readUnit(t, "carlos-instance@.service")
+ hidden := func(path string) bool {
+ for _, line := range strings.Split(ns, "\n") {
+ for _, key := range []string{"TemporaryFileSystem=", "InaccessiblePaths="} {
+ if strings.HasPrefix(line, key) {
+ for _, p := range strings.Fields(strings.TrimPrefix(line, key)) {
+ p = strings.TrimSuffix(p, ":ro")
+ if path == p || strings.HasPrefix(path, p+"/") {
+ return true
+ }
+ }
+ }
+ }
+ }
+ return false
+ }
+ names, err := unitNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range names {
+ u := readUnit(t, name)
+ if !strings.Contains(u, "User=carlos") {
+ continue
+ }
+ for _, line := range strings.Split(u, "\n") {
+ for _, key := range []string{"ReadWritePaths=", "CacheDirectory=", "StateDirectory=", "RuntimeDirectory="} {
+ if !strings.HasPrefix(line, key) {
+ continue
+ }
+ for _, p := range strings.Fields(strings.TrimPrefix(line, key)) {
+ switch key {
+ case "CacheDirectory=":
+ p = "/var/cache/" + p
+ case "StateDirectory=":
+ p = "/var/lib/" + p
+ case "RuntimeDirectory=":
+ p = "/run/" + p
+ }
+ if !hidden(p) && !strings.HasPrefix(p, "/run/carlos/instances/") && !strings.HasPrefix(p, "/data/instances/") {
+ t.Errorf("%s writes %s, which the tenant template does not hide", name, p)
+ }
+ }
+ }
+ }
+ if strings.Contains(u, "Delegate=") && !strings.Contains(ns, "ProtectControlGroups=yes") {
+ t.Errorf("%s delegates a cgroup subtree and the template leaves cgroupfs writable", name)
+ }
+ }
+ // Credential inputs a carlos-group process reads (the second table).
+ for _, cred := range []string{"/etc/carlos/relay-masters"} {
+ if !hidden(cred) {
+ t.Errorf("credential input %s is not hidden from tenants", cred)
+ }
+ }
+}
diff --git a/main.go b/main.go
index 5b2ea85cf..5bc223afd 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,8 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/host"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instanceexec"
"github.com/carlosframework/platform/internal/ops"
"github.com/carlosframework/platform/internal/version"
"github.com/carlosframework/platform/internal/vet"
@@ -134,6 +136,14 @@ func main() {
host.RunBootstrap(args)
case "adopt":
host.RunAdopt(args)
+ case "instance-control":
+ instancectl.Run(args)
+ case "instance-exec":
+ instanceexec.RunExec(args)
+ case "instance-exit":
+ instanceexec.RunExit(args)
+ case "instance-probe":
+ instanceexec.RunProbe(args)
case "vet":
vet.Run(args)
case "update":