203 lines
10 KiB
Markdown
203 lines
10 KiB
Markdown
# 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 `loopd` container, 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)
|
|
- `loopd` cannot open `admin.sock`; `bxctl` on 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-id` so the role user owns its files)
|
|
- is `--read-only` with tmpfs for `/tmp` and 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.hujson` or 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 `loopd` image (or `busybox wget`) to `1.1.1.1:443`, to
|
|
the tailnet Mattermost host, and to the host's `llama-server` port, all fail. The four Unix
|
|
sockets still work.
|
|
- **admin.sock is not in the loopd mount:** opening it from the `loopd` container is
|
|
`ENOENT` or `EACCES`. `bxctl approvals` from the host still lists.
|
|
- **broker.sock is in the loopd mount:** a tool request still reaches `brokerd`.
|
|
- **Tool without hosts:** `shell` or `http_fetch` with no host grant cannot reach the tailnet
|
|
(repeat of M3b from the new `brokerd` container).
|
|
- **inferproxy:** `loopd` still 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-thinking` completes 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 `brokerd` is unavailable
|
|
- `loopd` is 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 `consult` URL off-host (own egress row, grant, secret, later)
|
|
- HTTPS for `consult`
|
|
- Cloud-led sessions
|
|
- Changing the inference contract or slot layout
|
|
- Moving `bxctl` into 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)
|
|
|
|
1. **Nested Podman.** `brokerd` today 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 does `brokerd` stay a host process that only the tools and `loopd` move?
|
|
2. **Images.** One image per role, or one image with every role binary and a different
|
|
`command`?
|
|
3. **How they start on straylight.** systemd user units wrapping `podman run`, Podman
|
|
quadlets, or a script in `deploy/` the device check invokes? (NixOS module is M8.)
|
|
4. **`consult` loopback from the `brokerd` container.** `slirp4netns`/`pasta` localhost is not
|
|
the host's `127.0.0.1`. Keep `consult` on host-gateway loopback, put the dummy in the
|
|
`brokerd` netns, or leave `brokerd` on the host until a live URL exists?
|
|
5. **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?
|
|
6. **Production cutover.** Device check on a private `$HOME` only in M7, or also move the
|
|
live Mattermost bot into the new layout as part of the milestone?
|
|
7. **Split.** One plan, or M7a (loopd `--network=none` + socket mounts, still one user) then
|
|
M7b (users, images, ACLs)?
|