10 KiB
M7 design: split deployment
Status: draft, 2026-10-04, awaiting the owner's answers in section 11. The implementer is Ornith
(through tools/run-plan.sh), as for M6. No plan is written until this spec is approved.
It rests on the brief (docs/design.md, Network isolation, Roles, Authority 4, Target
environment), on the 2026-09-18 decisions (rootless Podman, separate users per role, broker.sock
and admin.sock enforced with mounts in M7), and on what already exists: inferproxy, the M3b
tool image and egress proxy, flake.nix packages and deploy/tools-image.nix. P22 / M8 (a fleet
module and make install) stays out of this milestone.
Where this document and the brief disagree, section 10 names the brief change. There is none yet.
1. What M7 proves
Each role already has a narrow binary. Until now they all run as the owner's user on the host. M7 puts each role in its own rootless container so a compromise of one process is contained by namespaces, mounts and users, not only by code.
The milestone is checked by:
- from inside the
loopdcontainer, requests to the internet, the tailnet and the host all fail - a tool container without a host grant cannot reach the tailnet (already true of M3b; repeated from the new layout)
loopdcannot openadmin.sock;bxctlon the host still can
2. What already holds
| Piece | Today |
|---|---|
| Tool containers | Fresh rootless Podman per call, image from Nix, named by digest, --network=none unless the grant names hosts, egress proxy for http_fetch |
inferproxy |
Unix socket in, one TCP upstream to the host llama-server |
loopd |
No credentials, no network in process terms; it still runs with the host network namespace |
| Sockets | Pair directories under $HOME/run/; both ends are the same Unix user, so the split is code (forbidden) |
flake.nix |
Builds every binary and the tool image; no NixOS module (P22 / M8) |
| Production on straylight | Host processes, owner's user, Mattermost bot untouched by this spec's device check |
M7 does not rewrite the IPC, grants, audit, or inference contract. It changes where the binaries run and who can see which sockets and files.
3. Isolation model
One long-lived container per role: loopd, brokerd, gatewayd, memoryd, inferproxy.
Tool containers stay as they are (one shot, from brokerd). bxctl stays on the host: it is
the owner's CLI, not a role.
Each role container:
- runs as a dedicated user on the host (the 2026-09-18 decision: separate users per role)
- is started rootless Podman (
--userns=keep-idso the role user owns its files) - is
--read-onlywith tmpfs for/tmpand the lock/socket directories it must write - drops capabilities (
--cap-drop=all,no-new-privileges) - mounts only the directories that role may see, with the mode that role needs
Network per role (brief, Network isolation):
| Role | Network |
|---|---|
loopd |
--network=none. Loopback only. Four Unix sockets. |
inferproxy |
Outbound to the configured llama-server host:port and nothing else |
gatewayd |
Outbound to Mattermost. No listening TCP port |
memoryd |
Outbound to Honcho and nothing else |
brokerd |
No outbound internet in M7. It talks to the container runtime, to its sockets, and (for consult) to a loopback URL already required to be 127.0.0.1 or localhost. How loopback consult is reached from inside the container is question 4 |
| tool containers | Unchanged: none, or the per-call egress proxy |
4. Sockets and mounts
Peer authentication stays directory permissions, now across users.
Each pair of roles shares one directory that only those two users can enter. The listening
socket lives there, mode 0600, owned by the listener. The other role's container mounts that
directory. A third role does not get the directory.
The pairs that already exist under run/:
| Directory | Listener | Client | M7 extra |
|---|---|---|---|
run/loop/ |
loopd (loop.sock) |
gatewayd, bxctl (host) |
bxctl is in the owner's group or ACL for this dir |
run/loop-broker/ |
brokerd (broker.sock) |
loopd |
loopd does not mount run/owner-broker/ |
run/owner-broker/ |
brokerd (admin.sock) |
bxctl (host) |
not mounted into loopd |
run/infer/ |
inferproxy |
loopd |
|
run/loop-memory/ |
memoryd |
loopd |
|
run/broker-memory/ |
memoryd |
brokerd |
|
run/memory-broker/ |
brokerd (state.sock) |
memoryd |
|
run/owner-memory/ |
memoryd (admin.sock) |
bxctl (host) |
not mounted into loopd |
run/gateway/ |
brokerd (gateway.sock), when [gateway] is set |
gatewayd |
Files:
| Tree | Who writes | Who reads |
|---|---|---|
sessions/ |
loopd |
memoryd (reindex, Honcho send), bxctl |
memory/ |
memoryd |
loopd (baseline copies at epoch start, through the socket, as today) |
grants/ |
the owner, from the host | brokerd (read-only) |
audit/ |
brokerd |
bxctl audit verify on the host |
broker/sessions/ |
brokerd |
brokerd |
config *.toml, system.md, soul.md, heartbeat.md, compact.md |
the owner | the role that loads them |
loopd never sees grants/, audit/, or admin.sock. That is the mount half of the 2026-09-18
socket split.
5. Images and how they start
flake.nix already builds one package of every binary. M7 adds, under deploy/:
- a rootless image per role or one image holding all role binaries (question 2), still
built by Nix, loaded with
podman load, named by digest - a host ACL file for the tailnet node (question 5):
deploy/acl.hujsonor the owner's existing Tailscale ACL repo, copied or pointed at from here so the gate can see it - unit files or a small supervisor script that starts the five role containers with the mounts in section 4 (question 3)
No NixOS module in M7 (P22 / M8). No make install for Omarchy in M7.
Secrets stay bxcore::secrets: systemd credential, env, or owner-only file. Inside a
container the credential backend needs the host to pass the credential in; the env and file
backends already work if the file is mounted mode 0600. Default remains credential.
One instance per daemon still holds: the existing lock files live on the mounted run/ dirs.
6. Components
| Piece | Change |
|---|---|
loopd |
none required in Rust if --network=none plus mounts are enough. A start failure when a required socket dir is missing already has runbook entries; add any new pointer M7 creates |
brokerd |
Podman is invoked from inside the brokerd container to start tool containers (nested rootless, question 1). consult stays loopback HTTP/1.1 |
gatewayd |
none in Rust |
memoryd |
none in Rust |
inferproxy |
none in Rust; its upstream is still host llama-server |
bxctl |
stays on the host; needs group/ACL on loop.sock and admin.sock dirs |
bxcore |
none |
deploy/ |
role image(s), start units or script, tailnet ACL policy, example mounts |
docs/egress.md |
no new run-time destination. consult is still loopback until a later live URL |
7. Testing
Given tests first, against a fake podman / a recorded argument list where the start script is
code, and against permission checks on a scratch tree of run/ dirs:
- loopd has no net: a helper in the
loopdimage (orbusybox wget) to1.1.1.1:443, to the tailnet Mattermost host, and to the host'sllama-serverport, all fail. The four Unix sockets still work. - admin.sock is not in the loopd mount: opening it from the
loopdcontainer isENOENTorEACCES.bxctl approvalsfrom the host still lists. - broker.sock is in the loopd mount: a tool request still reaches
brokerd. - Tool without hosts:
shellorhttp_fetchwith no host grant cannot reach the tailnet (repeat of M3b from the newbrokerdcontainer). - inferproxy:
loopdstill completes a self-test through the socket.
On the device (straylight, private $HOME, production daemons untouched, slot 1 left busy):
- start the five role containers from
deploy/ - from inside
loopd, the three negative probes in the first bullet fail bxctl chat --session m7-live --no-thinkingcompletes one turn on slot 0- a tool call with no host grant cannot reach the tailnet
- production Mattermost bot stays down for this check
8. Runbook
New entries, each with a pointer from the message, for every start failure M7 adds. Candidates:
- a role container cannot bind its socket (directory missing or wrong user)
- nested Podman from
brokerdis unavailable loopdis missing one of its four sockets at start
Existing entries (brokerd-start-failed, secret-unavailable, consult-not-configured, …)
keep their text. If a path in an entry assumes host processes, the task that adds the unit
updates the check steps.
9. Out of scope
- P22 / M8: NixOS module,
dist/units for Omarchy, fleet of agents, crossbar - A live
consultURL off-host (own egress row, grant, secret, later) - HTTPS for
consult - Cloud-led sessions
- Changing the inference contract or slot layout
- Moving
bxctlinto a container - Replacing rootless Podman
10. Changes to the brief and decisions
None required if section 11 stays inside the brief. The 2026-09-18 rows already say M7 enforces the socket split with mounts and gives each role its own user.
11. The owner's answers (open)
- Nested Podman.
brokerdtoday starts tool containers as the owner's user. In M7 it runs inside a container. Is nested rootless Podman (socket from the host, or Podman-in-Podman) acceptable, or doesbrokerdstay a host process that only the tools andloopdmove? - Images. One image per role, or one image with every role binary and a different
command? - How they start on straylight. systemd user units wrapping
podman run, Podman quadlets, or a script indeploy/the device check invokes? (NixOS module is M8.) consultloopback from thebrokerdcontainer.slirp4netns/pastalocalhost is not the host's127.0.0.1. Keepconsulton host-gateway loopback, put the dummy in thebrokerdnetns, or leavebrokerdon the host until a live URL exists?- Tailnet ACL source of truth. A file in
deploy/that the owner's Tailscale ACL is generated from, or a pointer plus a checked-in copy of the policy this host should have? - Production cutover. Device check on a private
$HOMEonly in M7, or also move the live Mattermost bot into the new layout as part of the milestone? - Split. One plan, or M7a (loopd
--network=none+ socket mounts, still one user) then M7b (users, images, ACLs)?