carlos / platform Public

units: carlos-instance-cmd@, the seed template outside six lines (run spec §5.4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f3678a93e05984c3322a0a5577c0bfe75675850c parent 36c8236
7 files changed, +236 −10
  • approved_run_copy_test.go +1 −1
  • docs/site/cli.md +3 −3
  • internal/host/host_test.go +1 −0
  • internal/host/units/carlos-instance-cmd@.service +163 −0
  • internal/host/units_hardening_test.go +4 −3
  • internal/host/units_isolation_test.go +63 −2
  • internal/instanceexec/families_test.go +1 −1
diff --git a/approved_run_copy_test.go b/approved_run_copy_test.go
index a92e2db90..d6a0ce00f 100644
--- a/approved_run_copy_test.go
+++ b/approved_run_copy_test.go
@@ -33,7 +33,7 @@ var runCopyIDs = func() []string {
// runCopyBuilt is every deck id whose text the code or docs now carry. The
// task that writes a row's text adds its id here in the same commit.
-var runCopyBuilt = []string{"RUN-C9", "RUN-C10", "RUN-C13", "RUN-C14"}
+var runCopyBuilt = []string{"RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14"}
func mustParseRunRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 44dc7d488..95bf4bc59 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2073,12 +2073,12 @@ link with exactly the arguments and environment in it, refusing anything
that is not the platform's own live link. It then stays on as the
instance's first process: it passes stop signals on to the app, cleans up
exited processes, and exits with the app's status, so an app with no
-signal handling still stops cleanly. `ExecStart=` of the three instance
-templates.
+signal handling still stops cleanly.
+`ExecStart=` of the four instance templates.
### carlos instance-exit
-`ExecStopPost=` of the three instance templates. It leaves an advisory
+`ExecStopPost=` of the four 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.
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 4bd257c09..07d5bc59e 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -39,6 +39,7 @@ var expectedUnits = []string{
"carlos-edge.service",
"carlos-hibernate-verify.service",
"carlos-hibernate-verify.timer",
+ "carlos-instance-cmd@.service",
"carlos-instance-control.service",
"carlos-instance-control.socket",
"carlos-instance-probe.service",
diff --git a/internal/host/units/carlos-instance-cmd@.service b/internal/host/units/carlos-instance-cmd@.service
new file mode 100644
index 000000000..c3237c770
--- /dev/null
+++ b/internal/host/units/carlos-instance-cmd@.service
@@ -0,0 +1,163 @@
+# One `carlos run` command against an isolated exec-backed instance (run
+# spec §5.4). It is the seed template outside six lines, and a test holds
+# it there (TestCmdTemplateEqualsSeedOutsideTheNamedLines): the same uid,
+# namespace, 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 exec-backed instance %I (cmd)
+# Stop-only coupling, as for the seed: 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
+ExecStart=/opt/carlos/carlos instance-exec cmd %I
+# The advisory exit record, as for the other templates. Not "+" (codex R3-2).
+ExecStopPost=/opt/carlos/carlos instance-exit 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.
+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 flat child ran with cwd /,
+# and an app writing relative paths (./data/blobs) wrote under /data;
+# without this line a unit's cwd is / on a read-only tmpfs and that app
+# exits at start with "read-only file system". systemd applies it after
+# the namespace is set up, so the bind above is what it chdirs into. Same
+# value in all three templates: the seed and the sidecar see the files
+# the instance made from the same place.
+WorkingDirectory=/data/instances/%I
+# Its own build, and no other app's (#214 item 2). The namespace block
+# empties /opt/carlos; these put back /opt/carlos/live/<host>, a link to
+# ../app, and /opt/carlos/app, which PID 1 binds by chasing the source
+# link to this host's live build at every start. The control service
+# writes both sources before it starts the unit.
+BindReadOnlyPaths=/run/carlos-control/exe/%I/app:/opt/carlos/app /run/carlos-control/exe/%I/live:/opt/carlos/live
+
+# --- 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
+# The version store holds every app's build on the box, world-readable so
+# a unit can exec its own (#214 item 2). An instance sees none of it: an
+# empty /opt/carlos with the platform binary that ExecStart= and
+# ExecStopPost= run put back, and, outside this block because it is per
+# instance, its own build at /opt/carlos/live/<host>.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# 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. /run/carlos-control is the root control
+# service's socket directory (design §5.2): hiding it here too means a
+# connect from inside any instance's namespace is impossible outright,
+# not merely refused by the peer check on the far end (final review,
+# plan 2, item 3).
+InaccessiblePaths=/etc/carlos /run/carlos-control
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# 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.
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# 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.
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# /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
+# 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.
+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
+# 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 instead, and the control service opens those. The "-" makes
+# this a no-op on a box without resolved.
+BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf
+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 tenant trees are nosuid, PrivateTmp's sit under a root 0700
+# directory).
+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_hardening_test.go b/internal/host/units_hardening_test.go
index 0cd6fc864..513c3e954 100644
--- a/internal/host/units_hardening_test.go
+++ b/internal/host/units_hardening_test.go
@@ -39,10 +39,10 @@ const instanceDeny = "IPAddressDeny=localhost multicast " +
// Every directive the hardening adds to the shared namespace block (#214
// items 2 and 3, #178), pinned exactly: the block is byte-identical across
-// the three templates and the probe (TestInstanceTemplatesShareOneNamespace),
-// so pinning it on carlos-instance@ pins it on all four.
+// the four templates and the probe (TestInstanceTemplatesShareOneNamespace),
+// so pinning it on carlos-instance@ pins it on all five.
func TestNamespaceBlockCarriesTheHardening(t *testing.T) {
- for _, name := range []string{"carlos-instance@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
+ for _, name := range []string{"carlos-instance@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-cmd@.service", "carlos-instance-probe.service"} {
u := readUnit(t, name)
for _, want := range []string{
// #214 item 2: no other app's build is visible.
@@ -108,6 +108,7 @@ func TestInstanceTemplatesBindOnlyTheirOwnBuild(t *testing.T) {
"carlos-instance@.service": "%I",
"carlos-instance-seed@.service": "%I",
"carlos-instance-sidecar@.service": "%I",
+ "carlos-instance-cmd@.service": "%I",
"carlos-instance-probe.service": "_probe",
} {
u := readUnit(t, name)
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index f7a939f57..def5b5825 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -239,7 +239,7 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
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"} {
+ for _, name := range []string{"carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-cmd@.service", "carlos-instance-probe.service"} {
if block(name) != ref {
t.Errorf("%s namespace block differs from carlos-instance@.service", name)
}
@@ -278,6 +278,9 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
"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",
"KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"},
+ "carlos-instance-cmd@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=6h5m", "ExecStart=/opt/carlos/carlos instance-exec cmd %I",
+ "ExecStopPost=/opt/carlos/carlos instance-exit cmd %I", "StandardOutput=file:/run/carlos/logs/%I.cmd.sock",
+ "StandardError=file:/run/carlos/logs/%I.cmd-err.sock", "KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"},
"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",
"SuccessExitStatus=129 130 141 143", "WorkingDirectory=/data/instances/%I"},
@@ -300,7 +303,7 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
t.Errorf("%s uses ExecStopPost=+: the plus prefix keeps the unit's tmpfs (codex R3-2)", name)
}
// A seed a signal ended has not seeded (#126).
- if name == "carlos-instance-seed@.service" && strings.Contains(u, "\nSuccessExitStatus=") {
+ if (name == "carlos-instance-seed@.service" || name == "carlos-instance-cmd@.service") && strings.Contains(u, "\nSuccessExitStatus=") {
t.Errorf("%s counts a signal's exit status as success", name)
}
if name != "carlos-instance-probe.service" && !strings.Contains(u, "User=carlos") {
@@ -405,3 +408,61 @@ func TestSameUIDAuditIsCoveredByTheTemplates(t *testing.T) {
}
}
}
+
+// The cmd template is the seed template outside six named lines (run spec
+// §5.4, codex 4): every sandbox line is the seed's, so a command has
+// exactly the reach a seed has. Comments and blank lines are free; every
+// directive is compared, in order.
+func TestCmdTemplateEqualsSeedOutsideTheNamedLines(t *testing.T) {
+ directives := func(name string) []string {
+ var out []string
+ for _, line := range strings.Split(readUnit(t, name), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ out = append(out, line)
+ }
+ return out
+ }
+ swap := map[string]string{
+ "Description=carlos exec-backed instance %I (seed)": "Description=carlos exec-backed instance %I (cmd)",
+ "TimeoutStartSec=120": "TimeoutStartSec=6h5m",
+ "ExecStart=/opt/carlos/carlos instance-exec seed %I": "ExecStart=/opt/carlos/carlos instance-exec cmd %I",
+ "ExecStopPost=/opt/carlos/carlos instance-exit seed %I": "ExecStopPost=/opt/carlos/carlos instance-exit cmd %I",
+ "StandardOutput=file:/run/carlos/logs/%I.seed.sock": "StandardOutput=file:/run/carlos/logs/%I.cmd.sock",
+ "StandardError=inherit": "StandardError=file:/run/carlos/logs/%I.cmd-err.sock",
+ }
+ seed, cmd := directives("carlos-instance-seed@.service"), directives("carlos-instance-cmd@.service")
+ want := make([]string, len(seed))
+ used := map[string]bool{}
+ for i, l := range seed {
+ if c, ok := swap[l]; ok {
+ want[i], used[l] = c, true
+ continue
+ }
+ want[i] = l
+ }
+ for l := range swap {
+ if !used[l] {
+ t.Errorf("the seed template no longer has %q; the run spec names it as a cmd difference", l)
+ }
+ }
+ for i := 0; i < len(want) || i < len(cmd); i++ {
+ var w, g string
+ if i < len(want) {
+ w = want[i]
+ }
+ if i < len(cmd) {
+ g = cmd[i]
+ }
+ if w != g {
+ t.Fatalf("directive %d: cmd template has %q, want %q (the seed's line with the named swaps)", i, g, w)
+ }
+ }
+ for _, keep := range []string{"Type=oneshot", "RemainAfterExit=yes", "KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"} {
+ if !hasDirective(readUnit(t, "carlos-instance-cmd@.service"), keep) {
+ t.Errorf("cmd template lacks %q", keep)
+ }
+ }
+}
diff --git a/internal/instanceexec/families_test.go b/internal/instanceexec/families_test.go
index 937b5c945..08b322026 100644
--- a/internal/instanceexec/families_test.go
+++ b/internal/instanceexec/families_test.go
@@ -15,7 +15,7 @@ import (
)
// Every carlos process inside an instance unit (the probe, the trampoline
-// and its exit hook, for all three templates) runs under
+// and its exit hook, for all four templates) runs under
// RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX. A socket of any other
// family is refused, so code that asks for one fails on every box: the
// probe's net.InterfaceAddrs, which is netlink, kept every box on v0.25.0