carlos / platform Public

units: rootfs templates and probe with the restated namespace block (rootfs apps plan 2, task 4)

Five units for an app shipped as an image: carlos-instance-rootfs@,
-rootfs-gen@, -rootfs-worker@, -rootfs-cmd@ and the rootfs probe. Each
starts from its binary sibling and carries one restated namespace block,
byte for byte, written from the plan's line-by-line table (spec §5.3):
RootImageOptions=ro,nosuid,nodev and MountAPIVFS=yes for the root itself;
a private tmpfs on /tmp for PrivateTmp=; ProtectControlGroups=strict for
=yes; TemporaryFileSystem=/dev/pts:ro (deviation 8); the /var tmpfs lines
and InaccessiblePaths= dropped because the host's /var, /etc/carlos and
/run/carlos-control are not in an image root; resolved's resolv.conf bind
replaced outside the block by root's per-instance copy. IPAddressDeny= and
MemoryDenyWriteExecute=yes stay (R11: a jit release loses it only through
Task 5's drop-in). No app or live bind: the image is the app, mounted
through root's exe layout for the unit (C3).

rootfs@ and rootfs-cmd@ carry Slice=carlos-instances-%i.slice; the gen and
worker templates key nothing by their instance string (slice, binds, image,
log socket come from the per-start drop-in); the probe sits in
carlos-instances.slice and also binds root's box-addrs list (#367; the
binary probe's bind is Task 7a's). Tests pin the shared block, the exact
diff against the binary block, the bind-source allowlist and the per-unit
lines; expectedUnits and the must-not-be-enabled list learn the five names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0cd2b414de409d209b4be5a2495a58ed80a4ba2c parent 5dee22d
8 files changed, +1460 −1
  • internal/host/host_test.go +5 −0
  • internal/host/units/carlos-instance-rootfs-cmd@.service +259 −0
  • internal/host/units/carlos-instance-rootfs-gen@.service +239 −0
  • internal/host/units/carlos-instance-rootfs-probe.service +231 −0
  • internal/host/units/carlos-instance-rootfs-worker@.service +251 −0
  • internal/host/units/carlos-instance-rootfs@.service +281 −0
  • internal/host/units_isolation_test.go +2 −1
  • internal/host/units_rootfs_test.go +192 −0
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index e1154574e..56794f00b 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -47,6 +47,11 @@ var expectedUnits = []string{
"carlos-instance-control.socket",
"carlos-instance-gen@.service",
"carlos-instance-probe.service",
+ "carlos-instance-rootfs-cmd@.service",
+ "carlos-instance-rootfs-gen@.service",
+ "carlos-instance-rootfs-probe.service",
+ "carlos-instance-rootfs-worker@.service",
+ "carlos-instance-rootfs@.service",
"carlos-instance-seed@.service",
"carlos-instance-sidecar@.service",
"carlos-instance@.service",
diff --git a/internal/host/units/carlos-instance-rootfs-cmd@.service b/internal/host/units/carlos-instance-rootfs-cmd@.service
new file mode 100644
index 000000000..02ebbd06e
--- /dev/null
+++ b/internal/host/units/carlos-instance-rootfs-cmd@.service
@@ -0,0 +1,259 @@
+# One `carlos run` command against a rootfs instance (run spec §5.4;
+# rootfs apps design §5.5): carlos-instance-cmd@.service for an app
+# shipped as an image. It is the rootfs instance template outside its
+# named lines: the same uid, namespace, image, bind mounts, slice and
+# working directory as the instance, so the command has exactly the
+# instance's reach. The agent starts it through the control service's
+# POST /cmd and follows it with GET /cmd/<host>; the agent, not this file,
+# enforces the run's own timeout.
+[Unit]
+Description=carlos rootfs instance %I (cmd)
+# Stop-only coupling, as for the instance: when the edge stops, the
+# command stops, and only the agent starts one. A run picked before an
+# agent restart is never run again (run spec §5.2).
+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
+# The result stays loaded, active (exited) or failed, until the agent has
+# read it and stopped the unit (codex 4).
+# A backstop past the run cap of 6 hours; the agent stops the unit at the
+# run's own timeout.
+TimeoutStartSec=6h5m
+# --rootfs before the role, in ExecStart= and ExecStopPost= alike, as in
+# carlos-instance-rootfs@.service.
+ExecStart=/opt/carlos/carlos instance-exec --rootfs cmd %I
+# The advisory exit record, as for the other templates. Not "+" (codex R3-2).
+ExecStopPost=/opt/carlos/carlos instance-exit --rootfs cmd %I
+User=carlos
+Group=carlos
+Slice=carlos-instances-%i.slice
+# SIGTERM to the command, SIGKILL to anything left 20 s later.
+KillMode=mixed
+TimeoutStopSec=20
+# No SuccessExitStatus=: the agent reads the exit status itself.
+# Two listeners, so stdout and stderr stay apart (run spec §5.5). PID 1
+# opens both as root, outside the namespace, on the nosymfollow logs mount.
+StandardOutput=file:/run/carlos/logs/%I.cmd.sock
+StandardError=file:/run/carlos/logs/%I.cmd-err.sock
+# The instance's own two directories, the only writable data paths, bound
+# into the image root at the mount points ship adds (rootfs apps design
+# §5.1).
+BindPaths=/data/instances/%I /run/carlos/instances/%I
+ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
+# The working directory is the instance's own data directory, the one its
+# database is in (discussion #256 item D): a command with no working_dir
+# in its manifest starts where the instance starts, and the trampoline
+# chdirs to the manifest's working_dir when there is one (rootfs apps
+# design §5.2). systemd applies it after the namespace is set up, so the
+# bind above is what it chdirs into. Same value as the instance template:
+# a command sees the files the instance made from the same place.
+WorkingDirectory=/data/instances/%I
+# The image is the app (#214 item 2): no app and no live bind, and nothing
+# from the version store. The command's root is the image the run was
+# admitted against, through root's own cmd link under the exe layout, not
+# the run link the instance and its workers mount (merge ruling M4): an
+# instance starting on another image around a swap can never repoint the
+# source this unit is about to mount, nor the reverse. The control service
+# writes it before it starts the unit.
+RootImage=/run/carlos-control/exe/%I/rootfs/cmd/image
+# The command's own root-written launch file and resolver copy, beside its
+# image link, read-only (C4; probe launch-root-owned, dns). The control
+# service writes all three sources before it starts the unit.
+BindReadOnlyPaths=/run/carlos-control/exe/%I/rootfs/cmd/launch.json:/opt/carlos/launch.json /run/carlos-control/exe/%I/rootfs/cmd/resolv.conf:/etc/resolv.conf
+
+# --- the rootfs namespace (restated from carlos-instance@.service for an image root; rootfs apps design §5.3) ---
+# Every line of the binary block (carlos-instance@.service) is restated
+# here for a root that is the instance's image, not the host: a line that
+# still binds is kept with its comment; a line that would be a no-op, or
+# would fail on a path the image lacks, is replaced by one that proves the
+# same property, and the rootfs probe (instance-probe --rootfs) proves
+# each replacement from inside. The five rootfs units carry this block
+# byte for byte (TestRootfsTemplatesShareOneNamespace), so what the probe
+# proves is what a tenant gets.
+#
+# New, with no binary line to stand in for: the root itself. The image is
+# mounted read-only, no set-id bit in it is honoured and no device node in
+# it is usable, and /proc, /sys, /dev and /run are fresh API mounts of
+# this namespace, not the image's directories. RootImage= itself sits
+# outside the block because it is per instance (a drop-in for the
+# generation and worker templates). The probe's root-image check: the root
+# is a squashfs on a loop device carrying the probe marker.
+RootImageOptions=ro,nosuid,nodev
+MountAPIVFS=yes
+# Kept: on an image root this makes the root and the image's /etc
+# read-only (squashfs already is); /proc, /dev and /run stay
+# writable-implicit. The probe's root-read-only check: a create at / and
+# at /etc answers EROFS.
+ProtectSystem=strict
+# Kept: the image's /data mount point (ship adds it) becomes an empty
+# read-only tmpfs, and the bind outside this block reveals exactly one
+# directory in it (probe data-tree).
+TemporaryFileSystem=/data:ro
+# Kept: /run is a fresh tmpfs under MountAPIVFS=, so this is created,
+# never missing (probe run-tree).
+TemporaryFileSystem=/run/carlos:ro
+# Dropped: TemporaryFileSystem=/var/cache:ro and /var/lib:ro. The binary
+# block hides those HOST trees because other carlos-uid units write them
+# (codex R9-2); the host's /var is not in an image root at all, and the
+# image's own /var/lib is the app's (Debian's /var/lib/gems). What stands
+# in for them is the root being the image, proven by the probe's
+# root-image check, and its mounts-allowlisted check: no mount in the
+# namespace comes from the host's /var, /opt/carlos/versions,
+# /opt/carlos/live or another instance's directory.
+#
+# Kept: the version store holds every app's build on the box, world-
+# readable so a unit can exec its own (#214 item 2). A rootfs instance
+# sees none of it, and nothing of its image's own /opt/carlos either (ship
+# adds the mount point): an empty /opt/carlos with the platform binary
+# that ExecStart= and ExecStopPost= run put back, and, outside this block
+# because it is per instance, root's launch file at
+# /opt/carlos/launch.json. The probe's opt-own-image check lists exactly
+# what this line and the per-unit binds put back.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Dropped: InaccessiblePaths=/etc/carlos /run/carlos-control. The binary
+# block hides root-written credentials a carlos-GROUP process may read
+# (/etc/carlos/relay-masters is root:carlos 0640, codex R10-1) and the
+# root control service's socket directory (isolated-exec design §5.2).
+# Neither path exists in an image root; systemd fails a unit on a missing
+# InaccessiblePaths= target, and "-" would make the line a silent no-op.
+# The probe's etc-carlos and control-unreachable checks: both answer
+# ENOENT from inside.
+#
+# Kept: hides the image's /home, /root and /run/user; systemd ignores a
+# missing one. A unit-file pin only: the property is the image's, not the
+# host's.
+ProtectHome=yes
+# Replaces PrivateTmp=yes, which would bind host directories under
+# /var/tmp/systemd-private-* and need /var/tmp in the image. A private
+# tmpfs instead (rootfs apps design §5.3): empty, writable, charged to
+# this cgroup, and nothing of the host's /tmp in it. The probe's
+# tmp-private check: empty, writable, and the host's fixture
+# /tmp/carlos-probe absent.
+TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev
+# Kept: a private /dev on the image's mount point (ship adds it). Probe
+# shm-private and the mount allowlist.
+PrivateDevices=yes
+# Kept: PrivateDevices= bind-mounts the HOST's /dev/shm into the private
+# /dev, and every instance is uid carlos, so a sibling's shared memory
+# would be one open away. Each unit gets its own, charged to its own
+# cgroup (probe shm-private).
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# Kept: POSIX message queues and SysV IPC belong to an IPC namespace, and
+# every instance is uid carlos, so a sibling's queue would be one mq_open
+# away (#178). PrivateIPC= gives each unit its own namespace, and
+# mq_open() goes through that namespace's own internal mount.
+# PrivateDevices= carries the host's /dev/mqueue into the private /dev the
+# way it carries /dev/shm, and that mount would still list and open the
+# host namespace's queues, so it is replaced the same way (probe
+# ipc-private covers both).
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# New (Plan 2 deviation 8; Plan 6 adds it to the binary block):
+# PrivateDevices= carries the host's /dev/pts and /dev/ptmx into the
+# private /dev the same way, and a tenant's terminal is what Plan 6 makes
+# of them. Hidden here from the start, so Plan 6 never edits a security
+# block it did not review. The probe's pts-private check: /dev/pts lists
+# nothing and open(/dev/ptmx) fails.
+TemporaryFileSystem=/dev/pts:ro
+# Replaces ProtectControlGroups=yes. The binary block mounts /sys/fs/cgroup
+# read-only so the edge's delegated subtree, carlos-owned, is not one
+# write away for a same-uid tenant (codex R9-1); with a fresh API /sys,
+# whether cgroup2 is mounted read-only under it is not something the
+# binary probe's check (a write to the edge's delegated cgroup.procs) can
+# see from an image. strict (systemd 257) is a cgroup namespace with a
+# private read-only /sys/fs/cgroup showing only this unit's own subtree.
+# The probe's cgroup-strict check: /proc/self/cgroup is 0::/, the mount
+# carries ro, access(W_OK) on its own cgroup.procs answers EROFS, and the
+# listing names no carlos.slice or system.slice; a systemd that ignores
+# the value leaves the listing populated and fails the line.
+ProtectControlGroups=strict
+# Kept: these apply to the fresh /proc (probe pid1).
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+# Kept, unchanged meaning; a unit-file pin. NoNewPrivileges= also voids
+# any set-id file in the image.
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+# Kept, unchanged; a unit-file pin.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+#
+# Kept byte for byte (the test pins it to the binary block's). The box's
+# own addresses are not in this list (#367): the control service denies
+# them on carlos-instances.slice, whose list every unit below it is merged
+# with, and the probe's box-addr check finds them closed from inside.
+IPAddressDeny=localhost multicast 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10
+# Replaced, outside this block because it is per instance:
+# BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf.
+# Its "-" skips a box without resolved, and inside an image that would
+# leave the image's own (empty) resolv.conf in place: no DNS. Each rootfs
+# unit binds root's copy of exactly the file the control service opened on
+# the slice (instancectl resolverFile) over /etc/resolv.conf instead. The
+# reason the binary line never binds resolved's stub still holds: an allow
+# opens an address both ways and on every port, so a loopback resolver can
+# never be opened; a tenant could bind systemd-resolved's 127.0.0.53
+# itself and reach every listener bound to all addresses through it.
+# Where resolved runs, instances resolve through its upstreams, and the
+# control service opens those. The probe's dns and resolver-ports checks
+# read /etc/resolv.conf from inside and compare it with the allow.
+#
+# Kept, unchanged; unit-file pins.
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the image is nosuid by RootImageOptions=, the tenant trees are
+# nosuid, and /tmp is a nosuid tmpfs of this unit's own).
+#
+# Kept in the block for every template, the probe included (R11, rootfs
+# apps design §5.11). A release whose root-staged manifest declares jit
+# runs without it through a per-start drop-in that root writes and checks
+# the effective property of (C10), never through a unit file. The probe's
+# mdwe check: an mmap(PROT_WRITE|PROT_EXEC) answers EPERM inside the
+# probe, which never gets the drop-in.
+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). A worker is
+# a sibling service with its own family rule (isolated-exec design §4.3;
+# rootfs apps design §5.4).
+OOMPolicy=kill
diff --git a/internal/host/units/carlos-instance-rootfs-gen@.service b/internal/host/units/carlos-instance-rootfs-gen@.service
new file mode 100644
index 000000000..fff3496df
--- /dev/null
+++ b/internal/host/units/carlos-instance-rootfs-gen@.service
@@ -0,0 +1,239 @@
+# One generation of a rootfs instance (zero-downtime deploys design §7.6;
+# rootfs apps design §5.3): carlos-instance-gen@.service for an app
+# shipped as an image. The instance string is "<instance-id>:<generation>"
+# (%I), and the trampoline and the exit writer split it. systemd has no
+# specifier for part of an instance string, and ':' is BindPaths='s field
+# separator, so everything keyed by the host ID alone (the slice, the data
+# mounts) or by the generation (the image, root's launch file and resolver
+# copy, the log socket) comes from a runtime drop-in the control service
+# writes before each start. Without that drop-in this unit has no
+# RootImage=, an empty /data and no launch file, so its trampoline
+# refuses, and the control service will not start it unless systemd
+# reports the drop-in's slice. carlos-instance-rootfs@.service is the
+# same unit keyed by the host alone.
+[Unit]
+Description=carlos rootfs instance generation %I
+# Stop-only coupling, as carlos-instance@.service (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
+# --rootfs before the role, in ExecStart= and ExecStopPost= alike, as in
+# carlos-instance-rootfs@.service.
+ExecStart=/opt/carlos/carlos instance-exec --rootfs run %I
+# The tenant runs in its own data directory (#256 D). %I here is
+# <id>:<n>, not the host's id, so the real directory comes from the
+# generation's runtime drop-in, which overrides this line. This one only
+# applies if the drop-in did not, and then the start is already refused
+# (the control service checks the drop-in's slice first), and /data is an
+# empty tmpfs, so the chdir fails closed.
+WorkingDirectory=/data/instances
+ExecStopPost=/opt/carlos/carlos instance-exit --rootfs run %I
+User=carlos
+Group=carlos
+# The activator owns recycling; systemd must not race it.
+Restart=no
+KillMode=mixed
+TimeoutStopSec=20
+# The main process is the trampoline, which stays as the namespace's init
+# and runs the app as its child (#126): it forwards the SIGTERM and exits
+# 128+n when the app dies by signal n. These are SIGHUP, SIGINT, SIGPIPE
+# and SIGTERM, the deaths systemd counts as clean for a main process, so a
+# stop still leaves the unit inactive rather than failed.
+SuccessExitStatus=129 130 141 143
+StandardError=inherit
+
+# --- the rootfs namespace (restated from carlos-instance@.service for an image root; rootfs apps design §5.3) ---
+# Every line of the binary block (carlos-instance@.service) is restated
+# here for a root that is the instance's image, not the host: a line that
+# still binds is kept with its comment; a line that would be a no-op, or
+# would fail on a path the image lacks, is replaced by one that proves the
+# same property, and the rootfs probe (instance-probe --rootfs) proves
+# each replacement from inside. The five rootfs units carry this block
+# byte for byte (TestRootfsTemplatesShareOneNamespace), so what the probe
+# proves is what a tenant gets.
+#
+# New, with no binary line to stand in for: the root itself. The image is
+# mounted read-only, no set-id bit in it is honoured and no device node in
+# it is usable, and /proc, /sys, /dev and /run are fresh API mounts of
+# this namespace, not the image's directories. RootImage= itself sits
+# outside the block because it is per instance (a drop-in for the
+# generation and worker templates). The probe's root-image check: the root
+# is a squashfs on a loop device carrying the probe marker.
+RootImageOptions=ro,nosuid,nodev
+MountAPIVFS=yes
+# Kept: on an image root this makes the root and the image's /etc
+# read-only (squashfs already is); /proc, /dev and /run stay
+# writable-implicit. The probe's root-read-only check: a create at / and
+# at /etc answers EROFS.
+ProtectSystem=strict
+# Kept: the image's /data mount point (ship adds it) becomes an empty
+# read-only tmpfs, and the bind outside this block reveals exactly one
+# directory in it (probe data-tree).
+TemporaryFileSystem=/data:ro
+# Kept: /run is a fresh tmpfs under MountAPIVFS=, so this is created,
+# never missing (probe run-tree).
+TemporaryFileSystem=/run/carlos:ro
+# Dropped: TemporaryFileSystem=/var/cache:ro and /var/lib:ro. The binary
+# block hides those HOST trees because other carlos-uid units write them
+# (codex R9-2); the host's /var is not in an image root at all, and the
+# image's own /var/lib is the app's (Debian's /var/lib/gems). What stands
+# in for them is the root being the image, proven by the probe's
+# root-image check, and its mounts-allowlisted check: no mount in the
+# namespace comes from the host's /var, /opt/carlos/versions,
+# /opt/carlos/live or another instance's directory.
+#
+# Kept: the version store holds every app's build on the box, world-
+# readable so a unit can exec its own (#214 item 2). A rootfs instance
+# sees none of it, and nothing of its image's own /opt/carlos either (ship
+# adds the mount point): an empty /opt/carlos with the platform binary
+# that ExecStart= and ExecStopPost= run put back, and, outside this block
+# because it is per instance, root's launch file at
+# /opt/carlos/launch.json. The probe's opt-own-image check lists exactly
+# what this line and the per-unit binds put back.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Dropped: InaccessiblePaths=/etc/carlos /run/carlos-control. The binary
+# block hides root-written credentials a carlos-GROUP process may read
+# (/etc/carlos/relay-masters is root:carlos 0640, codex R10-1) and the
+# root control service's socket directory (isolated-exec design §5.2).
+# Neither path exists in an image root; systemd fails a unit on a missing
+# InaccessiblePaths= target, and "-" would make the line a silent no-op.
+# The probe's etc-carlos and control-unreachable checks: both answer
+# ENOENT from inside.
+#
+# Kept: hides the image's /home, /root and /run/user; systemd ignores a
+# missing one. A unit-file pin only: the property is the image's, not the
+# host's.
+ProtectHome=yes
+# Replaces PrivateTmp=yes, which would bind host directories under
+# /var/tmp/systemd-private-* and need /var/tmp in the image. A private
+# tmpfs instead (rootfs apps design §5.3): empty, writable, charged to
+# this cgroup, and nothing of the host's /tmp in it. The probe's
+# tmp-private check: empty, writable, and the host's fixture
+# /tmp/carlos-probe absent.
+TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev
+# Kept: a private /dev on the image's mount point (ship adds it). Probe
+# shm-private and the mount allowlist.
+PrivateDevices=yes
+# Kept: PrivateDevices= bind-mounts the HOST's /dev/shm into the private
+# /dev, and every instance is uid carlos, so a sibling's shared memory
+# would be one open away. Each unit gets its own, charged to its own
+# cgroup (probe shm-private).
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# Kept: POSIX message queues and SysV IPC belong to an IPC namespace, and
+# every instance is uid carlos, so a sibling's queue would be one mq_open
+# away (#178). PrivateIPC= gives each unit its own namespace, and
+# mq_open() goes through that namespace's own internal mount.
+# PrivateDevices= carries the host's /dev/mqueue into the private /dev the
+# way it carries /dev/shm, and that mount would still list and open the
+# host namespace's queues, so it is replaced the same way (probe
+# ipc-private covers both).
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# New (Plan 2 deviation 8; Plan 6 adds it to the binary block):
+# PrivateDevices= carries the host's /dev/pts and /dev/ptmx into the
+# private /dev the same way, and a tenant's terminal is what Plan 6 makes
+# of them. Hidden here from the start, so Plan 6 never edits a security
+# block it did not review. The probe's pts-private check: /dev/pts lists
+# nothing and open(/dev/ptmx) fails.
+TemporaryFileSystem=/dev/pts:ro
+# Replaces ProtectControlGroups=yes. The binary block mounts /sys/fs/cgroup
+# read-only so the edge's delegated subtree, carlos-owned, is not one
+# write away for a same-uid tenant (codex R9-1); with a fresh API /sys,
+# whether cgroup2 is mounted read-only under it is not something the
+# binary probe's check (a write to the edge's delegated cgroup.procs) can
+# see from an image. strict (systemd 257) is a cgroup namespace with a
+# private read-only /sys/fs/cgroup showing only this unit's own subtree.
+# The probe's cgroup-strict check: /proc/self/cgroup is 0::/, the mount
+# carries ro, access(W_OK) on its own cgroup.procs answers EROFS, and the
+# listing names no carlos.slice or system.slice; a systemd that ignores
+# the value leaves the listing populated and fails the line.
+ProtectControlGroups=strict
+# Kept: these apply to the fresh /proc (probe pid1).
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+# Kept, unchanged meaning; a unit-file pin. NoNewPrivileges= also voids
+# any set-id file in the image.
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+# Kept, unchanged; a unit-file pin.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+#
+# Kept byte for byte (the test pins it to the binary block's). The box's
+# own addresses are not in this list (#367): the control service denies
+# them on carlos-instances.slice, whose list every unit below it is merged
+# with, and the probe's box-addr check finds them closed from inside.
+IPAddressDeny=localhost multicast 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10
+# Replaced, outside this block because it is per instance:
+# BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf.
+# Its "-" skips a box without resolved, and inside an image that would
+# leave the image's own (empty) resolv.conf in place: no DNS. Each rootfs
+# unit binds root's copy of exactly the file the control service opened on
+# the slice (instancectl resolverFile) over /etc/resolv.conf instead. The
+# reason the binary line never binds resolved's stub still holds: an allow
+# opens an address both ways and on every port, so a loopback resolver can
+# never be opened; a tenant could bind systemd-resolved's 127.0.0.53
+# itself and reach every listener bound to all addresses through it.
+# Where resolved runs, instances resolve through its upstreams, and the
+# control service opens those. The probe's dns and resolver-ports checks
+# read /etc/resolv.conf from inside and compare it with the allow.
+#
+# Kept, unchanged; unit-file pins.
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the image is nosuid by RootImageOptions=, the tenant trees are
+# nosuid, and /tmp is a nosuid tmpfs of this unit's own).
+#
+# Kept in the block for every template, the probe included (R11, rootfs
+# apps design §5.11). A release whose root-staged manifest declares jit
+# runs without it through a per-start drop-in that root writes and checks
+# the effective property of (C10), never through a unit file. The probe's
+# mdwe check: an mmap(PROT_WRITE|PROT_EXEC) answers EPERM inside the
+# probe, which never gets the drop-in.
+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). A worker is
+# a sibling service with its own family rule (isolated-exec design §4.3;
+# rootfs apps design §5.4).
+OOMPolicy=kill
diff --git a/internal/host/units/carlos-instance-rootfs-probe.service b/internal/host/units/carlos-instance-rootfs-probe.service
new file mode 100644
index 000000000..fe4eb2f09
--- /dev/null
+++ b/internal/host/units/carlos-instance-rootfs-probe.service
@@ -0,0 +1,231 @@
+# The rootfs readiness probe (rootfs apps design §5.3, §8; isolated-exec
+# design §8.5): a oneshot under the SAME restated namespace text as the
+# rootfs templates (the test pins the block byte for byte), run by
+# carlos-instance-control at its start and every five minutes, with a
+# probe image for its root so that it sees the image root a rootfs tenant
+# gets. From inside it asserts what such a tenant would see and prints a
+# verdict; the unit's StandardOutput=truncate: lands it where the control
+# service reads it, and a box reports rootfs readiness only after this
+# probe passes. 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. The binary probe
+# (carlos-instance-probe.service) stays as it is.
+[Unit]
+Description=carlos rootfs 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 --rootfs
+User=carlos
+Group=carlos
+Slice=carlos-instances.slice
+StandardOutput=truncate:/run/carlos-control/rootfs-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 probe image, through root's link under the exe layout for the name
+# _probe, exactly as a tenant's image is reached (#214 item 2; the image
+# is the app). The control service writes it before every probe run.
+RootImage=/run/carlos-control/exe/_probe/rootfs/run/image
+# The same two root-written files a tenant gets, for the name _probe, and
+# one more: the box's own addresses, which the probe must find closed
+# (box-addr; the deny lives on carlos-instances.slice, #367). The control
+# service writes all three sources before every probe run.
+BindReadOnlyPaths=/run/carlos-control/exe/_probe/rootfs/run/launch.json:/opt/carlos/launch.json /run/carlos-control/exe/_probe/rootfs/run/resolv.conf:/etc/resolv.conf /run/carlos-control/exe/_probe/rootfs/run/box-addrs:/opt/carlos/box-addrs
+
+# --- the rootfs namespace (restated from carlos-instance@.service for an image root; rootfs apps design §5.3) ---
+# Every line of the binary block (carlos-instance@.service) is restated
+# here for a root that is the instance's image, not the host: a line that
+# still binds is kept with its comment; a line that would be a no-op, or
+# would fail on a path the image lacks, is replaced by one that proves the
+# same property, and the rootfs probe (instance-probe --rootfs) proves
+# each replacement from inside. The five rootfs units carry this block
+# byte for byte (TestRootfsTemplatesShareOneNamespace), so what the probe
+# proves is what a tenant gets.
+#
+# New, with no binary line to stand in for: the root itself. The image is
+# mounted read-only, no set-id bit in it is honoured and no device node in
+# it is usable, and /proc, /sys, /dev and /run are fresh API mounts of
+# this namespace, not the image's directories. RootImage= itself sits
+# outside the block because it is per instance (a drop-in for the
+# generation and worker templates). The probe's root-image check: the root
+# is a squashfs on a loop device carrying the probe marker.
+RootImageOptions=ro,nosuid,nodev
+MountAPIVFS=yes
+# Kept: on an image root this makes the root and the image's /etc
+# read-only (squashfs already is); /proc, /dev and /run stay
+# writable-implicit. The probe's root-read-only check: a create at / and
+# at /etc answers EROFS.
+ProtectSystem=strict
+# Kept: the image's /data mount point (ship adds it) becomes an empty
+# read-only tmpfs, and the bind outside this block reveals exactly one
+# directory in it (probe data-tree).
+TemporaryFileSystem=/data:ro
+# Kept: /run is a fresh tmpfs under MountAPIVFS=, so this is created,
+# never missing (probe run-tree).
+TemporaryFileSystem=/run/carlos:ro
+# Dropped: TemporaryFileSystem=/var/cache:ro and /var/lib:ro. The binary
+# block hides those HOST trees because other carlos-uid units write them
+# (codex R9-2); the host's /var is not in an image root at all, and the
+# image's own /var/lib is the app's (Debian's /var/lib/gems). What stands
+# in for them is the root being the image, proven by the probe's
+# root-image check, and its mounts-allowlisted check: no mount in the
+# namespace comes from the host's /var, /opt/carlos/versions,
+# /opt/carlos/live or another instance's directory.
+#
+# Kept: the version store holds every app's build on the box, world-
+# readable so a unit can exec its own (#214 item 2). A rootfs instance
+# sees none of it, and nothing of its image's own /opt/carlos either (ship
+# adds the mount point): an empty /opt/carlos with the platform binary
+# that ExecStart= and ExecStopPost= run put back, and, outside this block
+# because it is per instance, root's launch file at
+# /opt/carlos/launch.json. The probe's opt-own-image check lists exactly
+# what this line and the per-unit binds put back.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Dropped: InaccessiblePaths=/etc/carlos /run/carlos-control. The binary
+# block hides root-written credentials a carlos-GROUP process may read
+# (/etc/carlos/relay-masters is root:carlos 0640, codex R10-1) and the
+# root control service's socket directory (isolated-exec design §5.2).
+# Neither path exists in an image root; systemd fails a unit on a missing
+# InaccessiblePaths= target, and "-" would make the line a silent no-op.
+# The probe's etc-carlos and control-unreachable checks: both answer
+# ENOENT from inside.
+#
+# Kept: hides the image's /home, /root and /run/user; systemd ignores a
+# missing one. A unit-file pin only: the property is the image's, not the
+# host's.
+ProtectHome=yes
+# Replaces PrivateTmp=yes, which would bind host directories under
+# /var/tmp/systemd-private-* and need /var/tmp in the image. A private
+# tmpfs instead (rootfs apps design §5.3): empty, writable, charged to
+# this cgroup, and nothing of the host's /tmp in it. The probe's
+# tmp-private check: empty, writable, and the host's fixture
+# /tmp/carlos-probe absent.
+TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev
+# Kept: a private /dev on the image's mount point (ship adds it). Probe
+# shm-private and the mount allowlist.
+PrivateDevices=yes
+# Kept: PrivateDevices= bind-mounts the HOST's /dev/shm into the private
+# /dev, and every instance is uid carlos, so a sibling's shared memory
+# would be one open away. Each unit gets its own, charged to its own
+# cgroup (probe shm-private).
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# Kept: POSIX message queues and SysV IPC belong to an IPC namespace, and
+# every instance is uid carlos, so a sibling's queue would be one mq_open
+# away (#178). PrivateIPC= gives each unit its own namespace, and
+# mq_open() goes through that namespace's own internal mount.
+# PrivateDevices= carries the host's /dev/mqueue into the private /dev the
+# way it carries /dev/shm, and that mount would still list and open the
+# host namespace's queues, so it is replaced the same way (probe
+# ipc-private covers both).
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# New (Plan 2 deviation 8; Plan 6 adds it to the binary block):
+# PrivateDevices= carries the host's /dev/pts and /dev/ptmx into the
+# private /dev the same way, and a tenant's terminal is what Plan 6 makes
+# of them. Hidden here from the start, so Plan 6 never edits a security
+# block it did not review. The probe's pts-private check: /dev/pts lists
+# nothing and open(/dev/ptmx) fails.
+TemporaryFileSystem=/dev/pts:ro
+# Replaces ProtectControlGroups=yes. The binary block mounts /sys/fs/cgroup
+# read-only so the edge's delegated subtree, carlos-owned, is not one
+# write away for a same-uid tenant (codex R9-1); with a fresh API /sys,
+# whether cgroup2 is mounted read-only under it is not something the
+# binary probe's check (a write to the edge's delegated cgroup.procs) can
+# see from an image. strict (systemd 257) is a cgroup namespace with a
+# private read-only /sys/fs/cgroup showing only this unit's own subtree.
+# The probe's cgroup-strict check: /proc/self/cgroup is 0::/, the mount
+# carries ro, access(W_OK) on its own cgroup.procs answers EROFS, and the
+# listing names no carlos.slice or system.slice; a systemd that ignores
+# the value leaves the listing populated and fails the line.
+ProtectControlGroups=strict
+# Kept: these apply to the fresh /proc (probe pid1).
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+# Kept, unchanged meaning; a unit-file pin. NoNewPrivileges= also voids
+# any set-id file in the image.
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+# Kept, unchanged; a unit-file pin.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+#
+# Kept byte for byte (the test pins it to the binary block's). The box's
+# own addresses are not in this list (#367): the control service denies
+# them on carlos-instances.slice, whose list every unit below it is merged
+# with, and the probe's box-addr check finds them closed from inside.
+IPAddressDeny=localhost multicast 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10
+# Replaced, outside this block because it is per instance:
+# BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf.
+# Its "-" skips a box without resolved, and inside an image that would
+# leave the image's own (empty) resolv.conf in place: no DNS. Each rootfs
+# unit binds root's copy of exactly the file the control service opened on
+# the slice (instancectl resolverFile) over /etc/resolv.conf instead. The
+# reason the binary line never binds resolved's stub still holds: an allow
+# opens an address both ways and on every port, so a loopback resolver can
+# never be opened; a tenant could bind systemd-resolved's 127.0.0.53
+# itself and reach every listener bound to all addresses through it.
+# Where resolved runs, instances resolve through its upstreams, and the
+# control service opens those. The probe's dns and resolver-ports checks
+# read /etc/resolv.conf from inside and compare it with the allow.
+#
+# Kept, unchanged; unit-file pins.
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the image is nosuid by RootImageOptions=, the tenant trees are
+# nosuid, and /tmp is a nosuid tmpfs of this unit's own).
+#
+# Kept in the block for every template, the probe included (R11, rootfs
+# apps design §5.11). A release whose root-staged manifest declares jit
+# runs without it through a per-start drop-in that root writes and checks
+# the effective property of (C10), never through a unit file. The probe's
+# mdwe check: an mmap(PROT_WRITE|PROT_EXEC) answers EPERM inside the
+# probe, which never gets the drop-in.
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=64
+MemoryMax=64M
+# Read back by the probe from its own cgroup (#214 item 1): proof that CPU
+# and IO settings take under carlos-instances.slice, where the control
+# service sets each host slice's from its tier.
+CPUQuota=50%
+CPUWeight=50
+IOWeight=50
diff --git a/internal/host/units/carlos-instance-rootfs-worker@.service b/internal/host/units/carlos-instance-rootfs-worker@.service
new file mode 100644
index 000000000..14094b19f
--- /dev/null
+++ b/internal/host/units/carlos-instance-rootfs-worker@.service
@@ -0,0 +1,251 @@
+# One declared worker process of a rootfs release (rootfs apps design
+# §5.4): a sibling of the instance in the same slice, running from the
+# same image with the same uid, namespace and reach, the way a sidecar
+# does for a binary instance. The instance string is
+# "<instance-id>:<worker>" (%I), and the trampoline and the exit writer
+# split it. As for a generation, systemd has no specifier for part of an
+# instance string and ':' is BindPaths='s field separator, so everything
+# keyed by the host ID alone (the slice, the data mounts) or by the worker
+# (the image, root's launch file and resolver copy, the log socket) comes
+# from a runtime drop-in the control service writes before each start.
+# Without that drop-in this unit has no RootImage=, an empty /data and no
+# launch file, so its trampoline refuses, and the control service will not
+# start it unless systemd reports the drop-in's slice.
+[Unit]
+Description=carlos rootfs worker %I
+# Stop-only coupling: when the edge stops (including the stop half of a
+# restart) a worker 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
+# --rootfs before the role, in ExecStart= and ExecStopPost= alike: worker
+# is a role the trampoline and instance-exit take only with it (there is
+# no binary worker unit).
+ExecStart=/opt/carlos/carlos instance-exec --rootfs worker %I
+# The worker runs in the instance's own data directory, the one the
+# instance's database is in (#256 D). %I here is <id>:<worker>, not the
+# host's id, so the real directory comes from the worker's runtime
+# drop-in, which overrides this line. This one only applies if the
+# drop-in did not, and then the start is already refused (the control
+# service checks the drop-in's slice first), and /data is an empty tmpfs,
+# so the chdir fails closed.
+WorkingDirectory=/data/instances
+# 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 (isolated-exec design §6.5). Not "+"
+# (codex R3-2).
+ExecStopPost=/opt/carlos/carlos instance-exit --rootfs worker %I
+User=carlos
+Group=carlos
+# The activator owns recycling: a crash stands the worker down and the
+# instance's next wake starts it again. systemd must not race that.
+Restart=no
+# SIGTERM to the worker, SIGKILL to anything it left behind after
+# TimeoutStopSec; the activator's stop budget is 20s.
+KillMode=mixed
+TimeoutStopSec=20
+# The main process is the trampoline, which stays as the namespace's init
+# and runs the worker as its child (#126): it forwards the SIGTERM and
+# exits 128+n when the worker dies by signal n. These are SIGHUP, SIGINT,
+# SIGPIPE and SIGTERM, the deaths systemd counts as clean for a main
+# process, so a stop still leaves the unit inactive rather than failed.
+SuccessExitStatus=129 130 141 143
+StandardError=inherit
+
+# --- the rootfs namespace (restated from carlos-instance@.service for an image root; rootfs apps design §5.3) ---
+# Every line of the binary block (carlos-instance@.service) is restated
+# here for a root that is the instance's image, not the host: a line that
+# still binds is kept with its comment; a line that would be a no-op, or
+# would fail on a path the image lacks, is replaced by one that proves the
+# same property, and the rootfs probe (instance-probe --rootfs) proves
+# each replacement from inside. The five rootfs units carry this block
+# byte for byte (TestRootfsTemplatesShareOneNamespace), so what the probe
+# proves is what a tenant gets.
+#
+# New, with no binary line to stand in for: the root itself. The image is
+# mounted read-only, no set-id bit in it is honoured and no device node in
+# it is usable, and /proc, /sys, /dev and /run are fresh API mounts of
+# this namespace, not the image's directories. RootImage= itself sits
+# outside the block because it is per instance (a drop-in for the
+# generation and worker templates). The probe's root-image check: the root
+# is a squashfs on a loop device carrying the probe marker.
+RootImageOptions=ro,nosuid,nodev
+MountAPIVFS=yes
+# Kept: on an image root this makes the root and the image's /etc
+# read-only (squashfs already is); /proc, /dev and /run stay
+# writable-implicit. The probe's root-read-only check: a create at / and
+# at /etc answers EROFS.
+ProtectSystem=strict
+# Kept: the image's /data mount point (ship adds it) becomes an empty
+# read-only tmpfs, and the bind outside this block reveals exactly one
+# directory in it (probe data-tree).
+TemporaryFileSystem=/data:ro
+# Kept: /run is a fresh tmpfs under MountAPIVFS=, so this is created,
+# never missing (probe run-tree).
+TemporaryFileSystem=/run/carlos:ro
+# Dropped: TemporaryFileSystem=/var/cache:ro and /var/lib:ro. The binary
+# block hides those HOST trees because other carlos-uid units write them
+# (codex R9-2); the host's /var is not in an image root at all, and the
+# image's own /var/lib is the app's (Debian's /var/lib/gems). What stands
+# in for them is the root being the image, proven by the probe's
+# root-image check, and its mounts-allowlisted check: no mount in the
+# namespace comes from the host's /var, /opt/carlos/versions,
+# /opt/carlos/live or another instance's directory.
+#
+# Kept: the version store holds every app's build on the box, world-
+# readable so a unit can exec its own (#214 item 2). A rootfs instance
+# sees none of it, and nothing of its image's own /opt/carlos either (ship
+# adds the mount point): an empty /opt/carlos with the platform binary
+# that ExecStart= and ExecStopPost= run put back, and, outside this block
+# because it is per instance, root's launch file at
+# /opt/carlos/launch.json. The probe's opt-own-image check lists exactly
+# what this line and the per-unit binds put back.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Dropped: InaccessiblePaths=/etc/carlos /run/carlos-control. The binary
+# block hides root-written credentials a carlos-GROUP process may read
+# (/etc/carlos/relay-masters is root:carlos 0640, codex R10-1) and the
+# root control service's socket directory (isolated-exec design §5.2).
+# Neither path exists in an image root; systemd fails a unit on a missing
+# InaccessiblePaths= target, and "-" would make the line a silent no-op.
+# The probe's etc-carlos and control-unreachable checks: both answer
+# ENOENT from inside.
+#
+# Kept: hides the image's /home, /root and /run/user; systemd ignores a
+# missing one. A unit-file pin only: the property is the image's, not the
+# host's.
+ProtectHome=yes
+# Replaces PrivateTmp=yes, which would bind host directories under
+# /var/tmp/systemd-private-* and need /var/tmp in the image. A private
+# tmpfs instead (rootfs apps design §5.3): empty, writable, charged to
+# this cgroup, and nothing of the host's /tmp in it. The probe's
+# tmp-private check: empty, writable, and the host's fixture
+# /tmp/carlos-probe absent.
+TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev
+# Kept: a private /dev on the image's mount point (ship adds it). Probe
+# shm-private and the mount allowlist.
+PrivateDevices=yes
+# Kept: PrivateDevices= bind-mounts the HOST's /dev/shm into the private
+# /dev, and every instance is uid carlos, so a sibling's shared memory
+# would be one open away. Each unit gets its own, charged to its own
+# cgroup (probe shm-private).
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# Kept: POSIX message queues and SysV IPC belong to an IPC namespace, and
+# every instance is uid carlos, so a sibling's queue would be one mq_open
+# away (#178). PrivateIPC= gives each unit its own namespace, and
+# mq_open() goes through that namespace's own internal mount.
+# PrivateDevices= carries the host's /dev/mqueue into the private /dev the
+# way it carries /dev/shm, and that mount would still list and open the
+# host namespace's queues, so it is replaced the same way (probe
+# ipc-private covers both).
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# New (Plan 2 deviation 8; Plan 6 adds it to the binary block):
+# PrivateDevices= carries the host's /dev/pts and /dev/ptmx into the
+# private /dev the same way, and a tenant's terminal is what Plan 6 makes
+# of them. Hidden here from the start, so Plan 6 never edits a security
+# block it did not review. The probe's pts-private check: /dev/pts lists
+# nothing and open(/dev/ptmx) fails.
+TemporaryFileSystem=/dev/pts:ro
+# Replaces ProtectControlGroups=yes. The binary block mounts /sys/fs/cgroup
+# read-only so the edge's delegated subtree, carlos-owned, is not one
+# write away for a same-uid tenant (codex R9-1); with a fresh API /sys,
+# whether cgroup2 is mounted read-only under it is not something the
+# binary probe's check (a write to the edge's delegated cgroup.procs) can
+# see from an image. strict (systemd 257) is a cgroup namespace with a
+# private read-only /sys/fs/cgroup showing only this unit's own subtree.
+# The probe's cgroup-strict check: /proc/self/cgroup is 0::/, the mount
+# carries ro, access(W_OK) on its own cgroup.procs answers EROFS, and the
+# listing names no carlos.slice or system.slice; a systemd that ignores
+# the value leaves the listing populated and fails the line.
+ProtectControlGroups=strict
+# Kept: these apply to the fresh /proc (probe pid1).
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+# Kept, unchanged meaning; a unit-file pin. NoNewPrivileges= also voids
+# any set-id file in the image.
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+# Kept, unchanged; a unit-file pin.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+#
+# Kept byte for byte (the test pins it to the binary block's). The box's
+# own addresses are not in this list (#367): the control service denies
+# them on carlos-instances.slice, whose list every unit below it is merged
+# with, and the probe's box-addr check finds them closed from inside.
+IPAddressDeny=localhost multicast 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10
+# Replaced, outside this block because it is per instance:
+# BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf.
+# Its "-" skips a box without resolved, and inside an image that would
+# leave the image's own (empty) resolv.conf in place: no DNS. Each rootfs
+# unit binds root's copy of exactly the file the control service opened on
+# the slice (instancectl resolverFile) over /etc/resolv.conf instead. The
+# reason the binary line never binds resolved's stub still holds: an allow
+# opens an address both ways and on every port, so a loopback resolver can
+# never be opened; a tenant could bind systemd-resolved's 127.0.0.53
+# itself and reach every listener bound to all addresses through it.
+# Where resolved runs, instances resolve through its upstreams, and the
+# control service opens those. The probe's dns and resolver-ports checks
+# read /etc/resolv.conf from inside and compare it with the allow.
+#
+# Kept, unchanged; unit-file pins.
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the image is nosuid by RootImageOptions=, the tenant trees are
+# nosuid, and /tmp is a nosuid tmpfs of this unit's own).
+#
+# Kept in the block for every template, the probe included (R11, rootfs
+# apps design §5.11). A release whose root-staged manifest declares jit
+# runs without it through a per-start drop-in that root writes and checks
+# the effective property of (C10), never through a unit file. The probe's
+# mdwe check: an mmap(PROT_WRITE|PROT_EXEC) answers EPERM inside the
+# probe, which never gets the drop-in.
+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). A worker is
+# a sibling service with its own family rule (isolated-exec design §4.3;
+# rootfs apps design §5.4).
+OOMPolicy=kill
diff --git a/internal/host/units/carlos-instance-rootfs@.service b/internal/host/units/carlos-instance-rootfs@.service
new file mode 100644
index 000000000..45965d43d
--- /dev/null
+++ b/internal/host/units/carlos-instance-rootfs@.service
@@ -0,0 +1,281 @@
+# One rootfs instance (rootfs apps design §5.3): carlos-instance@.service
+# for an app shipped as an image rather than a binary. The activator owns
+# it exactly as it owns a binary instance: lease, restore, seed,
+# replication, hibernation, control socket, logs. The process is a unit,
+# still uid carlos, whose root is the instance's own image (RootImage=),
+# inside a mount namespace in which nothing of the host exists but the
+# instance's own two directories, the platform binary and root's launch
+# file, 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 rootfs 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
+# --rootfs before the role, in ExecStart= and ExecStopPost= alike: the
+# trampoline takes the rootfs launch shape (root's launch file names the
+# command, the tenant's names only the environment; rootfs apps design
+# §5.2), and instance-exit takes the rootfs roles only with it.
+ExecStart=/opt/carlos/carlos instance-exec --rootfs 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 (isolated-exec design §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 --rootfs run %I
+User=carlos
+Group=carlos
+# One slice per host holds the instance, its workers and its command, and
+# carries the tier (isolated-exec design §4.3, §6.6); it is the slice a
+# binary instance of the same host would sit in. 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 main process is the trampoline, which stays as the namespace's init
+# and runs the app as its child (#126): it forwards the SIGTERM and exits
+# 128+n when the app dies by signal n. These are SIGHUP, SIGINT, SIGPIPE
+# and SIGTERM, the deaths systemd counts as clean for a main process, so a
+# stop still leaves the unit inactive rather than failed.
+SuccessExitStatus=129 130 141 143
+# The log stream: stdout and stderr connect to the agent's per-role
+# listener (isolated-exec design §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, bound
+# into the image root at the mount points ship adds (rootfs apps design
+# §5.1).
+BindPaths=/data/instances/%I /run/carlos/instances/%I
+ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
+# The working directory is the instance's own data directory, the one its
+# database is in (discussion #256 item D): a process with no working_dir
+# in its manifest starts where a binary starts, and the trampoline chdirs
+# to the manifest's working_dir when there is one (rootfs apps design
+# §5.2). systemd applies it after the namespace is set up, so the bind
+# above is what it chdirs into. Same value in the cmd template: a command
+# sees the files the instance made from the same place.
+WorkingDirectory=/data/instances/%I
+# The image is the app (#214 item 2): no app and no live bind, and nothing
+# from the version store. The root is the instance's own staged image,
+# reached through root's per-start link under the control service's exe
+# layout for this unit, which the control service writes after checking
+# the image against the registry row and the staged manifest (C2, C3).
+# PID 1 chases the link at every start, so one instance sees its own
+# image and no other.
+RootImage=/run/carlos-control/exe/%I/rootfs/run/image
+# Two root-written files, read-only, beside the image link: the launch
+# file naming the command root resolved from the staged manifest, the
+# only thing the trampoline will exec (C4; probe launch-root-owned), and
+# root's copy of exactly the resolver file the control service opened on
+# the slice, standing in for the binary block's resolved bind (probe dns,
+# resolver-ports). The control service writes all three sources before it
+# starts the unit.
+BindReadOnlyPaths=/run/carlos-control/exe/%I/rootfs/run/launch.json:/opt/carlos/launch.json /run/carlos-control/exe/%I/rootfs/run/resolv.conf:/etc/resolv.conf
+
+# --- the rootfs namespace (restated from carlos-instance@.service for an image root; rootfs apps design §5.3) ---
+# Every line of the binary block (carlos-instance@.service) is restated
+# here for a root that is the instance's image, not the host: a line that
+# still binds is kept with its comment; a line that would be a no-op, or
+# would fail on a path the image lacks, is replaced by one that proves the
+# same property, and the rootfs probe (instance-probe --rootfs) proves
+# each replacement from inside. The five rootfs units carry this block
+# byte for byte (TestRootfsTemplatesShareOneNamespace), so what the probe
+# proves is what a tenant gets.
+#
+# New, with no binary line to stand in for: the root itself. The image is
+# mounted read-only, no set-id bit in it is honoured and no device node in
+# it is usable, and /proc, /sys, /dev and /run are fresh API mounts of
+# this namespace, not the image's directories. RootImage= itself sits
+# outside the block because it is per instance (a drop-in for the
+# generation and worker templates). The probe's root-image check: the root
+# is a squashfs on a loop device carrying the probe marker.
+RootImageOptions=ro,nosuid,nodev
+MountAPIVFS=yes
+# Kept: on an image root this makes the root and the image's /etc
+# read-only (squashfs already is); /proc, /dev and /run stay
+# writable-implicit. The probe's root-read-only check: a create at / and
+# at /etc answers EROFS.
+ProtectSystem=strict
+# Kept: the image's /data mount point (ship adds it) becomes an empty
+# read-only tmpfs, and the bind outside this block reveals exactly one
+# directory in it (probe data-tree).
+TemporaryFileSystem=/data:ro
+# Kept: /run is a fresh tmpfs under MountAPIVFS=, so this is created,
+# never missing (probe run-tree).
+TemporaryFileSystem=/run/carlos:ro
+# Dropped: TemporaryFileSystem=/var/cache:ro and /var/lib:ro. The binary
+# block hides those HOST trees because other carlos-uid units write them
+# (codex R9-2); the host's /var is not in an image root at all, and the
+# image's own /var/lib is the app's (Debian's /var/lib/gems). What stands
+# in for them is the root being the image, proven by the probe's
+# root-image check, and its mounts-allowlisted check: no mount in the
+# namespace comes from the host's /var, /opt/carlos/versions,
+# /opt/carlos/live or another instance's directory.
+#
+# Kept: the version store holds every app's build on the box, world-
+# readable so a unit can exec its own (#214 item 2). A rootfs instance
+# sees none of it, and nothing of its image's own /opt/carlos either (ship
+# adds the mount point): an empty /opt/carlos with the platform binary
+# that ExecStart= and ExecStopPost= run put back, and, outside this block
+# because it is per instance, root's launch file at
+# /opt/carlos/launch.json. The probe's opt-own-image check lists exactly
+# what this line and the per-unit binds put back.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Dropped: InaccessiblePaths=/etc/carlos /run/carlos-control. The binary
+# block hides root-written credentials a carlos-GROUP process may read
+# (/etc/carlos/relay-masters is root:carlos 0640, codex R10-1) and the
+# root control service's socket directory (isolated-exec design §5.2).
+# Neither path exists in an image root; systemd fails a unit on a missing
+# InaccessiblePaths= target, and "-" would make the line a silent no-op.
+# The probe's etc-carlos and control-unreachable checks: both answer
+# ENOENT from inside.
+#
+# Kept: hides the image's /home, /root and /run/user; systemd ignores a
+# missing one. A unit-file pin only: the property is the image's, not the
+# host's.
+ProtectHome=yes
+# Replaces PrivateTmp=yes, which would bind host directories under
+# /var/tmp/systemd-private-* and need /var/tmp in the image. A private
+# tmpfs instead (rootfs apps design §5.3): empty, writable, charged to
+# this cgroup, and nothing of the host's /tmp in it. The probe's
+# tmp-private check: empty, writable, and the host's fixture
+# /tmp/carlos-probe absent.
+TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev
+# Kept: a private /dev on the image's mount point (ship adds it). Probe
+# shm-private and the mount allowlist.
+PrivateDevices=yes
+# Kept: PrivateDevices= bind-mounts the HOST's /dev/shm into the private
+# /dev, and every instance is uid carlos, so a sibling's shared memory
+# would be one open away. Each unit gets its own, charged to its own
+# cgroup (probe shm-private).
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# Kept: POSIX message queues and SysV IPC belong to an IPC namespace, and
+# every instance is uid carlos, so a sibling's queue would be one mq_open
+# away (#178). PrivateIPC= gives each unit its own namespace, and
+# mq_open() goes through that namespace's own internal mount.
+# PrivateDevices= carries the host's /dev/mqueue into the private /dev the
+# way it carries /dev/shm, and that mount would still list and open the
+# host namespace's queues, so it is replaced the same way (probe
+# ipc-private covers both).
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# New (Plan 2 deviation 8; Plan 6 adds it to the binary block):
+# PrivateDevices= carries the host's /dev/pts and /dev/ptmx into the
+# private /dev the same way, and a tenant's terminal is what Plan 6 makes
+# of them. Hidden here from the start, so Plan 6 never edits a security
+# block it did not review. The probe's pts-private check: /dev/pts lists
+# nothing and open(/dev/ptmx) fails.
+TemporaryFileSystem=/dev/pts:ro
+# Replaces ProtectControlGroups=yes. The binary block mounts /sys/fs/cgroup
+# read-only so the edge's delegated subtree, carlos-owned, is not one
+# write away for a same-uid tenant (codex R9-1); with a fresh API /sys,
+# whether cgroup2 is mounted read-only under it is not something the
+# binary probe's check (a write to the edge's delegated cgroup.procs) can
+# see from an image. strict (systemd 257) is a cgroup namespace with a
+# private read-only /sys/fs/cgroup showing only this unit's own subtree.
+# The probe's cgroup-strict check: /proc/self/cgroup is 0::/, the mount
+# carries ro, access(W_OK) on its own cgroup.procs answers EROFS, and the
+# listing names no carlos.slice or system.slice; a systemd that ignores
+# the value leaves the listing populated and fails the line.
+ProtectControlGroups=strict
+# Kept: these apply to the fresh /proc (probe pid1).
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+# Kept, unchanged meaning; a unit-file pin. NoNewPrivileges= also voids
+# any set-id file in the image.
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+# Kept, unchanged; a unit-file pin.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+#
+# Kept byte for byte (the test pins it to the binary block's). The box's
+# own addresses are not in this list (#367): the control service denies
+# them on carlos-instances.slice, whose list every unit below it is merged
+# with, and the probe's box-addr check finds them closed from inside.
+IPAddressDeny=localhost multicast 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10
+# Replaced, outside this block because it is per instance:
+# BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf.
+# Its "-" skips a box without resolved, and inside an image that would
+# leave the image's own (empty) resolv.conf in place: no DNS. Each rootfs
+# unit binds root's copy of exactly the file the control service opened on
+# the slice (instancectl resolverFile) over /etc/resolv.conf instead. The
+# reason the binary line never binds resolved's stub still holds: an allow
+# opens an address both ways and on every port, so a loopback resolver can
+# never be opened; a tenant could bind systemd-resolved's 127.0.0.53
+# itself and reach every listener bound to all addresses through it.
+# Where resolved runs, instances resolve through its upstreams, and the
+# control service opens those. The probe's dns and resolver-ports checks
+# read /etc/resolv.conf from inside and compare it with the allow.
+#
+# Kept, unchanged; unit-file pins.
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the image is nosuid by RootImageOptions=, the tenant trees are
+# nosuid, and /tmp is a nosuid tmpfs of this unit's own).
+#
+# Kept in the block for every template, the probe included (R11, rootfs
+# apps design §5.11). A release whose root-staged manifest declares jit
+# runs without it through a per-start drop-in that root writes and checks
+# the effective property of (C10), never through a unit file. The probe's
+# mdwe check: an mmap(PROT_WRITE|PROT_EXEC) answers EPERM inside the
+# probe, which never gets the drop-in.
+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). A worker is
+# a sibling service with its own family rule (isolated-exec design §4.3;
+# rootfs apps design §5.4).
+OOMPolicy=kill
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index 21745e8a3..daa41eeb6 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -342,7 +342,8 @@ func TestControlUnitsShape(t *testing.T) {
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"] || have["carlos-instance-gen@.service"] {
+ if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] || have["carlos-instance-gen@.service"] ||
+ have["carlos-instance-rootfs-probe.service"] || have["carlos-instance-rootfs@.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 -"} {
diff --git a/internal/host/units_rootfs_test.go b/internal/host/units_rootfs_test.go
new file mode 100644
index 000000000..f95634363
--- /dev/null
+++ b/internal/host/units_rootfs_test.go
@@ -0,0 +1,192 @@
+package host
+
+import (
+ "strings"
+ "testing"
+)
+
+var rootfsTemplates = []string{"carlos-instance-rootfs@.service", "carlos-instance-rootfs-gen@.service", "carlos-instance-rootfs-worker@.service", "carlos-instance-rootfs-cmd@.service", "carlos-instance-rootfs-probe.service"}
+
+func rootfsBlock(t *testing.T, name string) string {
+ t.Helper()
+ u := readUnit(t, name)
+ start := strings.Index(u, "# --- the rootfs namespace")
+ end := strings.Index(u, "# --- per-service resources")
+ if start < 0 || end < 0 || end < start {
+ t.Fatalf("%s: rootfs namespace block markers missing", name)
+ }
+ return u[start:end]
+}
+
+// The five rootfs units carry one restated namespace block, byte for byte,
+// so a property the rootfs probe proves is the property a rootfs tenant
+// gets (spec §5.3, §8; the binary templates' TestInstanceTemplatesShareOneNamespace).
+func TestRootfsTemplatesShareOneNamespace(t *testing.T) {
+ t.Parallel()
+ ref := rootfsBlock(t, "carlos-instance-rootfs@.service")
+ for _, name := range rootfsTemplates[1:] {
+ if rootfsBlock(t, name) != ref {
+ t.Errorf("%s rootfs namespace block differs from carlos-instance-rootfs@.service", name)
+ }
+ }
+}
+
+// The restatement, pinned as an exact diff against the binary block: every
+// directive the binary block has is either kept or in the dropped set, and
+// every directive the rootfs block adds is in the added set. Any other
+// difference is a change nobody argued for.
+func TestRootfsNamespaceRestatesTheBinaryBlock(t *testing.T) {
+ t.Parallel()
+ binary := readUnit(t, "carlos-instance@.service")
+ bstart, bend := strings.Index(binary, "# --- the namespace"), strings.Index(binary, "# --- per-service resources")
+ bin := map[string]bool{}
+ for _, d := range directives(binary[bstart:bend]) {
+ bin[d] = true
+ }
+ rfs := map[string]bool{}
+ for _, d := range directives(rootfsBlock(t, "carlos-instance-rootfs@.service")) {
+ rfs[d] = true
+ }
+ dropped := map[string]string{
+ "TemporaryFileSystem=/var/cache:ro": "the host's /var is not in an image root (probe root-image, mounts-allowlisted)",
+ "TemporaryFileSystem=/var/lib:ro": "same",
+ "InaccessiblePaths=/etc/carlos /run/carlos-control": "neither path exists in an image root (probe etc-carlos, control-unreachable)",
+ "PrivateTmp=yes": "replaced by a tmpfs on /tmp (probe tmp-private)",
+ "ProtectControlGroups=yes": "replaced by strict (probe cgroup-strict)",
+ "BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf": "replaced by root's per-instance copy outside the block (probe dns)",
+ }
+ added := map[string]bool{
+ "RootImageOptions=ro,nosuid,nodev": true,
+ "MountAPIVFS=yes": true,
+ "TemporaryFileSystem=/tmp:mode=1777,nosuid,nodev": true,
+ "ProtectControlGroups=strict": true,
+ "TemporaryFileSystem=/dev/pts:ro": true, // deviation 8; once Plan 6 adds it to the binary block it is kept, and Plan 6 drops this entry
+ }
+ for d := range bin {
+ if rfs[d] {
+ continue
+ }
+ if _, ok := dropped[d]; !ok {
+ t.Errorf("binary block line %q is missing from the rootfs block and is not in the dropped table", d)
+ }
+ }
+ for d := range dropped {
+ if !bin[d] {
+ t.Errorf("dropped table names %q, which the binary block no longer has; update the restatement", d)
+ }
+ if rfs[d] {
+ t.Errorf("rootfs block still carries %q, which the table drops", d)
+ }
+ }
+ for d := range rfs {
+ if bin[d] || added[d] {
+ continue
+ }
+ t.Errorf("rootfs block adds %q, which the restatement table does not name", d)
+ }
+ for d := range added {
+ if !rfs[d] {
+ t.Errorf("rootfs block lacks %q", d)
+ }
+ }
+ // The egress fence is the binary block's, byte for byte.
+ if !hasDirective(rootfsBlock(t, "carlos-instance-rootfs@.service"), instanceDeny) {
+ t.Error("rootfs block's IPAddressDeny= differs from the binary block's")
+ }
+ // R11: the block itself keeps MemoryDenyWriteExecute=yes for every
+ // template, the probe included. A jit release loses it only through
+ // the per-start drop-in root writes (Task 5), never in a unit file.
+ for _, name := range rootfsTemplates {
+ if !hasDirective(rootfsBlock(t, name), "MemoryDenyWriteExecute=yes") {
+ t.Errorf("%s block lacks MemoryDenyWriteExecute=yes", name)
+ }
+ }
+}
+
+// C3 in the unit files: every bind source a rootfs unit names is the
+// instance's own two directories, the platform binary, or root's exe
+// layout for that unit. Nothing from the version store, the live links or
+// another instance can be bound, whatever the control service writes.
+func TestRootfsTemplatesBindOnlyTheInstancesOwnAndRoots(t *testing.T) {
+ t.Parallel()
+ allowed := func(src, name string) bool {
+ own := "%I"
+ if name == "carlos-instance-rootfs-probe.service" {
+ own = "_probe"
+ }
+ switch {
+ case src == "/data/instances/"+own, src == "/run/carlos/instances/"+own, src == "/opt/carlos/carlos":
+ return true
+ case strings.HasPrefix(src, "/run/carlos-control/exe/"+own+"/rootfs/"):
+ return true
+ }
+ return false
+ }
+ for _, name := range rootfsTemplates {
+ for _, d := range directives(readUnit(t, name)) {
+ for _, key := range []string{"BindPaths=", "BindReadOnlyPaths=", "RootImage="} {
+ if !strings.HasPrefix(d, key) {
+ continue
+ }
+ for _, spec := range strings.Fields(strings.TrimPrefix(d, key)) {
+ src, _, _ := strings.Cut(strings.TrimPrefix(spec, "-"), ":")
+ if !allowed(src, name) {
+ t.Errorf("%s binds %q, outside the instance's own paths and root's exe layout", name, src)
+ }
+ }
+ }
+ if strings.Contains(d, "/opt/carlos/versions") || strings.Contains(d, "/opt/carlos/live") || strings.Contains(d, "/opt/carlos/app") {
+ t.Errorf("%s names a build path: %q (the image is the app)", name, d)
+ }
+ }
+ }
+}
+
+func TestRootfsTemplatesPerUnitLines(t *testing.T) {
+ t.Parallel()
+ for name, wants := range map[string][]string{
+ "carlos-instance-rootfs@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec --rootfs run %I", "ExecStopPost=/opt/carlos/carlos instance-exit --rootfs run %I",
+ "RootImage=/run/carlos-control/exe/%I/rootfs/run/image",
+ "BindReadOnlyPaths=/run/carlos-control/exe/%I/rootfs/run/launch.json:/opt/carlos/launch.json /run/carlos-control/exe/%I/rootfs/run/resolv.conf:/etc/resolv.conf",
+ "BindPaths=/data/instances/%I /run/carlos/instances/%I", "WorkingDirectory=/data/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", "SuccessExitStatus=129 130 141 143", "TasksMax=256", "OOMScoreAdjust=500", "OOMPolicy=kill", "User=carlos"},
+ "carlos-instance-rootfs-gen@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec --rootfs run %I", "ExecStopPost=/opt/carlos/carlos instance-exit --rootfs run %I", "WorkingDirectory=/data/instances", "SuccessExitStatus=129 130 141 143", "Restart=no", "User=carlos"},
+ "carlos-instance-rootfs-worker@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec --rootfs worker %I", "ExecStopPost=/opt/carlos/carlos instance-exit --rootfs worker %I", "WorkingDirectory=/data/instances", "SuccessExitStatus=129 130 141 143", "Restart=no", "BindsTo=carlos-edge.service", "User=carlos"},
+ "carlos-instance-rootfs-cmd@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=6h5m", "ExecStart=/opt/carlos/carlos instance-exec --rootfs cmd %I", "ExecStopPost=/opt/carlos/carlos instance-exit --rootfs cmd %I",
+ "RootImage=/run/carlos-control/exe/%I/rootfs/cmd/image", "StandardOutput=file:/run/carlos/logs/%I.cmd.sock", "StandardError=file:/run/carlos/logs/%I.cmd-err.sock", "WorkingDirectory=/data/instances/%I", "User=carlos"},
+ "carlos-instance-rootfs-probe.service": {"Type=oneshot", "ExecStart=/opt/carlos/carlos instance-probe --rootfs", "RootImage=/run/carlos-control/exe/_probe/rootfs/run/image",
+ "BindPaths=/data/instances/_probe /run/carlos/instances/_probe", "StandardOutput=truncate:/run/carlos-control/rootfs-probe.out", "Slice=carlos-instances.slice", "TasksMax=64", "MemoryMax=64M", "CPUQuota=50%", "CPUWeight=50", "IOWeight=50", "User=carlos",
+ "BindReadOnlyPaths=/run/carlos-control/exe/_probe/rootfs/run/launch.json:/opt/carlos/launch.json /run/carlos-control/exe/_probe/rootfs/run/resolv.conf:/etc/resolv.conf /run/carlos-control/exe/_probe/rootfs/run/box-addrs:/opt/carlos/box-addrs"},
+ } {
+ u := readUnit(t, name)
+ for _, w := range wants {
+ if !hasDirective(u, w) {
+ t.Errorf("%s lacks %q", name, w)
+ }
+ }
+ if strings.Contains(u, "ExecStopPost=+") {
+ t.Errorf("%s uses ExecStopPost=+ (codex R3-2)", name)
+ }
+ if name == "carlos-instance-rootfs-cmd@.service" && strings.Contains(u, "\nSuccessExitStatus=") {
+ t.Errorf("%s counts a signal's exit as success", name)
+ }
+ }
+ // The compound-instance templates key nothing by their instance string
+ // inside the namespace or anywhere by %i (zero-downtime plan 2 D1).
+ for _, name := range []string{"carlos-instance-rootfs-gen@.service", "carlos-instance-rootfs-worker@.service"} {
+ for _, d := range directives(readUnit(t, name)) {
+ for _, key := range []string{"Slice=", "BindPaths=", "ReadWritePaths=", "StandardOutput=", "RootImage="} {
+ if strings.HasPrefix(d, key) {
+ t.Errorf("%s carries %q; it belongs in the drop-in", name, d)
+ }
+ }
+ if strings.HasPrefix(d, "BindReadOnlyPaths=") && strings.Contains(d, "/run/carlos-control/exe") {
+ t.Errorf("%s binds a per-instance source itself: %q", name, d)
+ }
+ if strings.Contains(d, "%i") || (strings.Contains(d, "%I") && !strings.HasPrefix(d, "ExecStart=") && !strings.HasPrefix(d, "ExecStopPost=") && !strings.HasPrefix(d, "Description=")) {
+ t.Errorf("%s keys %q by its instance string", name, d)
+ }
+ }
+ }
+}