Compass host runtime tier
Status: Draft Tracking: RIG-3512 Owner: compass-runner (runtime) → compass-agent (onboarding review)
Problem / Intent
Section titled “Problem / Intent”A new user’s adoption cost is dominated by getting their existing skills,
tools, and secrets reachable by a Compass agent. Today the lowest tier is
podman (DL-325), which still asks the user to project their environment into a
container before the first useful session. Meanwhile the agent they already run
— a CLI agent on their own machine — has all of it for free. This record adds a
host tier: a host backend that runs the agent as a plain host process
with the same access as the CLI agent the user already runs, making replication
of their existing setup near-zero-setup. The ruling: the tier is worth it even
if some users never graduate, because the counterfactual is the user staying on
their current CLI agent at the same host-exposure posture — a host-tier Compass
user is strictly better off.
The second half of the same onboarding story is the imported corpus itself: the
transport (compass agent-config push --dir) exists, but a user importing
their existing skills/rules cannot tell what overlaps or is superseded by
Compass’s built-ins. A silently-shadowed skill looks like it worked — the worst
onboarding failure. This record designs an agent-driven config-import
review as part of the first-run flow, and the host tier is what makes it
land: the agent is already sitting where the user’s config lives.
Approach
Section titled “Approach”Half A — the host backend
Section titled “Half A — the host backend”Placement
Section titled “Placement”The host tier is a third SelectBackend value, joining ""/podman/
microvm. SelectBackend is the sole backend selection point
(go/internal/runtime/microvm.go:117-125):
func SelectBackend(cfg BackendConfig) (ContainerRuntime, error) { switch strings.TrimSpace(cfg.Backend) { case "", "podman": return NewPodmanCLI(), nil case "microvm": return NewMicroVMRuntime(cfg.MicroVM), nil default: return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default) and \"microvm\"", cfg.Backend) }}A case "host" arm returns the new backend and the error string extends to
name the third accepted value. The default stays podman: host is opted into
explicitly, never fallen back to.
On the DL-325 trust-model axis
(docs/designs/DECISIONS.md:158: “untrusted multi-tenant operation requires
the microVM hardware boundary (KVM, unchanged); self-host single-tenant
deployments keep podman as a permanent, supported entry tier”), the host tier
sits below podman. DL-325’s rule is that the boundary follows the trust
model, not the deployment shape — so the host tier is scoped by whose
machine and whose trust, not by which product a user bought.
The agent runs on the operator’s own machine, under their own uid, on work
they already trust themselves with. That is a single-trust-domain tier:
the operator is the only principal, so there is no boundary for the tier to
enforce. It is never a valid backend for untrusted work or for isolating
mutually-distrusting principals from each other — a shared kernel and a
shared $HOME cannot separate parties, whatever the deployment shape.
Nothing in that scoping is about the number of tenants a deployment serves. A user still has their own machine whatever shape their Server runs in, and running an agent on it — for onboarding, or for a task that genuinely needs that box (below) — puts exactly one trust domain on the host: theirs. So the tier is available to any user running an agent on their own machine, and the deployment topology their Server sits in does not change the analysis. Applying the tier to someone else’s work, on a machine serving more than one principal, is what DL-325 forbids — and that is a property of the trust domain, not of the product.
Guidance stays “prefer container/microVM” — the docs recommend graduating — but the tier is not gated or crippled to force it.
The tier’s second motivation is host capability the container cannot provide at all: workflows that need the user’s real session bus, display, or device access (e.g. window-management tooling driving the live desktop session). This is not onboarding scaffolding — it is a permanent capability, and it is the same need whichever deployment a user’s Server belongs to: the work has to run where the hardware and the session are. See Open Questions.
ContainerRuntime implementation
Section titled “ContainerRuntime implementation”ContainerRuntime is frozen at S1 (go/internal/runtime/podman.go:348-396;
the closing comment at :399: “ContainerRuntime is frozen (the Resize
reservation above)”). The host backend implements it; it does not amend it.
The nine methods, per the interface doc comments, and their host-process
semantics — including where the mapping is degenerate:
| Method | Interface contract (quoted) | Host semantics |
|---|---|---|
Create(ctx, spec) (ContainerID, error) |
“makes a container from spec without starting it, returning its id” (podman.go:349-350) |
Allocates a per-agent handle: mints a synthetic ContainerID, creates the agent’s private state dir (workspace root, home overlay dir, socket dir) from ContainerSpec. No process is spawned. Spec fields that configure container machinery (image, mounts as bind specs, network) are interpreted or ignored per a documented field map — see T1. |
Start(ctx, id) |
“starts a created container” (podman.go:352-353) |
Degenerate. There is no init process to start; the agent process itself is launched later by ExecStreaming. Start transitions the handle created → started and validates the state dir. It must not be pretended to be more: a “started” host handle is bookkeeping, not a running boundary. |
Exec(ctx, id, spec) (ExecOutput, error) |
“runs a command in a running container, capturing its output. A non-zero exit is a successful runtime call returning a failed command” (podman.go:355-359) |
Runs the command as a direct host subprocess of the Runner, under the Runner’s own uid, with ExecSpec’s env/cwd/stdin and the per-command timeout. ExecSpec.AsUser cannot switch user — the process runs as whoever runs the Runner — so the backend honors exactly one value, the Runner’s own effective uid, and rejects (errors on) an AsUser naming any other uid rather than silently running it wrong. That strict rejection is only launchable because the host tier derives Workspace.UID from os.Geteuid() instead of the baked fleet constant, so every provision-path AsUser already carries the euid it will run as — see “The host-tier uid contract” below. Without that derivation this rule would error on every provision exec and no host agent could launch. |
ExecStreaming(ctx, id, spec) (*StreamingExec, error) |
“starts a long-lived streaming command … returning its live stdio pipes plus a kill/wait handle” (podman.go:361-369) |
The one clean mapping: spawns the agent as a host child process in its own process group, stdio piped, bound to ctx. This is where the host-tier agent actually comes to life. |
Stop(ctx, id, timeout) |
“stops a running container, allowing timeout for graceful exit” (podman.go:371-373) |
Signals the handle’s process group: SIGTERM, wait up to timeout, then SIGKILL. Scope is the process group the backend spawned — a host process the agent double-forked out of the group is not reliably stopped; that leak is named, not papered over (no cgroup freezer in v1; see Open Questions). |
Remove(ctx, id) |
“removes a container (force-kills if still running)” (podman.go:375-376) |
Force-kills the process group if live, then deletes the handle’s state dir. It does not touch anything outside the state dir — the agent’s writes to the real host filesystem are permanent, which is the tier’s declared posture, not a cleanup bug. |
Exists(ctx, name) (bool, error) |
“reports whether a container with name currently exists (any state)” (podman.go:378-379) |
Degenerate. There is no container registry to consult; existence means “the backend has a handle (state dir) under this name”. This is handle-existence plus process-liveness, not container-existence: a crashed agent whose state dir remains still Exists, mirroring a stopped-but-not-removed container. |
MountLabel(ctx, id) (string, error) |
“reports the container’s SELinux mount label (its private MCS category), read from podman inspect” (podman.go:381-385) |
Degenerate: returns "", nil. There is no container and no per-container MCS category. Empty is already a first-class value in the consumer: ConfigMaterializer.Materialize documents “mcsLabel is "" on the PROVISION path … there is no label to target … skip chcon” (go/internal/runner/config_materialize.go:138-144). The host tier extends that meaning: empty on every path, so the chcon -R relabel (config_materialize.go:353-354) never runs. Agent reads succeed because materialized files carry the Runner’s own label and the agent is the Runner’s uid. See “MCS/SELinux relabel gap” below. |
Resize(ctx, id, limits) |
“changes a live container’s cgroup resource limits in place … the resize BEHAVIOR … is C3’s to fill in behind this signature” (podman.go:387-396) |
Degenerate for v1. The host backend owns no cgroup. It returns a typed “unsupported on host backend” error, never a silent success — a caller that believes it resized must not be lied to. A future systemd user-scope/cgroup v2 delegation could make this real; out of scope here. |
The honest summary: ExecStreaming, Exec, Stop, Remove are real;
Create/Start/Exists are bookkeeping over a state dir; MountLabel and
Resize are degenerate by construction. The backend documents each degenerate
case at the method, in these terms.
The host-tier uid contract
Section titled “The host-tier uid contract”The Exec row’s strict AsUser rejection and the fleet’s baked agent uid are
in direct conflict, and resolving it is a design obligation of this tier, not an
implementation detail. The fleet uid is a constant with no override:
// The const is untyped on purpose: it flows into the uint32 runtime.AgentSpec.UID// field and into int comparisons (e.g. os.Getuid()) alike, with no conversions at// the call sites. Keeping it in one importable package is the single source of// truth for the agent-uid invariant across the runner command and the runtime// package's proofs.const AgentUID = 1000(go/internal/agentuid/agentuid.go:8-13.) The Runner hands exactly that into
its spec defaults (go/cmd/compass-runner/main.go:153: UID: agentuid.AgentUID,),
and BuildSpec copies it verbatim into every agent’s workspace
(go/internal/runner/spec.go:89-93, dedented):
Workspace: runtime.Workspace{ CheckoutDir: d.CheckoutDir, HomeDir: d.HomeDir, UID: d.UID,},Nothing configures it. The Runner’s whole flag block declares no uid flag or env
override (go/cmd/compass-runner/main.go:44-84, run()’s flag declarations
through flag.Parse(); the command’s only other uid mentions are the podman
userns-remap preflight comment at main.go:96-98), and
NewConfigSpecBuilder’s sole check on the value is that it is not root
(go/internal/runner/spec.go:59-60):
if defaults.UID == 0 { return nil, errors.New("spec defaults require a non-root uid")}That uid is then what every provision-path exec passes as AsUser:
AgentRuntime.ExecAsAgent (go/internal/runtime/agent.go:203-204:
AsUser(strconv.FormatUint(uint64(handle.spec.Workspace.UID), 10))),
WriteAgentFile (agent.go:249-250), installCredentials (agent.go:343-344),
ensureCheckoutDir (agent.go:359-360), the secrets materializer
(go/internal/runtime/secrets_materialize.go:435-436), and the agent’s own
streaming exec (go/internal/runner/agent_exec.go:78-79:
AsUser(strconv.FormatUint(uint64(e.UID), 10))).
Ruling. On the host tier Workspace.UID is derived from the Runner’s real
effective uid — os.Geteuid() at Runner startup — and never from
agentuid.AgentUID. The fleet SpecDefaults.UID constant is not usable on
this tier: it names the uid baked into the agent image, which this tier does
not run, so a host Runner whose euid is not 1000 (the normal case, and the whole
premise of the tier) would fail every provision exec against a rule that is
otherwise correct. Deriving the uid makes the strict rejection both strict and
always-satisfied: the only value that ever reaches AsUser is the euid the
subprocess will run as anyway, and any other uid is a real caller bug that must
error. The rejection is not softened to accept-and-ignore — an AsUser
naming a different uid means the caller believes a user switch happened, and the
host backend cannot provide one.
Two consequences the plan carries (T1a):
- The host tier needs its own spec derivation.
SpecDefaultsis built once at Runner startup (main.go:148-156), so the host profile supplies the derived euid there rather than the constant; the existing non-root check (spec.go:59-60) still applies, so a Runner running as root is refused — the same posture the container tiers hold (go/internal/runtime/workspace.go:51-53: “UID is the unprivileged uid the agent runs as. Never container-root — that would let the agent tear down its own egress firewall”). runtime.Workspace.UIDis auint32(workspace.go:53) whileos.Geteuid()returns anint, and it returns-1on platforms without the syscall — so the derivation validates the value before narrowing rather than converting blindly.
Agent transport: the socket and config paths
Section titled “Agent transport: the socket and config paths”ContainerRuntime does not deliver the agent its gateway socket or its config;
the Runner’s Provision does, by bind-mount, to two paths that are frozen
constants on both sides of the rendezvous. A host process has no bind
mounts, so this is the one part of the tier that no ContainerRuntime
implementation can supply — it needs its own Provision leg.
Runner side (go/internal/runner/host.go:33-38, tabs expanded):
const ( agentSocketDir = "containers" agentSocketFile = "agent.sock" agentSocketMountPath = "/run/compass/agent.sock" agentConfigMountPath = "/run/compass/agent-config")preceded by the comment that names the contract (host.go:30-32):
“agentSocketMountPath is the fixed in-container path the socket is
bind-mounted to, so the agent needs no per-session configuration — it always
dials the same path”. Both are delivered as mounts on the podman provision leg —
host.go:198: spec.Mounts = append(spec.Mounts, listener.Mount(agentSocketMountPath))
and host.go:214:
spec.Mounts = append(spec.Mounts, runtime.Mount{HostPath: mount.HostPath, ContainerPath: agentConfigMountPath, ReadOnly: true}).
Agent side, both paths are compile-time constants with no configuration input
(packages/compass-agent/src/cli.ts:86-91):
/** * The in-container path the Runner bind-mounts this agent's socket to. Fixed by * contract with `internal/runner/host.go:33` — the agent takes no per-session * socket configuration, so this constant IS the rendezvous. */export const AGENT_SOCKET_PATH = "/run/compass/agent.sock";and (packages/compass-agent/src/config-reader.ts:47-53):
/** * The in-container path the Runner materializes the agent-config bundle to. * Fixed by contract with the Runner's mount (design §CD-3) — the agent takes no * per-session config location, so this constant IS the rendezvous. The agent * reads through `<mount>/current`, the symlink the Runner flips. */export const AGENT_CONFIG_MOUNT_PATH = "/run/compass/agent-config";Both are pinned by contract tests. packages/compass-agent/src/cli.test.ts:113-116
(tabs expanded):
describe("AGENT_SOCKET_PATH", () => { test("matches the Runner's fixed in-container mount path", () => { expect(AGENT_SOCKET_PATH).toBe("/run/compass/agent.sock"); });and packages/compass-agent/src/config-reader.test.ts:67-70, whose preceding
comment states why it is pinned (config-reader.test.ts:63-66) — “A drift is a
silent unconfigured boot, so it is pinned — beside AGENT_SOCKET_PATH’s
contract test”:
describe("AGENT_CONFIG_MOUNT_PATH", () => { test("is the frozen /run/compass/agent-config contract path", () => { expect(AGENT_CONFIG_MOUNT_PATH).toBe("/run/compass/agent-config"); });So a host-tier agent launched with no transport design dials a literal
/run/compass/agent.sock that either does not exist (no gateway, dial timeout)
or — if the host backend created it for real — is machine-global and needs root
to bind, which makes it structurally un-per-agent and contradicts the tier’s
one-state-dir-per-handle model. The config path fails worse: absent, it is a
silent unconfigured boot, exactly the drift the test above exists to catch.
Ruling. The host tier gets its own Provision leg, beside the existing
podman and microVM legs, and the two agent-side constants become
env-overridable:
- The leg serves the per-agent gateway socket inside the handle’s own state dir
(the same 0700 per-agent dir
Createmints), not under a machine-global/run/compass, and materializes the config tree to a path in that dir. No mounts are appended — there is nothing to mount into. - It threads both paths to the agent as environment variables on the streaming
exec that starts it (
AgentEnv.execSpec,go/internal/runner/agent_exec.go:77-81, already the seam that setsHOME/COMPASS_WORKDIR). AGENT_SOCKET_PATHandAGENT_CONFIG_MOUNT_PATHbecome env-overridable with today’s literals as defaults, so an agent that receives no override behaves byte-identically to today and the container tiers are untouched. The two contract tests keep pinning the default; each gains a case asserting the override path.cli.tsalready carries the precedent for the config half —MainDeps.configMountis documented as “Overridable ONLY so a test can point the reader at a tempdir fixture instead of the container path” (cli.ts:586-590) — this promotes that from a test-only dependency seam to a first-class environment input, which is a change to the frozen contract and is named as such.
This is a change to the compass-agent package’s frozen path contract and to
its two pinned contract tests. It is deliberate and scoped: the frozen value
stays the default, and only the host tier ever supplies an override. The
alternative (a Provision-side probe seam alone, mirroring vsockGatewayEngine)
is rejected in Alternatives considered — a Provision-side probe can only change
what the Runner does, and cannot change a path the agent resolves from a
constant with no configuration input.
Egress: explicitly unenforced
Section titled “Egress: explicitly unenforced”The container tiers arm a default-deny nftables firewall in the container’s
own network namespace (go/internal/runtime/egress.go:1-4):
“Default-deny + allowlist egress firewall for an agent container … The container’s own network namespace is firewalled with nftables, so a compromised agent can’t exfiltrate to an arbitrary host”
A host process has no private netns; that mechanism is structurally unenforceable here. The ruling: host-tier egress is explicitly unenforced — a first-class declared posture, not a degraded arm.
Concretely, the host backend must not implement the inGuestEgressArmer
probe-and-skip seam. That seam exists so a backend that armed egress itself can
tell AgentRuntime.provision to skip the host-side arm exec
(go/internal/runtime/agent.go:307-312):
func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err } }and its contract is “the backend armed it internally” — the test names it “a
fakeRuntime that self-arms egress in-guest … so AgentRuntime.provision must
skip the host-side armEgress exec — mirroring the microVM backend”
(go/internal/runtime/agent_test.go:298-301). Returning true from
EgressArmedInGuest() on the host backend would falsely claim someone armed
the firewall when nobody did and nobody can. Instead:
-
AgentRuntimegrows an explicit unenforced path: a backend marker interface (e.g.EgressUnenforced() bool, name settled at T2) that makes provision skiparmEgressand record the posture as unenforced — a distinct state, never conflated with armed. -
The unenforced posture is visible in session state and UI: the session carries an egress-posture field surfaced wherever session status renders, so a green launch is never read as contained. A user must be able to see, per session, “egress: unenforced (host tier)”.
-
A host-tier launch that carries any
EgressPolicyreaching provision fails loud (“host backend cannot enforce an egress policy”), never silently ignores it. The trigger is the policy’s presence, not a non-empty allowlist: an empty host set is the strictest posture, not the absence of a policy (go/internal/runtime/egress.go:29-31):// EgressPolicy is the set of destinations an agent container may reach. An empty// host set is pure default-deny (only loopback, established flows, and DNS to// the container's own resolver).and empty is also the Runner’s default:
--egress-allowdefaults to""(go/cmd/compass-runner/main.go:58-59) and the parse turns that into a real policy (main.go:378-381):func parseEgress(csv string) (runtime.EgressPolicy, error) {if strings.TrimSpace(csv) == "" {return runtime.AllowEgress()}Keying the check on non-emptiness would therefore reject a looser policy while silently discarding the tightest one — the exact silent-ignore this bullet exists to prevent, inverted.
-
Making presence expressible is a small upstream change the tier requires.
EgressPolicytoday draws no configured/unconfigured distinction: its only accessor isHosts()(go/internal/runtime/egress.go:67), so a zero-valueEgressPolicy{}and an explicitAllowEgress()are indistinguishable. T2 adds aconfigured boolset byAllowEgress/MustAllowEgressplus aConfigured()accessor, and the host backend refuses any spec whose policy reports configured.Hosts()andNftScript()are untouched, so container arming stays byte-identical. -
Consequently a host-tier launch must come through a path that carries no egress policy at all — the host Runner profile leaves
SpecDefaults.Egressat its zero value rather than callingparseEgress— instead of relying on an allowlist happening to be empty.
Most users of this tier will not have armed egress anyway — it is primarily an
enterprise-posture control. A future bubblewrap (Linux) / sandbox-exec
(macOS) wrapping mode could add real containment to the host tier later; it is
noted as future work and deliberately not designed here.
Secrets: pin the SecretSpec keyring:// provider
Section titled “Secrets: pin the SecretSpec keyring:// provider”Per DL-024 (docs/designs/DECISIONS.md:137): “Each agent runs in a per-agent
container on the Runner for blast-radius isolation, not credential avoidance.”
The container was never the thing keeping secrets from the agent — the agent is
handed resolved values regardless, and they are the user’s own secrets. So the
host tier changes nothing about who sees secrets. What this record does pin,
on merit and explicitly not as a mitigation, is at-rest handling on the
Server side for host-tier (self-host, single-box) deployments: the SecretSpec
resolver’s provider is pinned to keyring://, so resolved values live in the
OS keyring rather than wherever the SDK’s default chain lands.
The seam exists and is currently unused: WithProvider pins the provider URI
(go/internal/secrets/resolver.go:83-85):
// WithProvider pins the SecretSpec provider URI (e.g. "keyring://",// "onepassword://Production"). Empty uses the SDK's default provider chain.func WithProvider(uri string) SpecOption { return func(r *SpecResolver) { r.provider = uri } }and production pins nothing today (go/server/serve.go:528):
resolver := secrets.NewSpecResolver(st, secretsStateDir(cfg))T3 threads a config knob through serve.go and defaults the host-tier
single-box profile to keyring://. This does not depend on, replace, or
preempt the gateway-credentials at-rest encryption record
(docs/designs/server/compass-gateway-credentials-at-rest-encryption.md),
whose T0–T5 are all unimplemented — see Global Constraints.
The $HOME/.compass/{env,secrets} collision. The materializer writes
resolved secrets into the agent’s $HOME/.compass/env before agent start
(go/internal/runner/host.go:360-384: “Materialize the agent’s secrets into
the container BEFORE exec’ing the agent … h.materializer.Install(ctx, handle.ID(), handle.HomeDir(), …)”; the agent “sources that file from its own
namespace at startup”, go/internal/runner/agent_exec.go:72-74). In a
container, $HOME is container-private. On the host tier, a naive $HOME is
the user’s real home — colliding with any .compass state the user’s own
CLI tooling keeps, and strewing per-agent runtime files into a shared dir.
This is an ergonomics/path question, not a security one (the values are
the same user’s secrets either way, on the same machine, under the same uid).
Resolution: the host backend sets the agent’s HOME to the handle’s private
home-overlay dir inside the state dir (the handle.HomeDir() seam already
threads it), so $HOME/.compass/env lands per-agent and Remove cleans it.
The user’s real home is reachable by path — the whole point of the tier — but
is not the agent’s $HOME.
MCS/SELinux relabel gap
Section titled “MCS/SELinux relabel gap”The config-update path reads the container’s MCS label via podman inspect
MountLabel and chcon -Rs the freshly materialized version dir into it
(go/internal/runner/config_materialize.go:141-144: “read via podman inspect MountLabel … chcon -R it into the container’s MCS category AFTER
writing and BEFORE the flip, or a confined agent gets EACCES”; the relabel
shellout at :353-354). With no container there is no label and no confined
domain: the host backend’s MountLabel returns "", Materialize takes its
already-documented skip-chcon path on every call, and reads succeed because the
agent process runs as the same uid that wrote the files. No new mechanism; the
tier reuses the empty-label contract that already exists for the provision
path.
Structurally absent protections (declared, not weakened)
Section titled “Structurally absent protections (declared, not weakened)”These are absent in this tier, not weaker versions of present ones. The tier’s documentation and session UI state them:
- Host filesystem: the agent runs as the user’s uid with the user’s full filesystem access. No mount narrowing, no MCS confinement, no private root.
- Inter-agent isolation: two host-tier agents on one box are two processes under one uid; each can read the other’s state dir, sockets, and secrets file. (Corollary: the host tier is single-agent-at-a-time by default — see Open Questions.)
- Egress: unenforced, per above.
The counterfactual framing is the justification: the user’s existing CLI agent already runs at exactly this posture. The host tier adds Compass’s session management, config, and review flow at that same posture; it removes nothing the user had.
Half B — agent-driven config-import review
Section titled “Half B — agent-driven config-import review”What exists and what is missing
Section titled “What exists and what is missing”The transport is implemented. compass agent-config push --dir <path>
tars+gzips a local directory and PutAgentConfigs it
(go/cmd/compass/agent_config.go:30-32: “newPushCmd builds agent-config push --dir <path>: tar+gzip the dir into a bundle the store door accepts and
PutAgentConfig it (admin-gated)”). The bundle grammar whitelists top dirs
skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/,
profiles/ plus top-level AGENTS.md and models.yml
(go/cmd/compass/bundle.go:29-53,71-80, mirroring “the store door
(internal/store/agent_config.go) so a bundle this builder produces passes
validateAndHashConfigBundle”, bundle.go:23-24).
What is missing is judgment. A user pushing their existing corpus cannot tell which of their hand-written skills/rules Compass’s built-ins already cover, which conflict, and which are safe to drop. Filename collision checks are the shallow half; real overlap is semantic — a hand-written skill that does what a built-in does, differently, sharing no filename. Per the ruling this is not a deterministic gate: it is an agent task.
The review task
Section titled “The review task”A first-run onboarding flow in which a Compass agent (host tier — see below) reads the user’s imported corpus against Compass’s built-ins and produces a report; the user decides. Shape:
- Ingest: the agent reads the source corpus directly from where it lives
(
~/.agents,~/.claude, an existing bundle dir) and enumerates candidate members against the bundle grammar (what would even be importable). - Compare: for each candidate, the agent reads it and the built-in corpus and classifies: redundant (a built-in already does this — including semantic overlap with no shared filename), conflicting (contradicts a built-in rule/skill or Compass’s composition semantics), complementary (safe to import as-is), with a one-line rationale and the specific built-in it overlaps.
- Explain composition: the report states, per category, what will actually
happen on import, grounded in the shipped semantics — settings are
fleet-first whole-file (“overlay-over-project precedence”, DL-123), rules
and AGENTS.md compose additively (“fleet-first, both levels load, no
cross-level dedup”,
docs/designs/agent/compass-agent-config-passthrough/design.md:481-483; “the fleet file composes additively with the checkout’s own AGENTS.md chain”,:50-53); the bundle is a fleet-wide singleton, admin-gated, current-only (“upserts it as the single current bundle … current-only retention via the singleton PK upsert”,go/internal/store/agent_config.go:131-138); and credential-marked settings are rejected at the door (“credentials never ride the config bundle”,go/internal/store/agent_config.go:1026-1030) — so the agent tells the user up front which members will bounce and why. - Decide: the user marks each finding keep/drop/rewrite. The agent then assembles the approved subset into a bundle dir and (with the user’s go-ahead) runs the existing push. The agent proposes; the user disposes. The review never mutates the user’s source corpus and never pushes without an explicit user decision.
The deliverable of the review is the report plus the user’s recorded decisions — not an automatic mutation of anything.
Why the host tier makes this land
Section titled “Why the host tier makes this land”On the container tiers, reviewing a not-yet-imported corpus needs a push-then-inspect round trip: the corpus must enter the bundle pipeline before any agent can see it, which is backwards — the review is supposed to happen before the push. A host-tier agent is already sitting where the user’s config lives and reads the source corpus directly. That makes the review a natural first-run task on the exact tier a new user starts on, and it is a demonstration of value in the first session: the first thing Compass does is tell the user something true about their own setup.
Cross-references
Section titled “Cross-references”- The living tier spec (what the tiers are, operator-facing):
docs/specs/runtime/runner-tiers.md. This record is the point-in-time why; the spec is the living what. - The trust-model split this extends: DL-325 via
docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md. - The embedded-mode front door this sits beside (DL-319/DL-320,
docs/designs/DECISIONS.md:321-322): embedded mode lowers stack friction (the app spawns a local podman-backed stack); the host tier lowers agent environment friction. They compose: an embedded-local stack can run a host-backend Runner.
Alternatives considered
Section titled “Alternatives considered”A dedicated HostRuntime interface instead of implementing ContainerRuntime
Section titled “A dedicated HostRuntime interface instead of implementing ContainerRuntime”Rejected. ContainerRuntime is frozen at S1 and SelectBackend is the sole
selection point; a parallel interface would fork the AgentRuntime lifecycle
façade (Launch → provision → credentials) that all tiers share, for no gain —
the degenerate methods are few and honestly documentable. The microVM backend
already set the precedent of a non-podman backend behind the same interface.
The interface layer was never the hard part, and this record does not argue the
tier’s feasibility there: the load-bearing work is outside ContainerRuntime
entirely — the uid derivation and the Provision transport leg above, neither of
which a parallel interface would have made easier.
Deterministic import linting instead of an agent review
Section titled “Deterministic import linting instead of an agent review”Rejected by ruling. A filename/collision linter catches only the shallow half and gives false confidence on the dangerous half (semantic overlap). The deterministic checks that make sense (bundle grammar, credential denylist) already exist at the door and the client builder; the review’s job is exactly the part that needs reading comprehension.
Sandboxed-by-default host tier (bubblewrap/sandbox-exec from day one)
Section titled “Sandboxed-by-default host tier (bubblewrap/sandbox-exec from day one)”Deferred, not rejected. Wrapping the host process would blunt the tier’s core promise — same access as the user’s existing CLI agent, zero setup — and each wrapper is platform-specific. Noted as future work; a later record may add an opt-in wrapped mode.
A Provision-side probe seam alone for the agent transport
Section titled “A Provision-side probe seam alone for the agent transport”Rejected as insufficient, not as ugly. The microVM backend’s precedent is a
Provision probe (go/internal/runner/host.go:50-60, vsockGatewayEngine, whose
leg at host.go:800-810 “launches the container with NO agent-socket mount and
NO config mount”), and the host tier does need the equivalent leg. But a probe
only changes what the Runner does. The path the agent dials is resolved from
a module-level constant with no configuration input
(packages/compass-agent/src/cli.ts:91, config-reader.ts:53), so no
Runner-side seam can redirect it. The agent-side override is unavoidable; the
probe leg is necessary but not sufficient, and the record takes both.
Global Constraints
Section titled “Global Constraints”- Public repo. No managed/multi-tenant product detail; managed-plane
concerns are named and deferred, never sequenced here
(
docs/concepts/self-host-and-managed.md,docs/designs/meta/oss-core-managed-boundary/design.md). - Do not weaken the container tiers. The podman/microVM egress path
(
armEgress,EgressArmedInGuest) is untouched; the host tier adds a distinct unenforced posture beside it, never a change to arming. ContainerRuntimeis frozen at S1. The host backend implements the 9-method interface as-is (go/internal/runtime/podman.go:348-396); no interface amendment.- No dependency on gateway-credentials at-rest encryption. That record’s
T0–T5 are all unimplemented
(
docs/designs/server/compass-gateway-credentials-at-rest-encryption.md, tasks unchecked); nothing here waits on or assumes it. - The host tier is a single-trust-domain backend — valid only for an operator running their own agents on their own machine, never for untrusted work and never to isolate mutually-distrusting principals from each other (DL-325’s axis: the boundary follows the trust model, not the deployment shape). It is not scoped by deployment topology: a user of any deployment shape may run an agent on their own box.
- Agent proposes, user disposes — the import review never mutates the user’s source corpus and never pushes without an explicit user decision.
- Bundle grammar and door checks are authoritative and unchanged — the review explains them; it does not bypass or re-implement them.
- The host tier derives its own uid.
Workspace.UIDcomes from the Runner’sos.Geteuid(), neveragentuid.AgentUID(go/internal/agentuid/agentuid.go:13) — that constant names the uid baked into the agent image and has no Runner-side override. Root is still refused (go/internal/runner/spec.go:59-60). - Container-tier agent behaviour stays byte-identical. The two frozen
agent-side path constants (
packages/compass-agent/src/cli.ts:91,config-reader.ts:53) become env-overridable with today’s literals as defaults; only the host tier ever supplies an override, and the existing contract tests keep pinning the defaults. - No egress policy reaches the host backend. Presence, not emptiness, is
the fail-loud trigger — empty is the strictest policy
(
go/internal/runtime/egress.go:29-31), so the host launch path must carry no policy at all.
- T1 —
HostRuntimebackend (go/internal/runtime/host_backend.go). The 9-method implementation per the table above: state-dir handle model, process-group spawn/stop, degenerateMountLabel/Resize/AsUserdocumented at the method. Includes theContainerSpecfield map (which fields are honored, interpreted, or rejected on host). Interfaces: implementsContainerRuntime(go/internal/runtime/podman.go:348-396) exactly; registered inSelectBackend(go/internal/runtime/microvm.go:117-125) ascase "host", error string extended. Unit tests with a real short-lived process (spawn/exec/stop/remove/exists), plus the degenerate-method contracts. - T1a — host-tier spec + uid derivation (
go/cmd/compass-runner/main.go,go/internal/runner/spec.go). The host Runner profile derivesSpecDefaults.UIDfromos.Geteuid()instead ofagentuid.AgentUID, validating theint→uint32narrowing (and the-1no-syscall case) before use; the existing non-root check stays, so a root Runner is refused at startup. The host backend’sExec/ExecStreamingreject anAsUsernaming any uid other than the Runner’s own euid. Interfaces: consumesrunner.SpecDefaultsas built atmain.go:148-156; producesruntime.Workspace{UID: <derived euid>}(spec.go:89-93,go/internal/runtime/workspace.go:51-53). Tests: the derived uid equalsos.Geteuid()and is what every provisionAsUsercarries (go/internal/runtime/agent.go:203-204); a mismatchedAsUsererrors; a uid-0 derivation is refused (spec.go:59-60); a full host provision + launch succeeds on a box whose euid is NOT 1000 — the regression this task exists to prevent. - T1b — host
Provisionleg: agent socket + config delivery (go/internal/runner/host.go,packages/compass-agent/src/cli.ts,packages/compass-agent/src/config-reader.ts). A third Provision leg beside the podman andvsockGatewayEnginelegs: serve the per-agent gateway socket inside the handle’s own 0700 state dir, materialize the config tree there, append no mounts, and thread both paths to the agent as env vars on the starting streaming exec.AGENT_SOCKET_PATHandAGENT_CONFIG_MOUNT_PATHbecome env-overridable, defaulting to today’s literals. This touches thecompass-agentpackage’s frozen path contract and its two pinned contract tests (cli.test.ts:113-117,config-reader.test.ts:67-70), which keep pinning the defaults and each gain an override case. Interfaces: consumes the leg-selection seam (host.go:191-193) and the socket/config mount constants (host.go:33-38, delivered athost.go:198andhost.go:214); produces two env vars onAgentEnv.execSpec(go/internal/runner/agent_exec.go:77-81). Tests: the host leg appends no mounts (mirroringhost_vsock_gateway_test.go:121-128); the agent dials the overridden socket and reads the overridden config root; with no override both resolve to today’s literals. - T2 — unenforced-egress posture (
go/internal/runtime/agent.go+ session state). New backend marker (distinct frominGuestEgressArmer) makingprovisionskiparmEgresswhile recording posture=unenforced; fail-loud on ANYEgressPolicythat reaches provision (presence, not a non-empty allowlist — empty is the strictest policy,go/internal/runtime/egress.go:29-31), which requires adding aconfigured bool+Configured()toEgressPolicy(todayHosts()ategress.go:67is its only accessor) and leaving the host Runner profile’sSpecDefaults.Egressat its zero value; posture threaded into session state and rendered in the session UI/status surface. Interfaces: consumes theprovisionseam (agent.go:307-312); produces an egress-posture field on the session (exact proto/field shape decided at implementation, additive only). Tests mirrorTestInGuestArmerSkipsHostArmEgress(agent_test.go:312) for the new marker, plus the fail-loud policy case, plus a test asserting the host backend does NOT satisfyinGuestEgressArmer. - T3 —
keyring://provider pin (go/server/serve.go,go/internal/secrets). Config knob for the resolver provider; host-tier single-box profile defaults it tokeyring://. Interfaces:secrets.NewSpecResolver(st, dir, secrets.WithProvider(uri))(go/internal/secrets/resolver.go:83-85,97); wiring atserve.go:528. Test: resolver receives the configured URI; empty config preserves today’s default chain. - T4 — per-agent
$HOMEoverlay (host backend + materializer path threading). The handle’s private home dir is the agent’sHOME;$HOME/.compass/{env,secrets}land there;Removecleans them. Interfaces:handle.HomeDir()as consumed byh.materializer.Install(go/internal/runner/host.go:384);HOMEon the streaming exec (go/internal/runner/host_test.go:1194-1197names the existing contract). - T5 — config-import review agent task (compass-agent lane). The first-run
review flow per Half B: ingest/compare/explain/decide, report format, and
the assemble-and-push handoff to the existing
compass agent-config push --dirpath. Interfaces: consumes the bundle grammar (go/cmd/compass/bundle.go:29-53) and door semantics (go/internal/store/agent_config.go:131-174,1026-1033) read-only; produces a report artifact + an approved bundle dir. No new RPC. - T6 — docs. Tier documentation: the declared-absent protections list, the
graduation guidance (host → podman → microVM), and the first-run review
walkthrough. Cross-links
docs/specs/runtime/runner-tiers.md.
- T1 —
HostRuntimebackend implementing the frozenContainerRuntime, registered inSelectBackendashost - T1a — host-tier uid derivation:
Workspace.UIDfromos.Geteuid(), notagentuid.AgentUID;AsUserrejects any other uid; launch proven on a non-1000 euid - T1b — host
Provisionleg: per-agent socket + config path in the handle’s state dir, threaded as env vars;AGENT_SOCKET_PATH/AGENT_CONFIG_MOUNT_PATHenv-overridable (touches the two pinnedcompass-agentcontract tests) - T2 — unenforced-egress posture: new marker (not
inGuestEgressArmer), fail-loud on ANYEgressPolicyreaching provision (presence, not non-emptiness) via a newConfigured()distinction, posture visible in session state/UI - T3 — SecretSpec provider knob; host-tier profile pins
keyring:// - T4 — per-agent
$HOMEoverlay for.compass/{env,secrets} - T5 — agent-driven config-import review (first-run onboarding flow)
- T6 — tier docs: absent protections, graduation guidance, review walkthrough
Open Questions
Section titled “Open Questions”-
Two motivations, one feature — RULED: both are permanent, and the tier is not deployment-scoped. (Was: does host-capability want its own record?) Onboarding convenience and host capability (workflows needing the real session bus/display, which no container tier can provide) are two motivations wearing one backend, and the second is not onboarding scaffolding that a user graduates off. Some work simply has to run on the user’s own box: the hardware, the display, and the live session are there and nowhere else. The tier therefore ships as a permanent capability, not a wedge, and the earlier framing (“a supported consequence, not a designed-for surface”) is withdrawn as too weak.
The same ruling settles the scope question: availability follows the trust domain, not the deployment shape. A user whose Server sits in any deployment topology still has their own machine, and running an agent there puts one trust domain on that host — theirs. So the tier is not restricted to a single-tenant deployment; what DL-325 forbids is applying it to untrusted work or to separate mutually-distrusting principals, which is a property of the trust domain (see Approach § Placement).
Still open, narrowly: whether the device/session-bus surface (which devices, which sockets, how documented) wants its own follow-up record once a concrete workflow pins the requirements. That is a documentation and surface-area question, not a tier-existence question.
-
Concurrent host-tier agents (non-load-bearing, deferred): v1 documents the tier as effectively single-agent (no inter-agent isolation exists; process-group stop cannot contain a double-forked escapee). Whether to add a soft cap or a cgroup-scoped v2 is deferred until demand exists.
-
Resizefuture (non-load-bearing, deferred): a systemd user-scope / cgroup v2 delegation could make hostResizereal; deferred until C3’s resize behavior lands anywhere. -
The
Container*vocabulary — RULED and DONE (Matt): renamed toWorkload*in this change. Not deferred. The interface is named for one of its backends, and it now has four: podman containers, microVM guests (MicroVMRuntime,go/internal/runtime/microvm.go:71), Applecontaineron macOS (AppleContainerCLI, DL-330), and the direct host processes this tier adds.SelectBackend’s own comment (microvm.go:110-116) says the podman path eventually goes away entirely, which would leave an interface namedContainerRuntimewith no container implementation at all.Applied:
ContainerRuntime→WorkloadRuntime(85 refs),ContainerID→WorkloadID(198),ContainerSpec→WorkloadSpec(58),InContainerError→InWorkloadError(8).Session*was rejected — a session is already the user-facing conversational stream (SessionEventand siblings inproto/compass/v1/compass.proto), and one workload outlives many sessions, so the name would assert a one-to-one relation that does not hold.Sandboxwas rejected as asserting isolation the host tier explicitly does not provide.Deliberately NOT renamed, because these are genuinely containers:
ContainerController(the podman-only stack supervisor,go/internal/stack/deps.go:219),ContainerRef(a message container,go/internal/store/types.go:268), the testcontainer specs (PostgresContainerSpec,NatsContainerSpec,CollectorContainerSpec), and thecontainer_namewire field (proto/compass/v1/compass.proto:645,653,666,708), which is a compatibility boundary.AgentRuntime(go/internal/runtime/agent.go:155) also keeps its name — it is the per-agent lifecycle façade over a backend, and that name is accurate.The S1 freeze (
go/internal/runtime/podman.gofreeze comment) reserves the method set — “a backend that self-arms egress does NOT grow a verb here” — not the identifier, so the rename is legal under it. No signature, method set, or behaviour changed.
Ledger delta
Section titled “Ledger delta”Proposed rows for the coordinator to mint at freeze (described, ids not invented here):
- Host tier row: a
hostbackend joinsSelectBackend(""/podman/microvm/host) as a permanent tier for an operator running agents on their own machine — agent as a host process at the user’s existing CLI-agent exposure; egress explicitly unenforced (a declared posture, visible in session state, neverEgressArmedInGuest); blast-radius protections (host filesystem, inter-agent isolation, egress) structurally absent and declared. It serves two permanent cases, onboarding and host-capability work no container tier can reach (real session bus, display, device access). AMENDS DL-325’s trust-model axis with a third tier below podman: microVM required for untrusted multi-tenant, podman the permanent self-host container tier, host the single-trust-domain tier. Per DL-325’s own rule the boundary follows the trust model, not the deployment shape, so the host tier is not scoped by deployment topology — it is available to any user running an agent on their own machine, and is never valid for untrusted work or for isolating mutually-distrusting principals. The tier also pins two mechanism decisions:Workspace.UIDis derived from the Runner’sos.Geteuid()rather than the bakedagentuid.AgentUID, and the agent’s socket/config rendezvous paths become env-overridable (defaults unchanged) so the host Provision leg can serve them per-agent inside the handle’s state dir instead of by bind-mount. - Secrets-provider row: the Server’s SecretSpec resolver provider becomes
configurable; the host-tier single-box profile pins
keyring://— an at-rest-handling improvement on merit under DL-024’s framing (isolation was never credential avoidance), explicitly not a mitigation. - Import-review row: config-import review is an agent-driven first-run onboarding task (semantic overlap in scope, agent-proposes/user-disposes), not a deterministic gate; the deterministic door checks (grammar, credential denylist) remain the sole automatic enforcement.
- Runtime-vocabulary row: the backend seam is renamed from
Container*toWorkload*(WorkloadRuntime/WorkloadID/WorkloadSpec/InWorkloadError) because the interface spans four backends — podman, microVM, Applecontainer, host process — only some of which are containers, and the podman path is slated to go away. Ruled by Matt. The S1 freeze covers the method set, not the identifier; no signature or behaviour changed. Genuine containers keep the old vocabulary (ContainerController,ContainerRef, the testcontainer specs, thecontainer_namewire field).