From 0b0ef5582ae3f2d8af1f455e7d3ca57e3e9022fd Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 6 Sep 2026 13:18:00 -0400 Subject: [PATCH 1/6] design(runtime): host runner tier + living tier spec (RIG-3070) --- .../compass-host-runtime-tier/design.md | 462 ++++++++++++++++++ .../design.md | 57 ++- docs/specs/runtime/README.md | 21 + docs/specs/runtime/runner-tiers.md | 176 +++++++ 4 files changed, 699 insertions(+), 17 deletions(-) create mode 100644 docs/designs/infra/runtime/compass-host-runtime-tier/design.md create mode 100644 docs/specs/runtime/README.md create mode 100644 docs/specs/runtime/runner-tiers.md diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md new file mode 100644 index 00000000..c2aa01f3 --- /dev/null +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -0,0 +1,462 @@ +# Compass host runtime tier + +Status: Draft +Tracking: RIG-TBD (host runtime tier) +Owner: compass-runner (runtime) → compass-agent (onboarding review) + +## 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 + +### Half A — the `host` backend + +#### 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`): + +```go +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: a self-host single-tenant onboarding tier for a user +running their own agents on their own machine. It is never valid for untrusted +or multi-tenant operation. 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). See Open Questions — this record ships the tier for onboarding and +raises the permanent-host-capability framing as a question rather than ruling +it. + +#### `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` is **degenerate**: there is no user switch — the process runs as whoever runs the Runner. The backend rejects (errors on) an `AsUser` naming a different uid rather than silently running it wrong. | +| `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. + +#### 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`): + +```go +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: + +- `AgentRuntime` grows an explicit unenforced path: a backend marker interface + (e.g. `EgressUnenforced() bool`, name settled at T2) that makes provision + skip `armEgress` **and** 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 a non-empty `EgressPolicy` allowlist fails + loud at provision ("host backend cannot enforce an egress policy"), never + silently ignores it. + +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 + +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`): + +```go +// 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`): + +```go +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 + +The config-update path reads the container's MCS label via `podman inspect` +`MountLabel` and `chcon -R`s 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) + +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 + +#### What exists and what is missing + +The transport is implemented. `compass agent-config push --dir ` +tars+gzips a local directory and `PutAgentConfig`s it +(`go/cmd/compass/agent_config.go:30-32`: "newPushCmd builds `agent-config push +--dir `: 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 + +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: + +1. **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). +2. **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. +3. **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. +4. **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 + +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 + +- 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 + +### 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. + +### 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) + +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. + +## 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. +- **`ContainerRuntime` is 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 self-host single-tenant only** — never a valid backend + for untrusted or multi-tenant operation (DL-325's axis). +- **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. + +## Plan + +- **T1 — `HostRuntime` backend** (`go/internal/runtime/host_backend.go`). + The 9-method implementation per the table above: state-dir handle model, + process-group spawn/stop, degenerate `MountLabel`/`Resize`/`AsUser` + documented at the method. Includes the `ContainerSpec` field map (which + fields are honored, interpreted, or rejected on host). + Interfaces: implements `ContainerRuntime` + (`go/internal/runtime/podman.go:348-396`) exactly; registered in + `SelectBackend` (`go/internal/runtime/microvm.go:117-125`) as `case "host"`, + error string extended. Unit tests with a real short-lived process + (spawn/exec/stop/remove/exists), plus the degenerate-method contracts. +- **T2 — unenforced-egress posture** (`go/internal/runtime/agent.go` + session + state). New backend marker (distinct from `inGuestEgressArmer`) making + `provision` skip `armEgress` while recording posture=unenforced; fail-loud on + a non-empty `EgressPolicy`; posture threaded into session state and rendered + in the session UI/status surface. + Interfaces: consumes the `provision` seam (`agent.go:307-312`); produces an + egress-posture field on the session (exact proto/field shape decided at + implementation, additive only). Tests mirror + `TestInGuestArmerSkipsHostArmEgress` (`agent_test.go:312`) for the new + marker, plus the fail-loud policy case, plus a test asserting the host + backend does NOT satisfy `inGuestEgressArmer`. +- **T3 — `keyring://` provider pin** (`go/server/serve.go`, + `go/internal/secrets`). Config knob for the resolver provider; host-tier + single-box profile defaults it to `keyring://`. + Interfaces: `secrets.NewSpecResolver(st, dir, secrets.WithProvider(uri))` + (`go/internal/secrets/resolver.go:83-85,97`); wiring at `serve.go:528`. + Test: resolver receives the configured URI; empty config preserves today's + default chain. +- **T4 — per-agent `$HOME` overlay** (host backend + materializer path + threading). The handle's private home dir is the agent's `HOME`; + `$HOME/.compass/{env,secrets}` land there; `Remove` cleans them. + Interfaces: `handle.HomeDir()` as consumed by `h.materializer.Install` + (`go/internal/runner/host.go:384`); `HOME` on the streaming exec + (`go/internal/runner/host_test.go:1194-1197` names 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 --dir` path. + 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`. + +## Tasks + +- [ ] T1 — `HostRuntime` backend implementing the frozen `ContainerRuntime`, + registered in `SelectBackend` as `host` +- [ ] T2 — unenforced-egress posture: new marker (not `inGuestEgressArmer`), + fail-loud on policy, posture visible in session state/UI +- [ ] T3 — SecretSpec provider knob; host-tier profile pins `keyring://` +- [ ] T4 — per-agent `$HOME` overlay 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 + +- **Two motivations, one feature?** (load-bearing for scope, not for T1-T4 + correctness) Onboarding convenience and permanent host capability (workflows + needing the real session bus/display, which no container tier can provide) + are two motivations wearing one backend. This record ships the tier framed as + the onboarding wedge and treats host-capability use as a supported + consequence, not a designed-for product surface. If host-capability is a + first-class permanent use case, it likely wants its own follow-up record + (device/session-bus documentation, multi-agent-on-host story). Recommendation: + accept the onboarding framing here; revisit host-capability as its own record + when a concrete workflow demands it. +- **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. +- **`Resize` future** (non-load-bearing, deferred): a systemd user-scope / + cgroup v2 delegation could make host `Resize` real; deferred until C3's + resize behavior lands anywhere. + +## Ledger delta + +Proposed rows for the coordinator to mint at freeze (described, ids not +invented here): + +- **Host tier row**: a `host` backend joins `SelectBackend` + (`""`/`podman`/`microvm`/`host`) as the self-host single-tenant onboarding + tier — agent as a host process at the user's existing CLI-agent exposure; + egress explicitly unenforced (a declared posture, visible in session state, + never `EgressArmedInGuest`); blast-radius protections (host filesystem, + inter-agent isolation, egress) structurally absent and declared. **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 self-host onboarding tier — never valid for untrusted or + multi-tenant operation. +- **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. diff --git a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md index fcc099c3..7e7916da 100644 --- a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md +++ b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md @@ -4,6 +4,15 @@ Status: Active Tracking: RIG-3070 Owner: compass-obs (design) → compass-runner (impl, runtime/sequencing) +> **The living strategy now lives at +> [`docs/specs/runtime/runner-tiers.md`](../../../../specs/runtime/runner-tiers.md).** +> This record stays the point-in-time ruling — why the trust model is the +> axis, what it ratifies/supersedes/amends, and the execution plan. The +> durable tier strategy — the tier table, the adoption funnel, the standing +> guidance, and the **host tier** that has since joined the axis (designed in +> [`../compass-host-runtime-tier/design.md`](../compass-host-runtime-tier/design.md)) +> — is maintained in that spec, not here. + ## Problem / Intent The runtime corpus froze a microVM trajectory (DL-259 self-host KVM stack, @@ -197,9 +206,10 @@ an argument against embedded. Compass is fundamentally an always-on server — agents keep working while you are away — and a personal laptop sleeps; so embedded-local is the try-it-on-your-box on-ramp, and a user who wants always-on operation graduates to a self-host stack on a -dedicated box or VPS, or to managed. The funnel: embedded-local (front -door, your box) → self-host stack (always-on, dedicated box) → managed -(hosted always-on). +dedicated box or VPS, or to managed. The durable funnel — since extended +at its front with the host tier — is maintained in the living spec +(`docs/specs/runtime/runner-tiers.md`), not in this record's frozen +prose. The embedded supervision subsystem was deleted under DL-235 (`docs/designs/ui/compass-native-client-only/design.md:42-43`: "The @@ -227,13 +237,12 @@ parity constraint. ### Guided onboarding: embedded-local front door, then self-host -The adoption funnel starts before self-host: the zero-setup front door is -embedded-local — brew install the app, launch it, sign in with your own -subscription, and agents run locally on the podman backend (the -app-architecture that delivers this is the compass-native lane's -embedded-revival record, not this record). Self-host is the graduation -tier for always-on operation, and its bring-up must be near-one-command -on the user's own Linux box or a VPS. The entrypoint already exists: +The durable adoption funnel (host tier → embedded-local front door → +self-host graduation) lives in the living spec +(`docs/specs/runtime/runner-tiers.md`); this section records only the +execution consequences ruled here. Self-host is the graduation tier for +always-on operation, and its bring-up must be near-one-command on the +user's own Linux box or a VPS. The entrypoint already exists: `compass-stack` dispatches `up|down|status|preflight` (`go/cmd/compass-stack/main.go:8-13`: "up: bring the embedded stack to Ready (or attach to a live one) … preflight: check the @@ -247,13 +256,12 @@ host-floors unconditionally (`go/cmd/compass-stack/preflight.go`), so on a zero-KVM podman box it exits non-zero — a green-preflight experience for the podman entry tier is part of T1's deliverable, and T2's guide must not instruct a `preflight` run on the podman tier before then. This record adds -the adoption framing on top: -an onboarding guide (T2) that walks the funnel — the embedded-local front -door first, then both self-host graduation paths: the zero-KVM podman -path on any VPS or box (the entry tier), and the recommended microVM path -on a KVM-capable box or nested-virt-enabled instance (the docs recommend -microVM even on self-host; podman remains fully supported for users who -don't want the KVM premium). The specific VPS provider recommendation is +an onboarding guide (T2) that walks that funnel, covering both self-host +graduation paths: the zero-KVM podman path on any VPS or box (the entry +tier), and the recommended microVM path on a KVM-capable box or +nested-virt-enabled instance (the docs recommend microVM even on +self-host; podman remains fully supported for users who don't want the +KVM premium). The specific VPS provider recommendation is deferred to doc-writing time (OQ-2). The guide content itself is an impl task (T2), not frozen prose here. @@ -582,6 +590,21 @@ freeze-time delta shape the directory's amendments use the frozen KVM-only amendment (`microvm-kvm-only-amendment.md:96-97`) with the self-host carve-out; the `ContainerRuntime` interface stays frozen. +2. **Proposed (2026-09, spec split + host tier — no DL id minted here; the + coordinator assigns one at freeze).** The living runner tier strategy + moves out of this record into a spec, + `docs/specs/runtime/runner-tiers.md`, which becomes the maintained + source-of-truth for the tier table, the adoption funnel, and the + standing tier guidance; this record stays the point-in-time ruling. And + the **host tier** joins the DL-325 trust-model axis as a third tier — + a `SelectBackend` value `"host"` beside `""`/`"podman"`/`"microvm"` — + for single-tenant operation on the operator's own machine, with no + isolation boundary and egress explicitly UNENFORCED, designed in + `docs/designs/infra/runtime/compass-host-runtime-tier/design.md`. This + row AMENDS DL-325 (extends its axis with a third tier and relocates the + strategy's living home) rather than superseding it: DL-325's + untrusted-multi-tenant microVM requirement and permanent self-host + podman tier are unchanged. This stanza is human-readable guidance for the freeze coordinator; the ledger row is encoded in `DECISIONS.md` in the same PR at freeze time. diff --git a/docs/specs/runtime/README.md b/docs/specs/runtime/README.md new file mode 100644 index 00000000..9657ff78 --- /dev/null +++ b/docs/specs/runtime/README.md @@ -0,0 +1,21 @@ +# Runtime Specs + +Living source-of-truth for the **Compass runtime tier strategy** — which +runner backends exist, what each isolates, and how a user adopts them. The +point-in-time *design records* (the why) live in the +[design corpus](../../designs/) (`../../designs/`), bucketed by domain and +indexed by [`DECISIONS.md`](../../designs/DECISIONS.md). + +Available specs: + +- [`runner-tiers.md`](runner-tiers.md) — the runner tier strategy: the + trust-model axis (DL-325), the three tiers (host / podman / microVM) with + each tier's isolation boundary and egress posture, the adoption funnel from + host tier through embedded-local to self-host graduation, and the standing + guidance on when each tier is (and is not) the right choice. + +> These specs describe the strategy and current behavior. The *why* — the +> rulings behind the trust-model split and each tier — lives in the design +> records under [`../../designs/`](../../designs/) (bucketed by domain, +> indexed by `DECISIONS.md`); each spec's "Not yet specified" section names +> the surfaces still ahead of the code. diff --git a/docs/specs/runtime/runner-tiers.md b/docs/specs/runtime/runner-tiers.md new file mode 100644 index 00000000..d5dfcb73 --- /dev/null +++ b/docs/specs/runtime/runner-tiers.md @@ -0,0 +1,176 @@ +# Runner tiers + +Living source-of-truth for the **Compass runner tier strategy**: which runtime +backends exist, what boundary each provides, and how a user adopts them. The +point-in-time rationale — why the axis is the trust model, why podman is +permanent, why a host tier exists at all — lives in the design records this +spec cites; this spec states only the standing strategy. + +Two records carry the why: + +- [Runner adoption strategy](../../designs/infra/runtime/compass-runner-adoption-strategy/design.md) + — the trust-model split ruling (DL-325) and its execution plan. +- [Host runtime tier](../../designs/infra/runtime/compass-host-runtime-tier/design.md) + — the host tier's design record (the third tier, not yet built). + +## The trust-model axis + +**The security boundary follows the trust model, not the deployment +uniformly.** That is the ruled axis (DL-325, +[`DECISIONS.md`](../../designs/DECISIONS.md): "The runner end state splits by +trust model (RIG-3070): untrusted multi-tenant operation requires the microVM +hardware boundary (KVM, unchanged); self-host single-tenant deployments keep +podman as a permanent, supported entry tier requiring no `/dev/kvm`, with +microVM the recommended (not required) upgrade"). + +The consequence: a tier is chosen by asking *who is being isolated from whom*, +not by asking which deployment shape is in play. Untrusted multi-tenant +operation runs code from mutually-distrusting tenants and requires the +hardware isolation boundary. A self-host single-tenant deployment runs the +operator's own agents on their own code on their own box — there is no +untrusted tenant to isolate from — so the boundary strength is the operator's +choice, graded across three tiers. + +One doctrine governs what every tier's boundary is *for* (DL-024, +[`DECISIONS.md`](../../designs/DECISIONS.md): "Each agent runs in a per-agent +container on the Runner for blast-radius isolation, not credential +avoidance"). No tier withholds credentials from the agent — secrets are +materialized into the agent's environment on every tier. What varies across +tiers is the *blast radius* a misbehaving or compromised agent can reach, not +what the agent is trusted with. + +## The tiers + +Tiers are selected at constructor time through the single backend seam, +`SelectBackend` (`go/internal/runtime/microvm.go:117`: +`func SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`), which +today accepts `""`/`"podman"` and `"microvm"` and rejects anything else +(`go/internal/runtime/microvm.go:124`: `accepted values are "podman" +(default) and "microvm"`). The **host** tier joins that seam as a third +`SelectBackend` value, `"host"` — designed, not yet built (see +[Not yet specified](#not-yet-specified)). + +| Tier | Isolation boundary | Egress enforcement | Trust model served | +| --- | --- | --- | --- | +| **host** *(not yet built)* | None — the agent runs as a process on the operator's own machine | **Explicitly unenforced** (see below) | Single-tenant only: the operator's own box, own code, own agents | +| **podman** | Rootless container (shared host kernel) | Enforced: default-deny nftables in the container's own netns | Self-host single-tenant — the permanent supported entry tier | +| **microVM** | Hardware virtualization (cloud-hypervisor/KVM) | Enforced: armed in-guest by the backend | Required for untrusted multi-tenant; recommended self-host upgrade | + +### host *(not yet built)* + +- **Boundary:** none. The agent runs directly on the operator's machine, at + the same host exposure as any CLI agent the user already runs. There is no + container, no separate kernel, no namespace boundary. +- **Egress:** **explicitly unenforced** — not a degraded or partial arm. + Compass's egress firewall is default-deny nftables applied to the + container's own network namespace (`go/internal/runtime/egress.go:2-4`: + "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 such namespace, so the mechanism is structurally inapplicable. The + host tier does not reuse the in-guest-armed marker + (`go/internal/runtime/agent.go:298-299`: `type inGuestEgressArmer interface + { EgressArmedInGuest() bool }`), because that marker means a backend armed + egress itself — claiming it would be false. Host mode states plainly that + egress policy is not enforced. +- **When to use:** onboarding — near-zero setup, replicating the user's + existing CLI-agent posture with Compass's server, comms, and config + machinery on top; and host-capability work that a container cannot reach. +- **When NOT to use:** any deployment with an untrusted tenant, and any + deployment where egress policy must actually bind. It is also not the + preferred steady state for anyone (see + [Standing guidance](#standing-guidance)). +- **What it does and does not protect:** per DL-024 the container was never + credential avoidance — agents receive the user's own secrets on every tier. + The host tier gives up only the blast-radius boundary; it does not hand the + agent anything the other tiers withhold. + +### podman + +- **Boundary:** a rootless per-agent container over the podman CLI — a + shared-kernel namespace boundary, no `/dev/kvm` required. The backend is + the thin seam implementation (`go/internal/runtime/podman.go:11-12`: + "a thin ContainerRuntime over the podman CLI: the only place a subprocess + is spawned. Everything above depends on the interface"). +- **Egress:** enforced. The host-side arm execs the nftables script inside + the container before the agent runs (`go/internal/runtime/agent.go:319-321`: + "armEgress arms the egress firewall as the image's default user (uid 1000) + with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with + no capabilities — cannot alter the ruleset"). +- **When to use:** the permanent, supported self-host entry tier — any Linux + box or VPS without `/dev/kvm`, and macOS via podman-machine. It is the + production default today (`go/internal/runtime/microvm.go:119-120`: + `case "", "podman": return NewPodmanCLI(), nil`) and the backend behind the + embedded-local front door (DL-319). +- **When NOT to use:** untrusted multi-tenant operation — a shared kernel is + not the required boundary there. + +### microVM + +- **Boundary:** hardware virtualization — a per-session microVM under + cloud-hypervisor on KVM. Requires Linux with `/dev/kvm`; a KVM-absent host + hard-fails on this path, with no silent degrade. +- **Egress:** enforced, armed in-guest by the backend itself before the exec + gate opens; the runtime advertises this via the in-guest-armed marker so + the host-side arm is skipped (`go/internal/runtime/agent.go:304-306`: "has + already armed by Start, so the host-side armEgress exec … is skipped"). +- **When to use:** required for untrusted multi-tenant operation (DL-325); + recommended (not required) for self-host, for defense-in-depth or an + operator who runs untrusted code or shares the box. +- **When NOT to use:** it is never wrong on a capable host — the constraint + is the KVM floor, which cheap VPS tiers mostly cannot expose. + +## The adoption funnel + +Each stage is a graduation, never a gate — a user may stay at any stage. + +1. **Host tier** *(not yet built)* — the near-zero-setup front step: run + Compass agents at the same host exposure as the CLI agent you already use. + The counterfactual is not "that user on a container tier"; it is that user + staying on their existing agent with the same exposure and none of + Compass. +2. **Embedded-local (podman)** — the low-friction onboarding front door + (DL-319, [`DECISIONS.md`](../../designs/DECISIONS.md): "`mode="embedded"` + returns as the low-friction onboarding / local-dev front door — the app + spawns/supervises a LOCAL stack via rootless podman on the user's own + machine"), with zero-config mode selection (DL-320: "absent → embedded + (the zero-config onboarding default returns)"). Real isolation, still on + your own box. +3. **Self-host graduation** — always-on operation on a dedicated box: the + podman entry tier on any VPS (no `/dev/kvm` needed), or the microVM tier + on a KVM-capable machine. Client mode is the recommended steady state + (DL-319: client mode "stays first-class and is the RECOMMENDED + steady-state for real self-host"). + +## Standing guidance + +**The host tier is not the preferred steady state — as guidance, not a +gate.** The reason: it provides no blast-radius boundary and no egress +enforcement, so everything an agent can do, it can do to the whole machine. +The container and microVM tiers exist because that boundary is worth having +(DL-024's blast-radius doctrine). Compass therefore *recommends* graduating +to a container-backed tier, and the onboarding surfaces say so — but the host +tier is not crippled, feature-gated, or nagged into disuse to force the move. +A user who stays on the host tier indefinitely is a supported user, strictly +better off than on a bare CLI agent at the same exposure. + +The same guidance-not-gate posture holds one tier up: podman is the permanent +self-host entry tier, microVM the recommended upgrade — recommended in the +docs, never required for single-tenant self-host. + +## Not yet specified + +This spec mixes current behavior with ruled strategy. The line: + +- **Current:** the podman and microVM backends behind `SelectBackend` + (`go/internal/runtime/microvm.go:117-125`), podman as the default, egress + enforcement on both, and the trust-model split itself (DL-325, Active). +- **Not yet built:** the **host tier** in its entirety — there is today no + host/process backend and no `"host"` value in `SelectBackend`. Its design + lives in the + [host runtime tier record](../../designs/infra/runtime/compass-host-runtime-tier/design.md). +- **Not yet built:** the embedded-local front door's app architecture + (DL-319/DL-320's dual-mode revival) is designed in the compass-native + lane's embedded-revival record and lands there. +- **Future work, not designed:** an OS-sandbox egress mode for the host tier + (bubblewrap / sandbox-exec) is a possible later addition; nothing in this + spec depends on it. From e1f8ec5358e8f630e450934b1638096cdef031a4 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 7 Sep 2026 21:31:47 -0400 Subject: [PATCH 2/6] fix(runtime): rule the host tier uid, egress-presence, and agent transport (RIG-3512) Fold the three high review findings on the host-runtime-tier record. - The AsUser rejection rule would have errored on every provision exec: the fleet agent uid is the baked constant 1000, so the host tier now derives Workspace.UID from the Runner os.Geteuid(). The strict rejection stays. - The egress fail-loud triggered on a non-empty allowlist, which rejected a looser policy and silently discarded the strictest one (an empty host set is pure default-deny). It now triggers on the policy presence. - The plan omitted the agent transport: the socket and config paths are frozen in the compass-agent package and delivered by bind-mount, which a host process does not have. Adds the host provision leg and the path override. --- .../compass-host-runtime-tier/design.md | 322 +++++++++++++++++- 1 file changed, 313 insertions(+), 9 deletions(-) diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md index c2aa01f3..e7d486b8 100644 --- a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -1,7 +1,7 @@ # Compass host runtime tier Status: Draft -Tracking: RIG-TBD (host runtime tier) +Tracking: RIG-3512 Owner: compass-runner (runtime) → compass-agent (onboarding review) ## Problem / Intent @@ -81,7 +81,7 @@ semantics — including where the mapping is degenerate: | --- | --- | --- | | `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` is **degenerate**: there is no user switch — the process runs as whoever runs the Runner. The backend rejects (errors on) an `AsUser` naming a different uid rather than silently running it wrong. | +| `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. | @@ -94,6 +94,192 @@ The honest summary: `ExecStreaming`, `Exec`, `Stop`, `Remove` are real; `Resize` are degenerate by construction. The backend documents each degenerate case at the method, in these terms. +#### 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: + +```go +// 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): + +```go +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`): + +```go +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. `SpecDefaults` is 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.UID` is a `uint32` (`workspace.go:53`) while `os.Geteuid()` + returns an `int`, and it returns `-1` on platforms without the syscall — so the + derivation validates the value before narrowing rather than converting blindly. + +#### 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): + +```go +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`): + +```ts +/** + * 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`): + +```ts +/** + * 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 `/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): + +```ts +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": + +```ts +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 `Create` mints), 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 sets `HOME`/`COMPASS_WORKDIR`). +- `AGENT_SOCKET_PATH` and `AGENT_CONFIG_MOUNT_PATH` become **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.ts` already carries the precedent for the config half — + `MainDeps.configMount` is 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 The container tiers arm a default-deny nftables firewall **in the container's @@ -136,9 +322,44 @@ the firewall** when nobody did and nobody can. Instead: 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 a non-empty `EgressPolicy` allowlist fails - loud at provision ("host backend cannot enforce an egress policy"), never - silently ignores it. +- A host-tier launch that carries **any** `EgressPolicy` reaching 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`): + + ```go + // 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-allow` defaults to `""` + (`go/cmd/compass-runner/main.go:58-59`) and the parse turns that into a real + policy (`main.go:378-381`): + + ```go + 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. + `EgressPolicy` today draws no configured/unconfigured distinction: its only + accessor is `Hosts()` (`go/internal/runtime/egress.go:67`), so a zero-value + `EgressPolicy{}` and an explicit `AllowEgress()` are indistinguishable. T2 + adds a `configured bool` set by `AllowEgress`/`MustAllowEgress` plus a + `Configured()` accessor, and the host backend refuses any spec whose policy + reports configured. `Hosts()` and `NftScript()` 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.Egress` at + its zero value rather than calling `parseEgress` — 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` @@ -319,6 +540,10 @@ 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 @@ -335,6 +560,18 @@ promise — same access as the user's existing CLI agent, zero setup — and eac 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 + +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 - **Public repo.** No managed/multi-tenant product detail; managed-plane @@ -357,6 +594,20 @@ opt-in wrapped mode. 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.UID` comes from the + Runner's `os.Geteuid()`, never `agentuid.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. ## Plan @@ -370,10 +621,48 @@ opt-in wrapped mode. `SelectBackend` (`go/internal/runtime/microvm.go:117-125`) as `case "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 derives + `SpecDefaults.UID` from `os.Geteuid()` instead of `agentuid.AgentUID`, + validating the `int` → `uint32` narrowing (and the `-1` no-syscall case) + before use; the existing non-root check stays, so a root Runner is refused at + startup. The host backend's `Exec`/`ExecStreaming` reject an `AsUser` naming + any uid other than the Runner's own euid. + Interfaces: consumes `runner.SpecDefaults` as built at `main.go:148-156`; + produces `runtime.Workspace{UID: }` (`spec.go:89-93`, + `go/internal/runtime/workspace.go:51-53`). Tests: the derived uid equals + `os.Geteuid()` and is what every provision `AsUser` carries + (`go/internal/runtime/agent.go:203-204`); a mismatched `AsUser` errors; 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 `Provision` leg: 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 and `vsockGatewayEngine` legs: 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_PATH` and `AGENT_CONFIG_MOUNT_PATH` + become env-overridable, defaulting to today's literals. **This touches the + `compass-agent` package'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 at `host.go:198` + and `host.go:214`); produces two env vars on `AgentEnv.execSpec` + (`go/internal/runner/agent_exec.go:77-81`). Tests: the host leg appends no + mounts (mirroring `host_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 from `inGuestEgressArmer`) making `provision` skip `armEgress` while recording posture=unenforced; fail-loud on - a non-empty `EgressPolicy`; posture threaded into session state and rendered + ANY `EgressPolicy` that reaches provision (presence, not a non-empty + allowlist — empty is the strictest policy, `go/internal/runtime/egress.go:29-31`), + which requires adding a `configured bool` + `Configured()` to `EgressPolicy` + (today `Hosts()` at `egress.go:67` is its only accessor) and leaving the host + Runner profile's `SpecDefaults.Egress` at its zero value; posture threaded + into session state and rendered in the session UI/status surface. Interfaces: consumes the `provision` seam (`agent.go:307-312`); produces an egress-posture field on the session (exact proto/field shape decided at @@ -409,8 +698,17 @@ opt-in wrapped mode. - [ ] T1 — `HostRuntime` backend implementing the frozen `ContainerRuntime`, registered in `SelectBackend` as `host` +- [ ] T1a — host-tier uid derivation: `Workspace.UID` from `os.Geteuid()`, not + `agentuid.AgentUID`; `AsUser` rejects any other uid; launch proven on a + non-1000 euid +- [ ] T1b — host `Provision` leg: per-agent socket + config path in the handle's + state dir, threaded as env vars; `AGENT_SOCKET_PATH` / + `AGENT_CONFIG_MOUNT_PATH` env-overridable (touches the two pinned + `compass-agent` contract tests) - [ ] T2 — unenforced-egress posture: new marker (not `inGuestEgressArmer`), - fail-loud on policy, posture visible in session state/UI + fail-loud on ANY `EgressPolicy` reaching provision (presence, not + non-emptiness) via a new `Configured()` distinction, posture visible in + session state/UI - [ ] T3 — SecretSpec provider knob; host-tier profile pins `keyring://` - [ ] T4 — per-agent `$HOME` overlay for `.compass/{env,secrets}` - [ ] T5 — agent-driven config-import review (first-run onboarding flow) @@ -419,8 +717,9 @@ opt-in wrapped mode. ## Open Questions -- **Two motivations, one feature?** (load-bearing for scope, not for T1-T4 - correctness) Onboarding convenience and permanent host capability (workflows +- **Two motivations, one feature?** (load-bearing for scope, not for the + T1/T1a/T1b–T4 backend correctness) Onboarding convenience and permanent host + capability (workflows needing the real session bus/display, which no container tier can provide) are two motivations wearing one backend. This record ships the tier framed as the onboarding wedge and treats host-capability use as a supported @@ -452,6 +751,11 @@ invented here): for untrusted multi-tenant, podman the permanent self-host container tier, host the self-host onboarding tier — never valid for untrusted or multi-tenant operation. + The tier also pins two mechanism decisions: `Workspace.UID` is derived from + the Runner's `os.Geteuid()` rather than the baked `agentuid.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 From 1ca240ee0edece72b21b02eb7dfd29df7ff3e16e Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 8 Sep 2026 00:00:49 -0400 Subject: [PATCH 3/6] design(runtime): scope the host tier by trust domain, not deployment shape (RIG-3512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matt ruled the host tier is needed wherever a user runs an agent on their own machine, not only in a single-tenant deployment. The record had scoped it as a "self-host single-tenant onboarding tier", which contradicts DL-325 itself: that row says the boundary follows the trust model, NOT the deployment shape. A user running one agent on their own box puts exactly one trust domain on that host whatever topology their Server sits in. Re-scopes the tier as single-trust-domain, forbidden for untrusted work and for isolating mutually-distrusting principals — properties of the trust domain, not the product. Also rules the second open question: host capability (real session bus, display, device access) is a PERMANENT case, not onboarding scaffolding a user graduates off. That work has to run where the hardware and the session are. Withdraws the weaker "supported consequence, not a designed-for surface" framing; narrows the remaining question to the device/session-bus surface. Touches the placement prose, the constraint, the tier table and when-to-use in the living spec, and the proposed ledger row. markdownlint 0 issues/209 files; design-ledger-gate:ci and orion-ref-gate:check clean. Refs RIG-3512 --- .../compass-host-runtime-tier/design.md | 98 +++++++++++++------ docs/specs/runtime/runner-tiers.md | 25 +++-- 2 files changed, 86 insertions(+), 37 deletions(-) diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md index e7d486b8..eaf25bd0 100644 --- a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -57,17 +57,36 @@ 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: a self-host single-tenant onboarding tier for a user -running their own agents on their own machine. It is never valid for untrusted -or multi-tenant operation. Guidance stays "prefer container/microVM" — the -docs recommend graduating — but the tier is not gated or crippled to force it. +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). See Open Questions — this record ships the tier for onboarding and -raises the permanent-host-capability framing as a question rather than ruling -it. +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 @@ -588,8 +607,12 @@ probe leg is necessary but not sufficient, and the record takes both. 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 self-host single-tenant only** — never a valid backend - for untrusted or multi-tenant operation (DL-325's axis). +- **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 @@ -717,17 +740,29 @@ probe leg is necessary but not sufficient, and the record takes both. ## Open Questions -- **Two motivations, one feature?** (load-bearing for scope, not for the - T1/T1a/T1b–T4 backend correctness) Onboarding convenience and permanent host - capability (workflows - needing the real session bus/display, which no container tier can provide) - are two motivations wearing one backend. This record ships the tier framed as - the onboarding wedge and treats host-capability use as a supported - consequence, not a designed-for product surface. If host-capability is a - first-class permanent use case, it likely wants its own follow-up record - (device/session-bus documentation, multi-agent-on-host story). Recommendation: - accept the onboarding framing here; revisit host-capability as its own record - when a concrete workflow demands it. +- **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 @@ -742,15 +777,20 @@ Proposed rows for the coordinator to mint at freeze (described, ids not invented here): - **Host tier row**: a `host` backend joins `SelectBackend` - (`""`/`podman`/`microvm`/`host`) as the self-host single-tenant onboarding - tier — agent as a host process at the user's existing CLI-agent exposure; - egress explicitly unenforced (a declared posture, visible in session state, - never `EgressArmedInGuest`); blast-radius protections (host filesystem, - inter-agent isolation, egress) structurally absent and declared. **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 self-host onboarding tier — never valid for untrusted or - multi-tenant operation. + (`""`/`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, never `EgressArmedInGuest`); 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.UID` is derived from the Runner's `os.Geteuid()` rather than the baked `agentuid.AgentUID`, and the agent's socket/config rendezvous paths become env-overridable (defaults diff --git a/docs/specs/runtime/runner-tiers.md b/docs/specs/runtime/runner-tiers.md index d5dfcb73..591aab39 100644 --- a/docs/specs/runtime/runner-tiers.md +++ b/docs/specs/runtime/runner-tiers.md @@ -52,7 +52,7 @@ today accepts `""`/`"podman"` and `"microvm"` and rejects anything else | Tier | Isolation boundary | Egress enforcement | Trust model served | | --- | --- | --- | --- | -| **host** *(not yet built)* | None — the agent runs as a process on the operator's own machine | **Explicitly unenforced** (see below) | Single-tenant only: the operator's own box, own code, own agents | +| **host** *(not yet built)* | None — the agent runs as a process on the operator's own machine | **Explicitly unenforced** (see below) | Single **trust domain**: the operator's own box, own code, own agents. Not scoped by deployment shape — available to any user running an agent on their own machine | | **podman** | Rootless container (shared host kernel) | Enforced: default-deny nftables in the container's own netns | Self-host single-tenant — the permanent supported entry tier | | **microVM** | Hardware virtualization (cloud-hypervisor/KVM) | Enforced: armed in-guest by the backend | Required for untrusted multi-tenant; recommended self-host upgrade | @@ -72,13 +72,22 @@ today accepts `""`/`"podman"` and `"microvm"` and rejects anything else { EgressArmedInGuest() bool }`), because that marker means a backend armed egress itself — claiming it would be false. Host mode states plainly that egress policy is not enforced. -- **When to use:** onboarding — near-zero setup, replicating the user's - existing CLI-agent posture with Compass's server, comms, and config - machinery on top; and host-capability work that a container cannot reach. -- **When NOT to use:** any deployment with an untrusted tenant, and any - deployment where egress policy must actually bind. It is also not the - preferred steady state for anyone (see - [Standing guidance](#standing-guidance)). +- **When to use:** two permanent cases, on the operator's own machine. + **Onboarding** — near-zero setup, replicating the user's existing CLI-agent + posture with Compass's server, comms, and config machinery on top. And + **host-capability work a container cannot reach** — the real session bus, + display, or device access (window-management tooling driving the live + desktop, for instance). The second is not a transitional case a user + graduates off: that work has to run where the hardware and the session are. +- **Scope:** the tier is bounded by **trust domain, not deployment shape**. + Any user running an agent on their own machine puts exactly one principal + on that host — themselves — whatever topology their Server sits in. The + deployment a Server serves does not enter the analysis. +- **When NOT to use:** untrusted work, and any case that needs to isolate + mutually-distrusting principals from each other — a shared kernel and a + shared `$HOME` cannot separate parties. Also unsuitable wherever egress + policy must actually bind. It is not the preferred steady state for + general agent work (see [Standing guidance](#standing-guidance)). - **What it does and does not protect:** per DL-024 the container was never credential avoidance — agents receive the user's own secrets on every tier. The host tier gives up only the blast-radius boundary; it does not hand the From 3d164e1d0c8b3843afe50b565fa99153704e3b2c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 8 Sep 2026 15:40:30 -0400 Subject: [PATCH 4/6] design(runtime): record the Container* vocabulary misnomer and rule Workload* (RIG-3553) The host tier makes ContainerRuntime span a third non-container backend, after MicroVMRuntime already made it span a second. SelectBackend's own comment says the container path goes away entirely, leaving the interface with no container implementation. Rule the successor name (Workload*), reject Session* (already the user-facing conversational stream, and one environment outlives many sessions) and Sandbox (asserts isolation the host tier does not provide). AgentRuntime is not renamed; it is the per-agent lifecycle facade and that name is accurate. The ~365-reference migration is tracked in RIG-3553, sequenced after the microVM default flip. --- .../compass-host-runtime-tier/design.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md index eaf25bd0..a0c7b48e 100644 --- a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -770,6 +770,31 @@ probe leg is necessary but not sufficient, and the record takes both. - **`Resize` future** (non-load-bearing, deferred): a systemd user-scope / cgroup v2 delegation could make host `Resize` real; deferred until C3's resize behavior lands anywhere. +- **The `Container*` vocabulary is a known misnomer** (deferred, tracked + separately): this tier makes `ContainerRuntime` span a third backend that is + not a container — direct host processes — after `MicroVMRuntime` already made + it span a second (`go/internal/runtime/microvm.go:71`). `SelectBackend`'s own + comment states the endgame (`microvm.go:110-116`): once microVM is the sole + runtime the container path goes away entirely, leaving an interface named + `ContainerRuntime` with no container implementation. The misnomer is not the + interface alone: `ContainerID` (214 refs) already keys microVM sessions + (`microvm.go:84`) and would key host process groups here, and `ContainerSpec` + (58 refs) is likewise backend-neutral in practice. + + Ruled name: **`Workload*`** (`WorkloadRuntime`/`WorkloadID`/`WorkloadSpec`) — + verified unused in Go and proto, and true of a container, a microVM guest, + and a host process group alike. `Session*` was rejected: a session is already + the user-facing conversational stream (`SessionEvent` and siblings in + `proto/compass/v1/compass.proto`), one environment outlives many sessions, so + the name would assert a one-to-one relation that does not hold. `Sandbox` was + rejected as asserting isolation the host tier explicitly does not provide. + `AgentRuntime` (`go/internal/runtime/agent.go:155`) is **not** renamed — it is + the per-agent lifecycle façade over a backend, and that name is accurate. + + Deliberately **not** in this record's scope: a ~365-reference mechanical + rename would swamp the design content here, and the freeze at S1 covers the + method set, not the identifier. Sequenced after the microVM default flip, + when the vocabulary is forced by reality rather than argued. ## Ledger delta From 5caad8bf87ba71a4b107e4889537e3cf4d72185f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 8 Sep 2026 16:00:02 -0400 Subject: [PATCH 5/6] refactor(runtime)!: rename the Container* seam to Workload* (RIG-3553) The runtime interface was named for one of its backends, and it now has four: podman containers, microVM guests, Apple container on macOS, and the direct host processes the host tier adds. SelectBackend already says the podman path eventually goes away, which would leave an interface named ContainerRuntime with no container implementation. ContainerRuntime -> WorkloadRuntime (85), ContainerID -> WorkloadID (198), ContainerSpec -> WorkloadSpec (58), InContainerError -> InWorkloadError (8), plus the comment and design-record prose describing the seam. Genuine containers keep the old vocabulary: ContainerController (podman-only stack supervisor), ContainerRef (a message container), the testcontainer specs, and the container_name wire field, which is a compatibility boundary. AgentRuntime keeps its name; it is the per-agent lifecycle facade. The S1 freeze reserves the method set, not the identifier. No signature, method set, or behaviour changed. --- .../compass-agent-config-delivery/design.md | 2 +- .../agent/compass-agent-container-runtime.md | 2 +- .../compass-agent-spawn-despawn/design.md | 2 +- .../compass-elastic-session-runtime/design.md | 40 +++--- .../microvm-runner.md | 22 ++-- .../microvm-v2b-guest-supervisor-exec.md | 40 +++--- .../microvm-v3-egress-in-guest.md | 46 +++---- .../microvm-v4-gateway-over-vsock.md | 6 +- .../microvm-v5-preflight-boot-canary.md | 14 +- ...rovm-v7-teardown-recovery-observability.md | 16 +-- .../microvm-v8-acceptance-suite-benchmarks.md | 12 +- .../p2-persistent-session-volume.md | 4 +- .../virtualfs-descope-amendment.md | 6 +- .../compass-host-runtime-tier/design.md | 65 ++++++---- .../design.md | 14 +- .../compass-runner-arbitrary-uid/design.md | 26 ++-- .../apple-container-macos-runner/design.md | 22 ++-- .../spike-findings.md | 2 +- .../repo/compass-agent-effect-otel/design.md | 2 +- go/cmd/compass-runner/main.go | 6 +- go/cmd/compass-runner/main_test.go | 14 +- go/internal/compute/compute.go | 6 +- go/internal/compute/compute_test.go | 40 +++--- go/internal/compute/inplace.go | 6 +- .../gen/compass/v1/guest_control.pb.go | 4 +- go/internal/runner/agent_exec.go | 2 +- go/internal/runner/agent_exec_test.go | 18 +-- go/internal/runner/config_refresh_test.go | 10 +- .../runner/e2e_vsock_gateway_microvm_test.go | 8 +- go/internal/runner/helpers_test.go | 68 +++++----- go/internal/runner/host.go | 12 +- go/internal/runner/host_concurrency_test.go | 6 +- go/internal/runner/host_test.go | 12 +- go/internal/runner/host_vsock_gateway_test.go | 6 +- go/internal/runner/runner.go | 2 +- go/internal/runner/secrets_refresh_test.go | 4 +- .../runnerhub/integration_pgtest_test.go | 24 ++-- go/internal/runtime/agent.go | 42 +++--- go/internal/runtime/agent_test.go | 30 ++--- go/internal/runtime/contract_microvm_test.go | 12 +- go/internal/runtime/contract_podman_test.go | 10 +- go/internal/runtime/contract_suite_test.go | 48 +++---- .../runtime/egress_inguest_microvm_test.go | 8 +- .../runtime/egress_integrity_podman_test.go | 4 +- go/internal/runtime/microvm.go | 14 +- .../runtime/microvm_isolation_microvm_test.go | 8 +- go/internal/runtime/microvm_lifecycle.go | 42 +++--- .../runtime/microvm_lifecycle_microvm_test.go | 2 +- go/internal/runtime/microvm_lifecycle_test.go | 26 ++-- go/internal/runtime/microvm_preflight.go | 2 +- go/internal/runtime/microvm_start_test.go | 12 +- go/internal/runtime/podman.go | 121 +++++++++--------- go/internal/runtime/podman_test.go | 20 +-- go/internal/runtime/secrets_materialize.go | 8 +- .../runtime/secrets_materialize_test.go | 36 +++--- go/internal/runtime/userns_remap_test.go | 8 +- go/server/lifecycle_e2e_pgtest_test.go | 20 +-- proto/compass/v1/guest_control.proto | 4 +- 58 files changed, 544 insertions(+), 524 deletions(-) diff --git a/docs/designs/agent/compass-agent-config-delivery/design.md b/docs/designs/agent/compass-agent-config-delivery/design.md index d707bc42..26061a41 100644 --- a/docs/designs/agent/compass-agent-config-delivery/design.md +++ b/docs/designs/agent/compass-agent-config-delivery/design.md @@ -418,7 +418,7 @@ survives in this record. ### A second env surface (container `Env` at create) -`ContainerSpec.Env` exists (`podman.go:85-86`) and setting it at `Create` +`WorkloadSpec.Env` exists (`podman.go:85-86`) and setting it at `Create` would be easy — and wrong: env fixed at create cannot rotate, values ride `-e KEY=VALUE` into host-visible podman argv (the exposure RIG-1327 T5 eliminates, `compass-agent-container-runtime.md:828-833`), and it forks diff --git a/docs/designs/agent/compass-agent-container-runtime.md b/docs/designs/agent/compass-agent-container-runtime.md index 78367f8f..931aa4f9 100644 --- a/docs/designs/agent/compass-agent-container-runtime.md +++ b/docs/designs/agent/compass-agent-container-runtime.md @@ -820,7 +820,7 @@ declared secrets (file under `$HOME/.compass/secrets/` or env, per aggregate env file at `$HOME/.compass/env` (a **sibling** of the `secrets/` dir, so no secret named `env` can collide with it), rejecting values with newline/NUL (the env-file line grammar). - - `type SecretMaterializer struct { runtime ContainerRuntime }`; + - `type SecretMaterializer struct { runtime WorkloadRuntime }`; `func (m *SecretMaterializer) Install(ctx context.Context, handle *AgentHandle, secrets []secrets.ResolvedSecret) error` — routes by `Kind`: provider → `ProviderSeedScript`; gh (`SecretGH`) → `GHCredentials.SetupScript` (using `ResolvedSecret.Host`); generic diff --git a/docs/designs/agent/compass-agent-spawn-despawn/design.md b/docs/designs/agent/compass-agent-spawn-despawn/design.md index d6ca9604..5531001c 100644 --- a/docs/designs/agent/compass-agent-spawn-despawn/design.md +++ b/docs/designs/agent/compass-agent-spawn-despawn/design.md @@ -606,7 +606,7 @@ Remove(ctx context.Context, containerName string) error Red-first: dispatch table test (`dispatch_test.go` pattern, `dispatch_test.go:160-177`) + host test: Remove tears down a launched -container (fake ContainerRuntime records Stop+Remove), retires the bound +container (fake WorkloadRuntime records Stop+Remove), retires the bound session, closes the socket; a second Remove is a no-op. ### T4 — runnerhub: `Remove` relay + `RelayLifecycleCall` resolution edge diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md index 4369ef12..3627bc0d 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md @@ -73,7 +73,7 @@ Compass makes infra cost first-order. Nothing in Dogfood blocks on this. Compass ships as two products over one shared core: - **OSS core (AGPL, `RigelBuild/compass` — this repo).** The agent runtime and - every seam this record touches — `AgentRuntime` plus the `ContainerRuntime` / + every seam this record touches — `AgentRuntime` plus the `WorkloadRuntime` / `VirtualFS` / `ComputeRuntime` seams, the volume lifecycle, and the microVM boundary. All of this record's `go/internal/*` and `agent-image/*` citations are @@ -191,13 +191,13 @@ The agent gets the **same working copy the customer's own humans get**: ### The three seams The package layering (`go/internal/runtime/podman.go:10-20`) already isolates -the engine behind the `ContainerRuntime` interface +the engine behind the `WorkloadRuntime` interface (`go/internal/runtime/podman.go:286-324`) — "everything above depends on the interface, so a libpod-REST backend can replace it without touching a caller." The hardening work reuses that discipline: -- **`ContainerRuntime` — existing verbs frozen, extended additively.** It - remains the engine seam (create/start/exec/stop against a `ContainerID`, +- **`WorkloadRuntime` — existing verbs frozen, extended additively.** It + remains the engine seam (create/start/exec/stop against a `WorkloadID`, `go/internal/runtime/podman.go:286-324`), including `ExecStreaming` (`go/internal/runtime/podman.go:299-307`) for the long-lived agent process. Resize-in-place adds one verb — `Resize(ctx, id, ResourceLimits)` (a @@ -221,15 +221,15 @@ The hardening work reuses that discipline: - **`ComputeRuntime` — the elastic-compute seam (the one genuinely new abstraction).** Named `ComputeRuntime` (over `ExecRuntime`) because what it abstracts is the compute *capacity* an exec runs against, not the exec - mechanics `ContainerRuntime` already owns. It routes a heavy op to a + mechanics `WorkloadRuntime` already owns. It routes a heavy op to a backend: **run-in-place** (the session's own environment, optionally resized) vs **burst** (a bigger transient environment sharing the session's volume). It is justified by a capability no existing seam carries: - `ContainerRuntime.Exec` (`go/internal/runtime/podman.go:286-324`) models an + `WorkloadRuntime.Exec` (`go/internal/runtime/podman.go:286-324`) models an exec against a fixed, already-sized container, while a heavy op needs an exec whose *sizing and placement* are chosen by policy at call time. `Exec` is completion-shaped; a **streaming variant is reserved in the seam - now** (live stdio + kill/wait handle, mirroring how `ContainerRuntime` + now** (live stdio + kill/wait handle, mirroring how `WorkloadRuntime` splits `Exec`/`ExecStreaming`, `go/internal/runtime/podman.go:293-307`) for RIG-1720's agent-launched dev servers, even if unimplemented, so freezing the seam does not force a breaking change later. @@ -422,7 +422,7 @@ inside a real environment, never the absence of one. backend, never define one. 2. **Go through the seams (hard rule).** Every working-tree materialization goes through `VirtualFS`; every heavy-op exec goes through - `ComputeRuntime`; the engine stays behind `ContainerRuntime`. No direct + `ComputeRuntime`; the engine stays behind `WorkloadRuntime`. No direct `exec` for a heavy op, no raw-disk path outside the session volume. Every bypass deletes the incremental-hardening migration path; a bypass is a design violation, not a shortcut. @@ -507,7 +507,7 @@ built: ### S1 — the seams, landed with their fused in-container configurations (lane: infra) Freeze the two new Go seams and land their trivial fused-model -configurations end to end, with `ContainerRuntime`'s existing verbs frozen: +configurations end to end, with `WorkloadRuntime`'s existing verbs frozen: - **`VirtualFS`** — the thin source-of-tree seam plus its checkout backend. At S1 the destination is **today's clone-dir workspace** (the genuinely @@ -550,8 +550,8 @@ configurations end to end, with `ContainerRuntime`'s existing verbs frozen: fully buffered stdout/stderr, an accepted limit for whole-suite output until the streaming variant lands. `SpecBuilder` (`go/internal/runner/host.go:46-48`) derives the `WorkspaceSource`. - `ContainerRuntime` also gains the additively-reserved - `Resize(ctx, id ContainerID, limits ResourceLimits) error` — frozen here, + `WorkloadRuntime` also gains the additively-reserved + `Resize(ctx, id WorkloadID, limits ResourceLimits) error` — frozen here, unimplemented until C3 — so I1's microVM backend and every fake carry the full surface from the start and C3 lands no interface change. `ResourceLimits{CPUShares int, MemoryBytes int64}` is the concrete @@ -573,7 +573,7 @@ and — since winning that customer depends on having it — it is built early, not deferred behind the customer. The descoping of the split and the content-addressed VFS is what frees the capacity to build it now. -- **Backend behind `ContainerRuntime`:** slot a microVM OCI runtime +- **Backend behind `WorkloadRuntime`:** slot a microVM OCI runtime (krun/libkrun or kata) via podman's `--runtime` selection, so the engine seam (`go/internal/runtime/podman.go:286-324`) is reused rather than replaced where possible. The real work is above the seam: a microVM-bootable @@ -587,7 +587,7 @@ content-addressed VFS is what frees the capacity to build it now. stable absolute path, preserving the no-copy invariant P2/C3 rely on. - **Interfaces:** produces the microVM runtime binding behind - `runtime.ContainerRuntime` (runtime selection + the rootfs image build + + `runtime.WorkloadRuntime` (runtime selection + the rootfs image build + guest egress arming); consumes `runtime.EgressPolicy.NftScript()` (`go/internal/runtime/egress.go:71-107`). No new caller-facing seam — the boundary is an engine/runtime configuration behind the existing interface. @@ -658,7 +658,7 @@ The two elastic backends behind `ComputeRuntime`, and the routing policy that picks one: - **Resize-in-place:** raise the session environment's CPU/memory limits for - the op's duration, then restore, via `ContainerRuntime.Resize`. Available + the op's duration, then restore, via `WorkloadRuntime.Resize`. Available where the runtime supports live limit changes (rootless podman on cgroups v2); under the microVM boundary (I1) live memory hotplug is limited, so resize covers CPU/headroom cases and otherwise falls back to burst. The @@ -685,7 +685,7 @@ picks one: `ResourceClass ∈ {ClassInner, ClassResized, ClassBurst}`; consumes the P2 volume attach (burst mount), `runtime.EgressPolicy.NftScript()` (`go/internal/runtime/egress.go:87`) to arm the burst environment, and - `runtime.ContainerRuntime` for the transient environment's lifecycle. + `runtime.WorkloadRuntime` for the transient environment's lifecycle. Produces the routing-policy table + its config surface + the startup reconciliation pass. - **Depends:** S1, P2, I1 (the burst environment is I1's microVM boundary); @@ -782,7 +782,7 @@ relaunch on activity. (incremental-build probe); cold-idle archive→restore round-trip reconstructs the tree + `target/` byte-for-byte from the object store and the incremental-build probe still hits warm; a suspend leaks no container - (engine reconcile via `ContainerRuntime.Exists`) and a cold idle leaves no + (engine reconcile via `WorkloadRuntime.Exists`) and a cold idle leaves no local disk footprint; warm-start and rehydration latency asserted against a budget this task produces — D4's own measurement round sets the envelope (there is no pre-existing number), and E5's managed-user behavior suite @@ -821,9 +821,9 @@ order): `compute.ComputeRuntime` elastic-compute seam (in-environment passthrough backend, reserved streaming variant, fail-closed routing-policy shell) + provision wiring + `WorkspaceSource` variant; - `ContainerRuntime` existing verbs frozen, `Resize` added additively. + `WorkloadRuntime` existing verbs frozen, `Resize` added additively. - [ ] **I1** [infra] — microVM inter-tenant boundary: microVM OCI runtime - (krun/libkrun or kata) behind `ContainerRuntime` via podman `--runtime`, + (krun/libkrun or kata) behind `WorkloadRuntime` via podman `--runtime`, microVM-bootable rootfs image, guest-netns egress arming, virtio-fs volume mount, KVM-absent degrade-to-container path (parallel with M0/S1; consumed by C3). @@ -876,7 +876,7 @@ Nothing pinned in the Approach is re-opened here. reserves a streaming variant (live stdio + kill/wait handle) for agent-launched dev servers; the port-exposure and lifecycle wiring are RIG-1720's scope. **Recommendation:** freeze the reserved signature in S1 - mirroring `ContainerRuntime.ExecStreaming` + mirroring `WorkloadRuntime.ExecStreaming` (`go/internal/runtime/podman.go:299-307`); implement nothing here. 5. **[resolved — decided, now task I1] Inter-tenant isolation boundary = microVM.** The session environment and its bursts run model-written, @@ -886,7 +886,7 @@ Nothing pinned in the Approach is re-opened here. customer *depends on* having the boundary, deferring it behind the customer is circular. **Decision:** commit to a microVM inter-tenant boundary as the end-state (session and burst), built early (task I1) while the descoping of - split/VFS frees the capacity. The boundary slots behind `ContainerRuntime` + split/VFS frees the capacity. The boundary slots behind `WorkloadRuntime` as a microVM OCI runtime (krun/libkrun or kata via podman `--runtime`), so the seam is expected to hold; the image/boot/egress plumbing above it is the real work I1 owns. Through Dogfood + trusted-tenant Beta the rootless diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md index 9706e747..1397c8da 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md @@ -33,7 +33,7 @@ Runner depends on: (`agent.go:304`) — is a post-start `Exec`. With no exec into the guest, none of this runs. 2. **The agent's AF_UNIX gateway socket does not cross a VM boundary.** The - `ContainerSpec.Mounts` doc: "the per-container agent gateway socket is + `WorkloadSpec.Mounts` doc: "the per-container agent gateway socket is mounted read-write (the agent must connect() to it)" (`go/internal/runtime/podman.go:100-102`). Package `go/internal/runner/gateway` is "the Runner side of the agent->Runner call @@ -51,7 +51,7 @@ speaking a protocol over virtio-vsock**, with the host Runner driving create/exec/stdio/signal *and* the gateway control plane through that vsock channel. That is real Runner control-plane work regardless of VMM choice, and it is the honest scope of this record: a **dedicated microVM Runner backend** -— a second implementation behind `runtime.ContainerRuntime` — rather than a +— a second implementation behind `runtime.WorkloadRuntime` — rather than a config swap. This record details *under* the parent's frozen decisions (Decision 5: the @@ -66,11 +66,11 @@ permanent second runtime. ## Approach -A microVM backend as a **sibling `ContainerRuntime` implementation** beside +A microVM backend as a **sibling `WorkloadRuntime` implementation** beside `PodmanCLI`, selected by Runner config. `podman.go`'s own layering note anticipated exactly this seam use: "Everything above depends on the interface, so a libpod-REST backend can replace it without touching a caller" -(`go/internal/runtime/podman.go:11-13`). The `ContainerRuntime` interface +(`go/internal/runtime/podman.go:11-13`). The `WorkloadRuntime` interface (`podman.go:303-352`: Create/Start/Exec/ExecStreaming/Stop/Remove/Exists/ MountLabel/Resize) is the contract; `AgentRuntime`, the gateway, and the session lifecycle above it stay untouched. @@ -337,7 +337,7 @@ demand via cloud-hypervisor hotplug rather than reserving peak RAM (D5). ([CH README](https://github.com/cloud-hypervisor/cloud-hypervisor#objectives)), Rust, security-focused, runs rootless as an ordinary process, proven as a Kata VMM. Hotplug directly serves the S1-reserved - `ContainerRuntime.Resize` (`podman.go:342-351`, D5). Runs on KVM/MSHV, not + `WorkloadRuntime.Resize` (`podman.go:342-351`, D5). Runs on KVM/MSHV, not macOS HVF — acceptable because native-macOS-embedded is dropped (D2). Cost: we build the guest supervisor ourselves (would have been shared with the libkrun option). @@ -416,7 +416,7 @@ availability) surface first with minimal code. ### V1 — backend seam + selection + startup gate -A `MicroVMRuntime` skeleton implementing `runtime.ContainerRuntime`, plus the +A `MicroVMRuntime` skeleton implementing `runtime.WorkloadRuntime`, plus the config-driven backend selection in Runner startup. Through the transitional period (D2) both backends exist and selection resolves to the configured one, defaulting to the container path while microVM is proven; once microVM is the @@ -424,13 +424,13 @@ sole runtime the selection collapses to microVM with `VerifyMicroVMSupport` (V5) as a hard startup gate (D3 — no container fallback to select). - **Interfaces:** produces `runtime.MicroVMRuntime` satisfying - `runtime.ContainerRuntime` (`Create(ctx, ContainerSpec) (ContainerID, - error)`, `Start`, `Exec(ctx, ContainerID, ExecSpec) (ExecOutput, error)`, - `ExecStreaming(ctx, ContainerID, StreamingExecSpec) (*StreamingExec, + `runtime.WorkloadRuntime` (`Create(ctx, WorkloadSpec) (WorkloadID, + error)`, `Start`, `Exec(ctx, WorkloadID, ExecSpec) (ExecOutput, error)`, + `ExecStreaming(ctx, WorkloadID, StreamingExecSpec) (*StreamingExec, error)`, `Stop`, `Remove`, `Exists`, `MountLabel`, `Resize` — `podman.go:303-352`), every method returning a typed `ErrMicroVMNotImplemented` until V2b/V3 fill them in; produces - `runtime.SelectBackend(cfg RunnerConfig) (ContainerRuntime, error)`. + `runtime.SelectBackend(cfg RunnerConfig) (WorkloadRuntime, error)`. Consumes `RunnerConfig` (new fields `Backend string`, `MicroVM struct{ VMMPath, VirtiofsdPath, KernelImage, RootfsImage string }`). - **Test cycle:** selection unit tests (transitional: configured backend @@ -483,7 +483,7 @@ non-zero-exit-is-not-an-error contract (`podman.go:310-314`) and the over vsock; produces the filled `MicroVMRuntime` methods. Consumes V2a's artifacts and `BootConfig`. - **Test cycle:** contract tests asserting `MicroVMRuntime` and `PodmanCLI` - behave identically through the `ContainerRuntime` surface (shared + behave identically through the `WorkloadRuntime` surface (shared table-driven contract suite, KVM-gated for the microVM rows): exec exit codes, stdin feeding (`WriteAgentFile`'s stdin-not-argv invariant, `agent.go:241-248`), streaming stdio, kill/wait, uid enforcement (uid-0 diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md index a5b8ee5a..17fcea7a 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md @@ -13,7 +13,7 @@ parent's schedule-critical milestone (microvm-runner.md:466-476): grow seed, `proto/compass/v1/guest_control.proto:20-22`), and implement `MicroVMRuntime`'s Create/Start/Exec/ExecStreaming/Stop/Remove against it so the microVM backend behaves identically to `PodmanCLI` through the -`ContainerRuntime` surface (`go/internal/runtime/podman.go:303-352`). Every +`WorkloadRuntime` surface (`go/internal/runtime/podman.go:303-352`). Every method today is a typed stub ("returned by every MicroVMRuntime method until the in-guest control plane lands", `go/internal/runtime/microvm.go:48-55`); V2b fills them. Egress arming stays V3's; the gateway transport stays V4's. @@ -169,8 +169,8 @@ the nft-script branch errors as unimplemented until V3: ```proto message ProvisionRequest { string nft_script = 1; // V3: EgressPolicy.NftScript(); empty in V2b - uint32 default_exec_uid = 2; // the session's agent uid (ContainerSpec.UID) - map base_env = 3; // ContainerSpec.Env, the base env every exec inherits + uint32 default_exec_uid = 2; // the session's agent uid (WorkloadSpec.UID) + map base_env = 3; // WorkloadSpec.Env, the base env every exec inherits } message ProvisionResponse {} ``` @@ -218,7 +218,7 @@ carries no spec, yet Start is where V2b buries the `Provision` call, so V3's podman the arm rides a post-Start root-capable `Exec` from `AgentRuntime.provision` (`agent.go:293-307`) — an exec path the microVM backend REFUSES (uid-0/caps). So V3 cannot reuse that seam: the intended data -path is `ContainerSpec` growing an egress field captured at Create and +path is `WorkloadSpec` growing an egress field captured at Create and delivered by Start's Provision call (podman.go:89-116 has no egress field today). V2b does not build that field, but it records the assumption here so V3's designer inherits it explicitly rather than discovering the gap; if the @@ -250,7 +250,7 @@ refuses exec specs requesting uid 0 or capabilities" (microvm-runner.md:358-360). guestd rejects any `Exec`/`StartExec` whose `uid` is 0 with a typed Connect error, before spawning anything. An *absent* uid resolves to the session's default exec uid delivered by `Provision` -(`default_exec_uid`, the `ContainerSpec.UID` — the baked agent uid, +(`default_exec_uid`, the `WorkloadSpec.UID` — the baked agent uid, `podman.go:109-113`) — mirroring podman's "Nil runs as the image's default user (for the compass-agent image that is uid 1000, not root)" (`podman.go:119-121`), with the default supplied per session instead of baked @@ -269,7 +269,7 @@ a non-host CID is closed immediately. This lands in V2b (not V8, which only *probes* it) because V2b is what turns the port from a Health responder into an exec surface worth escalating to. -**Env base.** `ContainerSpec.Env` on podman is set on the container and thus +**Env base.** `WorkloadSpec.Env` on podman is set on the container and thus visible to execs; on the microVM backend the same base env arrives via `Provision.base_env` and guestd merges it under each exec's own `env` map (exec-specific keys win). Host-side assembly stays deterministic exactly as @@ -278,17 +278,17 @@ the wire, so determinism matters only for logging/tests. ### (c) `MicroVMRuntime` methods against the vsock service -The nine frozen signatures (`microvm.go:71-116`, `var _ ContainerRuntime = +The nine frozen signatures (`microvm.go:71-116`, `var _ WorkloadRuntime = (*MicroVMRuntime)(nil)`) are filled by translating each verb onto V2a's harness + the (a) service. `MicroVMRuntime` grows a per-session state table -(`ContainerID → *session`), where a `session` holds the V2a `BootConfig` +(`WorkloadID → *session`), where a `session` holds the V2a `BootConfig` (`go/internal/runtime/microvm/config.go:21-35`), the running `*microvm.VM` handle, the `GuestControl` client, and the runtime dir. The `microvm` package "depends on nothing in go/internal/runtime, so importing it there introduces no cycle" (`config.go:5-7`) — V2b is the planned importer. -- **`Create(ctx, ContainerSpec) (ContainerID, error)`** allocates without - booting (mirroring `podman create`): mint a session id (the `ContainerID` — +- **`Create(ctx, WorkloadSpec) (WorkloadID, error)`** allocates without + booting (mirroring `podman create`): mint a session id (the `WorkloadID` — there is no engine to print one, so the backend generates a random hex id and derives the runtime dir from it), create the per-session runtime dir (`/microvm//` — the layout V7 formalizes with pidfiles, @@ -553,7 +553,7 @@ execution, stdio, networking, and observability, plus a `rustjail` embedded OCI runtime); V2b is one exec session per VM (~4 RPCs), so adopting the agent would mean importing an order of magnitude more surface than the design needs, against the "guest supervisor is a thin exec supervisor" non-goal. (4) *Host -interface* — the acceptance bar is our frozen `runtime.ContainerRuntime` +interface* — the acceptance bar is our frozen `runtime.WorkloadRuntime` (`microvm.go:71-116`); no external agent implements it, so the host-side translation layer §(c) is ours regardless. The prior art proves the shape and the correctness model; the code stays a reference. @@ -563,7 +563,7 @@ the correctness model; the code stays a reference. Every task below inherits these; they restate the parent's binding decisions in V2b-concrete form. -- **The `ContainerRuntime` contract is the acceptance bar.** Every filled +- **The `WorkloadRuntime` contract is the acceptance bar.** Every filled method behaves identically to `PodmanCLI` through the interface (`podman.go:303-352`), specifically: a non-zero exec exit is a successful call returning `ExecOutput.ExitCode`, never an error (`podman.go:310-313`); @@ -787,7 +787,7 @@ U3, graceful Stop, idempotent Remove, `Exists` from the session table, (the per-session dir root), `DefaultCPUs int`, `DefaultMemoryMB int` — flagged OQ-D. Consumes U2 (guest behavior), U3 (`GuestExec`), the V2a harness (`Launch`/`Shutdown`/`BootConfig`, `launch.go`, `config.go`), and - `ContainerSpec`/`ExecSpec`/`StreamingExecSpec` unchanged. + `WorkloadSpec`/`ExecSpec`/`StreamingExecSpec` unchanged. - **Test cycle:** hardware-independent: spec→BootConfig assembly (paths, CID/ port allocation, mount→FSSharedDir, refusal of inexpressible specs per OQ-C), spec→ExecRequest mapping incl. numeric-uid parsing and env merge, @@ -801,14 +801,14 @@ U3, graceful Stop, idempotent Remove, `Exists` from the session table, exits before the kill escalation — proving the graceful preamble is not dead weight that always burns the full timeout. -### U5 — the shared `ContainerRuntime` contract suite +### U5 — the shared `WorkloadRuntime` contract suite The parent's V2b acceptance (microvm-runner.md:485-490): one table-driven suite asserting `MicroVMRuntime` and `PodmanCLI` behave identically through the interface, run against both backends. - **Interfaces:** produces `go/internal/runtime/contract_test.go`-class - shared suite parameterized over a `ContainerRuntime` factory; the podman + shared suite parameterized over a `WorkloadRuntime` factory; the podman rows gate on rootless podman availability (the existing suite's pattern), the microVM rows on `microvmtest.Require` (`microvmtest.go:107-128`). Consumes U4 and the existing `PodmanCLI`. @@ -848,7 +848,7 @@ the interface, run against both backends. - [ ] U4 — `MicroVMRuntime` lifecycle: Create/Start/Exec/ExecStreaming/ Stop/Remove/Exists/MountLabel behind the frozen signatures (Exists + dup-name Create keyed on `spec.Name`) -- [ ] U5 — shared ContainerRuntime contract suite (podman + microVM rows; +- [ ] U5 — shared WorkloadRuntime contract suite (podman + microVM rows; microVM rows KVM-gated) + Q-budget numbers ## Open Questions @@ -889,9 +889,9 @@ The non-load-bearing OQ-E/OQ-F stand at their recommendations. sketch, forced by buf lint regardless, not a contradiction of a decision. - **OQ-C (load-bearing) — mount expressiveness in V2b, and who owns the real mount shapes.** podman accepts arbitrary bind mounts - (`ContainerSpec.Mounts`, `podman.go:100-103`); the microVM backend has + (`WorkloadSpec.Mounts`, `podman.go:100-103`); the microVM backend has exactly one virtio-fs share in the V2a harness (the `workspace` tag, - `config.go:29-31`). The current producer of `ContainerSpec.Mounts` is + `config.go:29-31`). The current producer of `WorkloadSpec.Mounts` is `agentHost.Provision`, which on EVERY launch unconditionally appends two mounts — the gateway socket (`host.go:177`) and the read-only agent-config tree (`host.go:193`) — plus any operator `SpecDefaults.Mounts` (`spec.go:30`). @@ -958,7 +958,7 @@ The non-load-bearing OQ-E/OQ-F stand at their recommendations. both backends' Wait errors satisfy it (podman by the fallback, microVM by constructing it in `waitFunc`). ~10 host-side lines, guarded by a podman-row regression test, owned by U3b. This widens one runner-side symbol above the - `ContainerRuntime` interface — sanctioned because the prototype holds no + `WorkloadRuntime` interface — sanctioned because the prototype holds no interface immutable; the alternative (leave `isDeliberateKill` alone and accept that microVM Stop cannot distinguish deliberate kill from crash) was rejected as it breaks the crash-vs-stop signal D4 depends on @@ -974,7 +974,7 @@ teardown, D4's supervisor split) and resolves them within the parent's decisions. Two resolutions are genuinely new cross-record calls: OQ-A's no-credential-plus-boot-nonce auth resolution of the proto header's flagged question, and OQ-G's shared deliberate-kill error taxonomy on the -`ContainerRuntime` surface. Both are candidates for DL rows if Matt wants them +`WorkloadRuntime` surface. Both are candidates for DL rows if Matt wants them citable outside this record's lineage; both bind surfaces this record and its parent own (the GuestControl transport; the runtime error contract), so the recommendation is to keep them here. The caller owns the ledger delta at PR diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md index 209fa26f..e7ad1985 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md @@ -36,22 +36,22 @@ The load-bearing arm-routing fork is (a)-(c); every resolution is also listed in `## Open Questions` for the pre-freeze batch, and the body designs against the recommended option. -### (a) How `NftScript()` reaches the backend: `ContainerSpec.Egress` +### (a) How `NftScript()` reaches the backend: `WorkloadSpec.Egress` Today the policy stops at the `AgentRuntime` layer: `AgentSpec.Egress` (`go/internal/runtime/agent.go:40-42`) is consumed only by `AgentRuntime.armEgress`, which execs the script into the running container -(`agent.go:303-309`). `ContainerSpec` — the only thing a `ContainerRuntime` +(`agent.go:303-309`). `WorkloadSpec` — the only thing a `WorkloadRuntime` backend ever sees (`go/internal/runtime/podman.go:88-114`) — carries no egress field. The V2b record already recorded this exact gap as V3's inheritance: -"the intended data path is `ContainerSpec` growing an egress field captured at +"the intended data path is `WorkloadSpec` growing an egress field captured at Create and delivered by Start's Provision call" (microvm-v2b-guest-supervisor-exec.md:214-226). -**Resolution: `ContainerSpec` grows `Egress EgressPolicy`.** +**Resolution: `WorkloadSpec` grows `Egress EgressPolicy`.** - `AgentRuntime.createAndStart` sets it from `spec.Egress` when assembling the - `ContainerSpec` (`agent.go:262-272`). + `WorkloadSpec` (`agent.go:262-272`). - `PodmanCLI` **ignores** the field entirely: `createArgs` is untouched, so the podman argv — and the whole podman path — stays byte-identical. Podman keeps arming via the post-start `armEgress` exec as before ((c)). @@ -86,7 +86,7 @@ Two candidate owners for issuing the arm: transactional step (`microvm_lifecycle.go:269-310`); V3 adds `NftScript: session.nftScript` to that same request. One RPC provisions *and* arms; the gate opens only when both succeed. - - Pro: the `ContainerRuntime` contract identity holds — on podman, + - Pro: the `WorkloadRuntime` contract identity holds — on podman, `Start` then `Exec` works with no intermediate call, and the V2b contract suite asserts exactly that identity on both backends (contract_microvm_test.go:5-9, microvm_lifecycle_test.go's @@ -119,7 +119,7 @@ would **fail** (nft as a capability-less uid), failing every microVM provision. It must not run on this backend. Three candidates: -- **Option A (rejected): grow `ContainerRuntime` with an +- **Option A (rejected): grow `WorkloadRuntime` with an `ArmEgress(ctx, id, EgressPolicy) error` verb** (podman impl = today's exec moved verbatim; microVM impl = no-op). Clean in the abstract, but it violates the interface's freeze discipline — the surface was deliberately @@ -138,7 +138,7 @@ must not run on this backend. Three candidates: - **Option C (recommended): a backend capability probe in `AgentRuntime.provision`.** `MicroVMRuntime` gains one exported marker method, `EgressArmedInGuest() bool` (returns true), NOT on the - `ContainerRuntime` interface. `AgentRuntime.provision` type-asserts an + `WorkloadRuntime` interface. `AgentRuntime.provision` type-asserts an unexported single-method interface and skips `armEgress` when the backend self-arms: @@ -146,7 +146,7 @@ must not run on this backend. Three candidates: // in agent.go type inGuestEgressArmer interface{ EgressArmedInGuest() bool } - func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { + func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentSpec) error { if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err @@ -228,8 +228,8 @@ is the full default-drop base ruleset with an empty allowlist (`egress.go:29-34,109-115`) — there is no "no policy" representation. `MicroVMRuntime.Start` therefore **always** sends `session.nftScript` (never empty for a session created through -`ContainerSpec`), and every microVM session boots default-deny even when a -direct `ContainerRuntime` caller never set `Egress`. That is a deliberate +`WorkloadSpec`), and every microVM session boots default-deny even when a +direct `WorkloadRuntime` caller never set `Egress`. That is a deliberate divergence from podman, where a caller that skips `armEgress` gets an unfirewalled container: on this backend a silent open-egress VM is structurally impossible, which is the stronger reading of the parent's @@ -290,14 +290,14 @@ Every task below inherits these. stays ignored on this backend (`microvm_lifecycle.go:140-143`). - **The podman path is byte-identical.** No change to `createArgs`, to `armEgress`'s exec (`agent.go:300-309`), or to any podman argv; `PodmanCLI` - ignores `ContainerSpec.Egress` and does not implement the (c) probe. The + ignores `WorkloadSpec.Egress` and does not implement the (c) probe. The existing podman suites run unchanged. - **`EgressPolicy`/`NftScript()` consumed unchanged** (`egress.go:71-107`) — same script on both backends, per the parent's V3 Interfaces (microvm-runner.md:498-502). - **No proto wire change.** Doc-comment updates only (§(f)); `buf lint` + `buf breaking` green; internal-go lane only. -- **Frozen `ContainerRuntime` interface untouched.** The (c) probe is a marker +- **Frozen `WorkloadRuntime` interface untouched.** The (c) probe is a marker method on `MicroVMRuntime` + an unexported assertion in `AgentRuntime`, never an interface verb (`podman.go:379-388` discipline). - **KVM-gated vs hermetic split** (V2b GC, microvm-v2b-guest-supervisor-exec.md: @@ -362,11 +362,11 @@ and the `Provision` handler comment (`supervisor.go:137-140`). ### W2 — host: thread `spec.Egress` to `ProvisionRequest.nft_script`; probe-and-skip `armEgress` -The §(a)+(c) host half: `ContainerSpec.Egress`, the session capture, the +The §(a)+(c) host half: `WorkloadSpec.Egress`, the session capture, the Start-intrinsic delivery, the `AgentRuntime` probe. - **Interfaces:** produces - - `ContainerSpec.Egress EgressPolicy` (new field, `podman.go:88-114`; + - `WorkloadSpec.Egress EgressPolicy` (new field, `podman.go:88-114`; doc-comment states podman ignores it — the podman arm rides `AgentRuntime.armEgress`); - `microvmSession.nftScript string` recorded in `MicroVMRuntime.Create` as @@ -419,19 +419,19 @@ Start-intrinsic delivery, the `AgentRuntime` probe. independent hermetic proof of the script-delivery + fail-Start contract, so the seams are preferred; - `func (m *MicroVMRuntime) EgressArmedInGuest() bool { return true }` - (marker, NOT on `ContainerRuntime`); + (marker, NOT on `WorkloadRuntime`); - the unexported probe in `agent.go`: `type inGuestEgressArmer interface{ EgressArmedInGuest() bool }`, checked at the top of `AgentRuntime.provision` (`agent.go:290-293`) to skip `armEgress` when satisfied; `armEgress` itself unchanged (`agent.go:300-309`); plus a one-line pointer comment beside the - `ContainerRuntime` freeze note (`podman.go:379-388`) naming - `inGuestEgressArmer`, so a future backend — or a `ContainerRuntime` + `WorkloadRuntime` freeze note (`podman.go:379-388`) naming + `inGuestEgressArmer`, so a future backend — or a `WorkloadRuntime` decorator, which would otherwise swallow the marker and silently re-enable `armEgress` on the microVM backend (a loud but hard-to-diagnose launch failure) — discovers the probe; - `AgentRuntime.createAndStart` setting `Egress: spec.Egress` in the - `ContainerSpec` literal (`agent.go:263-272`). + `WorkloadSpec` literal (`agent.go:263-272`). Consumes `EgressPolicy`/`NftScript()` unchanged. - **Test cycle (hermetic):** (1) a fake runtime WITHOUT the marker still receives the `armEgress` exec (existing @@ -464,7 +464,7 @@ opening with `microvmtest.Require(t)`. pattern) — no new production code. Produces the KVM-gated test files only. - **Test cycle (KVM-gated):** 1. **Allowlisted reachable / non-allowlisted blocked, both families:** boot - a session whose `ContainerSpec.Egress` allowlists one real host; in-guest + a session whose `WorkloadSpec.Egress` allowlists one real host; in-guest execs (agent uid) show the allowlisted host connects and a non-allowlisted raw IPv4 and IPv6 destination time out — mirroring the podman lifecycle proof (lifecycle_test.go:137-140) inside the guest @@ -494,7 +494,7 @@ opening with `microvmtest.Require(t)`. - [ ] W1 — guestd `Provision` arms `nft_script` as guest root (replaces `CodeUnimplemented`), fail-closed, gate stays closed on failure -- [ ] W2 — `ContainerSpec.Egress` threaded Create→Start→`ProvisionRequest`; +- [ ] W2 — `WorkloadSpec.Egress` threaded Create→Start→`ProvisionRequest`; `AgentRuntime.provision` probe-and-skips `armEgress` on self-arming backends (podman path byte-identical) - [ ] W3 — KVM-gated in-guest egress integration suite (allow/deny both @@ -519,14 +519,14 @@ recommendation. because the parent record is frozen and the literal routing differs. **Recommendation:** ratify Start-intrinsic arming as the correct reading. - **OQ-2 (load-bearing) — the (c) probe mechanism.** Marker-method probe - (recommended, §(c) Option C) vs growing the frozen `ContainerRuntime` + (recommended, §(c) Option C) vs growing the frozen `WorkloadRuntime` interface (Option A). The probe keeps the interface frozen and the blast radius at one call site; the interface verb is the more discoverable shape but contradicts the S1 no-interface-change discipline (`podman.go:379-388`) and touches every fake. **Recommendation:** Option C. - **OQ-3 (load-bearing) — always-arm on the microVM backend (§(e)).** Every microVM Start arms at least default-deny, including direct - `ContainerRuntime` callers (the KVM contract/e2e suites), a conceded + `WorkloadRuntime` callers (the KVM contract/e2e suites), a conceded divergence (7) from podman. Risk: an existing KVM row that needs external egress would start failing — believed none (exec traffic is loopback/vsock), verified on hardware by W3(4) before freeze is exercised. diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md index 1e009c49..5977bc50 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md @@ -178,7 +178,7 @@ microvm-v3-egress-in-guest.md:138-163): - `MicroVMRuntime` gains one exported method, `AgentGatewayEndpoint(name string) (socketPath string, ok bool)`, returning `microvm.GatewaySocketPath(session.cfg.VsockSocket, agentGatewayVsockPort)` - for the named session — NOT on the frozen `ContainerRuntime` interface. + for the named session — NOT on the frozen `WorkloadRuntime` interface. - `agentHost` probes its engine via an unexported single-method interface, `type vsockGatewayEngine interface { AgentGatewayEndpoint(string) (string, bool) }`. - Probe **absent** (podman, every fake): today's path byte-identical — @@ -384,7 +384,7 @@ Every task below inherits these. mount list Provision builds on podman, `AGENT_SOCKET_PATH`, or any podman argv; fakes don't implement the (c) probe, so every existing hermetic runner suite runs unchanged. -- **Frozen `ContainerRuntime` interface untouched.** The (c) probe is a +- **Frozen `WorkloadRuntime` interface untouched.** The (c) probe is a marker/endpoint method on `MicroVMRuntime` + an unexported assertion in `agentHost`, never an interface verb — the V3-ratified discipline (microvm-v3-egress-in-guest.md:300-302). @@ -473,7 +473,7 @@ byte-identical. - `func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool)` — resolves the session by `name` (the `Exists` lookup shape) and returns `GatewaySocketPath(session.cfg.VsockSocket, agentGatewayVsockPort)`; - `(“”, false)` for an unknown name. NOT on `ContainerRuntime`; + `(“”, false)` for an unknown name. NOT on `WorkloadRuntime`; - in `go/internal/runner/host.go`: `type vsockGatewayEngine interface { AgentGatewayEndpoint(string) (string, bool) }`, asserted on `h.engine` at the top of `Provision` diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md index d27b7010..40859528 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md @@ -86,20 +86,20 @@ broken engine isn't sent chasing tokens first): *per selected backend*; 4. everything else unchanged. -**How main picks the preflight.** The frozen `ContainerRuntime` interface +**How main picks the preflight.** The frozen `WorkloadRuntime` interface gains nothing (the V3/V4-ratified discipline: capability probes are unexported single-method interface assertions, never interface verbs — microvm-v4-gateway-over-vsock.md § Global Constraints, "Frozen -`ContainerRuntime` interface untouched"). `main.go` grows one extracted, +`WorkloadRuntime` interface untouched"). `main.go` grows one extracted, hermetically testable helper: ```go // verifyBackendPreflight runs the selected engine's startup preflight: the // podman userns-remap check iff the engine is the podman backend, the microVM // support check iff it is the microVM backend. Probed via unexported -// single-method interfaces so the frozen ContainerRuntime interface is +// single-method interfaces so the frozen WorkloadRuntime interface is // untouched and a test fake can present either capability. -func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error +func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error ``` with two unexported probe interfaces in `package main`: @@ -326,7 +326,7 @@ Two candidate shapes for the canary's boot path: - **Teardown is Remove's, not a second copy.** Start already tears down its own partial boot on any failure (`microvm_lifecycle.go:374-383`), and Remove is the idempotent teardown; the canary adds no new cleanup path. - Mechanics: a canary `ContainerSpec` with a reserved name + Mechanics: a canary `WorkloadSpec` with a reserved name (`compass-canary-<8-hex random>` — outside the agent-session prefix so it cannot collide with `runner.AgentContainerNamePrefix` sessions), a **single throwaway workspace mount** — a freshly `os.MkdirTemp`'d host dir mounted @@ -477,7 +477,7 @@ Every task below inherits these. today's `VerifyUsernsRemapSupport` call with today's semantics; no podman argv, check, or message changes. Existing hermetic runner suites run unchanged. -- **Frozen `ContainerRuntime` interface untouched.** Preflight and canary are +- **Frozen `WorkloadRuntime` interface untouched.** Preflight and canary are `*MicroVMRuntime` methods reached from `main` via unexported single-method probe interfaces — the V3/V4-ratified discipline (microvm-v4-gateway-over-vsock.md § Global Constraints). @@ -569,7 +569,7 @@ added by W3. - the reorder: `backends.selectEngine()` moved ahead of the preflight (currently `main.go:153` and `main.go:94-104` respectively); the legibility comment rewritten for the per-backend contract; - - `func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error` + - `func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error` in `package main`, with unexported probes `type microVMPreflighter interface { VerifyMicroVMSupport(context.Context) error }` and `type podmanPreflighter interface { VerifyUsernsRemapSupport(context.Context) error }`; diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md index bbb488cc..10e90c6d 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md @@ -622,7 +622,7 @@ package's timeout onto `*runtime.TimeoutError` at this exact seam, // because the session's VM died mid-session — the VMM exited or virtiofsd // died (fatal, no remount-and-hope; microvm-runner.md:249-253). type SessionDeadError struct { - ID ContainerID + ID WorkloadID Cause string // "vmm" | "virtiofsd" } @@ -1089,7 +1089,7 @@ Every task below inherits these. message changes; the one shared touchpoint (the `backend`-labelled session counter in the Runner host) reads the backend name through an unexported probe and changes no podman behavior. -- **Frozen `ContainerRuntime` interface untouched.** `ReapOrphans` and the +- **Frozen `WorkloadRuntime` interface untouched.** `ReapOrphans` and the backend-name probe are `*MicroVMRuntime` methods / unexported interface assertions — the V3/V4-ratified single-method-probe discipline (`main.go:185-203`). @@ -1295,7 +1295,7 @@ assertion; see the Plan preamble). - `func (vm *VM) VMMExited() bool` — nil-safe delegation to the VMM child's `hasExited()` (PR #912 `launch.go:96-107`; a channel read, not a zombie-blind signal-0 probe), for `Exec`'s in-flight wrap (§(c)); - - `type SessionDeadError struct { ID ContainerID; Cause string }` with + - `type SessionDeadError struct { ID WorkloadID; Cause string }` with `Error()` in `go/internal/runtime` (untagged file, beside `CommandError`); - `microvmSession.deadCause` + `microvmSession.deadEpoch` + @@ -1475,13 +1475,13 @@ The §(d) main.go ordering fix and the `backend`-labelled session metric. ```go type startupHooks struct { setupOtel func(ctx context.Context) (func(), error) - selectEngine func() (runtime.ContainerRuntime, error) - preflight func(ctx context.Context, engine runtime.ContainerRuntime) error - lockRunRoot func(engine runtime.ContainerRuntime) (runtime.RunRootLock, func(), error) - reap func(ctx context.Context, engine runtime.ContainerRuntime, held runtime.RunRootLock) error + selectEngine func() (runtime.WorkloadRuntime, error) + preflight func(ctx context.Context, engine runtime.WorkloadRuntime) error + lockRunRoot func(engine runtime.WorkloadRuntime) (runtime.RunRootLock, func(), error) + reap func(ctx context.Context, engine runtime.WorkloadRuntime, held runtime.RunRootLock) error } - func startup(ctx context.Context, h startupHooks) (runtime.ContainerRuntime, func(), error) + func startup(ctx context.Context, h startupHooks) (runtime.WorkloadRuntime, func(), error) ``` which calls them in the order `setupOtel` → `selectEngine` → diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md index af739a14..bd205d5e 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md @@ -54,7 +54,7 @@ Three gaps V8 closes: guest). 2. **The contract and failure-mode proofs stop below the session lifecycle.** - The shared `ContainerRuntime` contract suite runs on the microVM backend + The shared `WorkloadRuntime` contract suite runs on the microVM backend (`contract_microvm_test.go:34-69`), but the S1-frozen seam contract the parent's design demands — "a session and a burst both boot on the microVM runtime and pass the S1/C3 contract tests unchanged" (design.md:596-597) @@ -165,7 +165,7 @@ assembled-backend layer; cycles marked *new* have no existing coverage. | --- | --- | --- | --- | | 1 | Inter-tenant probe (volume, vsock, host fs, host metadata/net) | *escalate + new* — PR #912 already boots two sessions for the volume surface (`microvm_isolation_microvm_test.go:391-395`, `TestMicroVMCrossSessionVolumeUnreachable`) and confines a single session's traversal (`:302-304`); net-new are the host-network legs and the vsock leg (OQ-8) | W1 | | 2 | Egress fail-closed inside the guest netns | *escalate* — `egress_inguest_microvm_test.go:37-42` already runs under the full backend and names itself V8 row (2) | W3 | -| 3 | S1 contract tests pass unchanged | *escalate* — `contract_microvm_test.go:34-69` covers `ContainerRuntime`; the `AgentRuntime.Launch` layer is podman-only (`lifecycle_test.go:1`) | W3 | +| 3 | S1 contract tests pass unchanged | *escalate* — `contract_microvm_test.go:34-69` covers `WorkloadRuntime`; the `AgentRuntime.Launch` layer is podman-only (`lifecycle_test.go:1`) | W3 | | 4 | Boot timeout killed + cleaned | *escalate + new* — `microvm_lifecycle_microvm_test.go:62-118` proves the corrupt-rootfs deadline is **fail-closed** (Start errors `:98-100`, no exec client `:108-110`, runtime dir removable `:111-116`); it asserts NOTHING about processes — "no orphan processes" is doc-comment text only (`:57-61`), read by no assertion. V8's delta is therefore the orphan-freedom assertion *itself*, pidfile-identity-verified, plus the caller-deadline cancel leg | W4 | | 5 | Mid-session VMM death under the session lifecycle | *new* at this layer — V7 (PR #931 §(c)) designs runtime-layer detection; gateway streams above it are unproven | W4 | | 6 | KVM-absent hard-fail (D3) | *escalate* — `microvm_preflight_test.go:82-88` unit-tests the axis; no acceptance-level assertion of the capability-naming error text | W5 | @@ -482,7 +482,7 @@ the CID-1 dial complete an HTTP exchange (exit 0) and the test MUST go red. mutations). W3 also adds the one missing leg: the same allow/deny probe pair run through `AgentRuntime.Launch`-provisioned sessions rather than direct `Create`/`Start` calls. -- **S1 contract (cycle 3).** The `ContainerRuntime` contract suite already +- **S1 contract (cycle 3).** The `WorkloadRuntime` contract suite already runs with every divergence cap ON (`contract_microvm_test.go:47-55`). The missing layer is `AgentRuntime`: the podman lifecycle e2e (`lifecycle_test.go:5-16` — create, checkout-dir ownership, uid-1000 exec, @@ -1032,8 +1032,8 @@ delta is the one-runtime topology and the symlink-in-A's-volume shape; its net-new content is the host-network leg and the vsock leg (OQ-8). - **Interfaces:** consumes `NewMicroVMRuntime(cfg MicroVMConfig) - *MicroVMRuntime`, the `ContainerRuntime` verbs - (`Create(ctx, ContainerSpec) (ContainerID, error)`, `Start`, `Exec`, + *MicroVMRuntime`, the `WorkloadRuntime` verbs + (`Create(ctx, WorkloadSpec) (WorkloadID, error)`, `Start`, `Exec`, `Remove`), `e2eConfig(t, env) MicroVMConfig` (`microvm_lifecycle_microvm_test.go:33-55`), and PR #912's isolation helpers including `TestMicroVMCrossSessionVolumeUnreachable`'s session @@ -1908,7 +1908,7 @@ Extends the existing `microvm` job (`ci.yml:624-850`) per § Approach (h). construction (`go/internal/delivery/trace_test.go:209-220`). Watch cardinality: no per-session attributes in any assertion helper. - **No production-code changes.** V8's Go deliverables are test files, test - data, and CI workflow edits; the frozen `ContainerRuntime` interface and + data, and CI workflow edits; the frozen `WorkloadRuntime` interface and all backend behavior are untouched. Where a proof requires a mutation, the mutation is transient (local build), recorded in the PR description, and never merged. diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md index f4858ae5..62bb9754 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md @@ -306,7 +306,7 @@ flow under volume-backed: resolve-or-create volume → `Attach` → `Materialize writable session volume at P2)". This is a **doc-comment change, not a shape change**: `Mount.ReadOnly` is already a per-mount bool (`podman.go:62-66`) and the `:ro` suffix is already conditional -(`podman.go:846-850`). The downstream `ContainerSpec.Mounts` already documents +(`podman.go:846-850`). The downstream `WorkloadSpec.Mounts` already documents a **read-write** bind mount in the shipped tree — "Not all read-only … the per-container agent gateway socket is mounted read-write (the agent must connect() to it)" (`podman.go:100-103`) — so a writable mount at the layer the @@ -582,7 +582,7 @@ The package skeleton mirrors `go/internal/compute`'s layering byte-identical (GC 8). Under `SourceVolume`, `ensureCheckoutDir` still runs (idempotent `mkdir -p` on the mounted path, same uid-ownership intent, `agent.go:354-358`). The `AgentSpec.Mounts` doc comment is amended - per P2-GC-a. No `ContainerRuntime` change (the interface stays frozen, + per P2-GC-a. No `WorkloadRuntime` change (the interface stays frozen, `podman.go:399-403`). - **Depends:** W1 (the mount it documents); parallel with W3. - **Test cycle:** existing launch-path regression suite green with zero-value diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md index 39322b5c..3e6295cd 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md @@ -20,7 +20,7 @@ Amends: RIG-1717 elastic session runtime record (PR #446) The frozen record's task **S1** lists the `vfs.VirtualFS` source-of-tree seam (interface + git-checkout backend + provision wiring) as a deliverable -alongside the `compute.ComputeRuntime` seam and the `ContainerRuntime.Resize` +alongside the `compute.ComputeRuntime` seam and the `WorkloadRuntime.Resize` freeze. During S1 execution, building `VirtualFS` surfaced that the seam has **no production caller at S1** and quietly bakes in an unsettled architectural decision. This amendment descopes `VirtualFS` from S1 to **P2**, where it @@ -74,7 +74,7 @@ Three findings drive the descope: **What S1 ships instead (unchanged by this amendment):** the `compute.ComputeRuntime` seam + its in-environment passthrough backend + the fail-closed routing-policy shell (`go/internal/compute`, PR #457), and the -additively-reserved `ContainerRuntime.Resize` verb + `ResourceLimits` +additively-reserved `WorkloadRuntime.Resize` verb + `ResourceLimits` (`go/internal/runtime`, PR #454). These are the two seams with teeth now and carry no clone/credential entanglement. The agent-self-clone-in-container model is left untouched (Global Constraint 8: the existing session path stays green; @@ -103,7 +103,7 @@ new code task — S1 shrinks, P2 grows. ### S1 (RIG-2393) — remove the `VirtualFS` deliverable -- S1's deliverables are **`ContainerRuntime.Resize` freeze** (PR #454) and +- S1's deliverables are **`WorkloadRuntime.Resize` freeze** (PR #454) and **`compute.ComputeRuntime`** seam + in-place backend + fail-closed routing (PR #457). The `vfs.VirtualFS` seam, its git-checkout backend, the `WorkspaceSource` variant, and the provision-materialize wiring are **removed diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md index a0c7b48e..a35991a5 100644 --- a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -770,31 +770,38 @@ probe leg is necessary but not sufficient, and the record takes both. - **`Resize` future** (non-load-bearing, deferred): a systemd user-scope / cgroup v2 delegation could make host `Resize` real; deferred until C3's resize behavior lands anywhere. -- **The `Container*` vocabulary is a known misnomer** (deferred, tracked - separately): this tier makes `ContainerRuntime` span a third backend that is - not a container — direct host processes — after `MicroVMRuntime` already made - it span a second (`go/internal/runtime/microvm.go:71`). `SelectBackend`'s own - comment states the endgame (`microvm.go:110-116`): once microVM is the sole - runtime the container path goes away entirely, leaving an interface named - `ContainerRuntime` with no container implementation. The misnomer is not the - interface alone: `ContainerID` (214 refs) already keys microVM sessions - (`microvm.go:84`) and would key host process groups here, and `ContainerSpec` - (58 refs) is likewise backend-neutral in practice. - - Ruled name: **`Workload*`** (`WorkloadRuntime`/`WorkloadID`/`WorkloadSpec`) — - verified unused in Go and proto, and true of a container, a microVM guest, - and a host process group alike. `Session*` was rejected: a session is already - the user-facing conversational stream (`SessionEvent` and siblings in - `proto/compass/v1/compass.proto`), one environment outlives many sessions, so - the name would assert a one-to-one relation that does not hold. `Sandbox` was - rejected as asserting isolation the host tier explicitly does not provide. - `AgentRuntime` (`go/internal/runtime/agent.go:155`) is **not** renamed — it is - the per-agent lifecycle façade over a backend, and that name is accurate. - - Deliberately **not** in this record's scope: a ~365-reference mechanical - rename would swamp the design content here, and the freeze at S1 covers the - method set, not the identifier. Sequenced after the microVM default flip, - when the vocabulary is forced by reality rather than argued. +- **The `Container*` vocabulary — RULED and DONE (Matt): renamed to + `Workload*` 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`), Apple `container` on + 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 named + `ContainerRuntime` with 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 (`SessionEvent` and siblings in + `proto/compass/v1/compass.proto`), and one workload outlives many sessions, + so the name would assert a one-to-one relation that does not hold. + `Sandbox` was 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 + the `container_name` wire 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.go` freeze 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 @@ -829,3 +836,11 @@ invented here): 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*` to + `Workload*` (`WorkloadRuntime`/`WorkloadID`/`WorkloadSpec`/`InWorkloadError`) + because the interface spans four backends — podman, microVM, Apple + `container`, 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, the `container_name` wire field). diff --git a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md index 7e7916da..85dcb2ee 100644 --- a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md +++ b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md @@ -162,10 +162,10 @@ Grounding the current state: - The seam is exactly what makes the permanent split cheap. The podman implementation is explicitly a thin seam (`go/internal/runtime/podman.go:10-13`: "podman.go — a thin - ContainerRuntime over the podman CLI: the only place a subprocess is + WorkloadRuntime over the podman CLI: the only place a subprocess is spawned. Everything above depends on the interface"), backend selection is constructor-time (`go/internal/runtime/microvm.go:117`: - `func SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`), and + `func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)`), and the frozen record pins byte-identical container behavior during coexistence (`docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md:397-402`: @@ -232,7 +232,7 @@ removes the KVM premium at the self-host front door — cheap VPS tiers mostly do not expose `/dev/kvm`, and a single-tenant operator gains little from a hardware boundary that exists to isolate untrusted tenants. The standing two-backend maintenance surface is the acknowledged price, bounded -by the frozen `ContainerRuntime` seam and the now-permanent byte-identical +by the frozen `WorkloadRuntime` seam and the now-permanent byte-identical parity constraint. ### Guided onboarding: embedded-local front door, then self-host @@ -352,7 +352,7 @@ task (T2), not frozen prose here. (`go/internal/runtime/microvm.go:110-113`) — and, per the trust-model split, stays permanently for self-host. - **Other runtime backends behind the seam — deferred, not declined-forever.** - The `ContainerRuntime` interface is frozen precisely so a new backend is one + The `WorkloadRuntime` interface is frozen precisely so a new backend is one `SelectBackend` case plus an implementation, no caller churn (`go/internal/runtime/podman.go`: "Everything above depends on the interface, so a libpod-REST backend can replace it without touching a @@ -459,8 +459,8 @@ in this record. (`go/cmd/compass-stack/preflight.go`). This is the green-preflight deliverable that §Guided onboarding names as T1's, and on which T2's podman-tier `preflight` instructions are blocked until it lands. -- **Interfaces:** consumes the frozen `ContainerRuntime` interface and - `SelectBackend(cfg BackendConfig) (ContainerRuntime, error)` +- **Interfaces:** consumes the frozen `WorkloadRuntime` interface and + `SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)` (`go/internal/runtime/microvm.go:117`); consumes the landed startup preflight surface (`verifyBackendPreflight`, above) and the microVM e2e/CI suites @@ -588,7 +588,7 @@ freeze-time delta shape the directory's amendments use client-only charter) is designed in the compass-native lane's embedded-revival record and carries its own ledger row there. AMENDS the frozen KVM-only amendment (`microvm-kvm-only-amendment.md:96-97`) - with the self-host carve-out; the `ContainerRuntime` interface stays + with the self-host carve-out; the `WorkloadRuntime` interface stays frozen. 2. **Proposed (2026-09, spec split + host tier — no DL id minted here; the coordinator assigns one at freeze).** The living runner tier strategy diff --git a/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md b/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md index 50240508..5d1333fc 100644 --- a/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md +++ b/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md @@ -119,7 +119,7 @@ The rationale for bare `keep-id` is `podman.go:25-26`: "Containers run with --userns=keep-id so files the agent writes in a bind-mount map back to the invoking user on the host" (restated at the flag itself, `:363-364`). The runner's bind-mounts are two kinds: the read-only config/cache mounts -(`ContainerSpec.Mounts`, `podman.go:82-84` — whose "read-only" field comment +(`WorkloadSpec.Mounts`, `podman.go:82-84` — whose "read-only" field comment is now stale, see P1; the materialized config tree, `go/internal/runner/config_materialize.go:199-201`), and one **read-write** mount — the per-container agent gateway socket @@ -233,28 +233,28 @@ unchanged by ordering. ### P1 — Remap the userns flag in `Create` Switch `PodmanCLI.Create` from the bare token to the remap, threading the -agent uid into `ContainerSpec` so the flag derives from the same value every +agent uid into `WorkloadSpec` so the flag derives from the same value every exec already uses (the T1/T2 invariant). - Changes: - - `go/internal/runtime/podman.go`: add `UID uint32` to `ContainerSpec` - (`:70-90`; note `ContainerSpec` has no `User` field — `:92-97` is + - `go/internal/runtime/podman.go`: add `UID uint32` to `WorkloadSpec` + (`:70-90`; note `WorkloadSpec` has no `User` field — `:92-97` is `ExecSpec`), documented as the container uid the invoking host user is mapped to. `Create` (`:355-366`) emits `fmt.Sprintf("--userns=keep-id:uid=%d,gid=%d", spec.UID, spec.UID)` in place of `"--userns=keep-id"` — gid is collapsed to uid because the image bakes gid==uid==1000 (`containers.nix:55-56`); a distinct GID field is not threaded until an image diverges. Extract the argv assembly into - `createArgs(spec ContainerSpec) []string` (mirroring + `createArgs(spec WorkloadSpec) []string` (mirroring `execStreamingArgs`, `:635`, "split out so the argv assembly is unit-testable without spawning podman"). - `go/internal/runtime/agent.go` `createAndStart` (`:245-254`): set - `UID: spec.Workspace.UID` on the `ContainerSpec`. + `UID: spec.Workspace.UID` on the `WorkloadSpec`. - Update the stale comments: `podman.go:25-26` and `:363-364` (the "maps back to the invoking user" rationale now reads "the invoking host user is mapped to the baked agent uid; files the agent writes in a bind-mount still map back to the invoking user"); `podman.go:82-84` - (`ContainerSpec.Mounts` "read-only host bind mounts" — false, the agent + (`WorkloadSpec.Mounts` "read-only host bind mounts" — false, the agent socket is read-write, see §(c)); `podman.go:95-96` (`ExecSpec.User` "Nil runs as the image's default user (root)" — the image default is uid 1000, not root); `agent.go:282-283` (`armEgress` "as root" — it runs as the @@ -262,12 +262,12 @@ exec already uses (the T1/T2 invariant). `agent-image/devenv.nix:69-82` identity comment (it cites bare keep-id and the verifyRunnerUID guard). - Interfaces: - - `type ContainerSpec struct { ...; UID uint32 }` (new field). - - `func createArgs(spec ContainerSpec) []string` (new, package-private). - - `func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID, error)` — unchanged signature. + - `type WorkloadSpec struct { ...; UID uint32 }` (new field). + - `func createArgs(spec WorkloadSpec) []string` (new, package-private). + - `func (p *PodmanCLI) Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error)` — unchanged signature. - Test cycle: new `TestCreateArgsRemapsUserns` in `go/internal/runtime/podman_test.go` pinning the exact token - `--userns=keep-id:uid=1000,gid=1000` for `ContainerSpec{UID: 1000}`. + `--userns=keep-id:uid=1000,gid=1000` for `WorkloadSpec{UID: 1000}`. Order within P1: extract `createArgs` first (still emitting the bare token), commit the test red against the bare token, then flip the flag to green. Run `go test ./go/internal/runtime/ -run TestCreateArgs`. @@ -311,7 +311,7 @@ exercises the identical mechanism an arbitrary-host-uid deployment relies on. `podmanUsable()` (the existing skip helper, `lifecycle_test.go:54-59`), alpine-based like `config_mount_test.go` (no compass-agent image dependency): - 1. `Create`/`Start` a container with `ContainerSpec{UID: }` shape — i.e. drive the real `PodmanCLI.Create` with a `UID` distinct from `os.Getuid()` — and assert `id -u` inside equals the spec'd UID (the remap maps host→spec'd uid). @@ -336,7 +336,7 @@ it implements the preflight per that ruling. ## Tasks -- [ ] P1 — `ContainerSpec.UID` + `createArgs` extraction + +- [ ] P1 — `WorkloadSpec.UID` + `createArgs` extraction + `--userns=keep-id:uid=,gid=` in `Create`; comment sweep (`podman.go:25,363`, `agent-image/devenv.nix:69-82`); `TestCreateArgsRemapsUserns` green. diff --git a/docs/designs/platform/apple-container-macos-runner/design.md b/docs/designs/platform/apple-container-macos-runner/design.md index 40c82b9e..9ac08b24 100644 --- a/docs/designs/platform/apple-container-macos-runner/design.md +++ b/docs/designs/platform/apple-container-macos-runner/design.md @@ -12,7 +12,7 @@ Linear: RIG-3238 (design) Investigation + design record for RIG-3238: whether Apple `container` (github.com/apple/container) becomes a supported backend behind the frozen -`ContainerRuntime`/`SelectBackend` seam for the Compass native app's embedded +`WorkloadRuntime`/`SelectBackend` seam for the Compass native app's embedded macOS front door, and if so, the adoption sequencing. This record carries Matt's RIG-3246 ruling plus an adoption plan whose BUILD (not direction) is gated on the T-1 spike; it does not implement the backend. @@ -130,16 +130,16 @@ Reasoning, in order of weight: no podman", OQ-13 resolved), so the "no machine / no podman" win covers the WHOLE macOS stack, not only the agent containers — the DL-260 podman shell for postgres is swapped for apple-container on macOS (T-2 scope). -3. **The seam was built for this.** `ContainerRuntime` is a frozen interface - (`go/internal/runtime/podman.go:343-348`: "ContainerRuntime is the +3. **The seam was built for this.** `WorkloadRuntime` is a frozen interface + (`go/internal/runtime/podman.go:343-348`: "WorkloadRuntime is the container engine seam … An interface so the Runner can hold a - ContainerRuntime and tests can substitute a fake") and `SelectBackend` + WorkloadRuntime and tests can substitute a fake") and `SelectBackend` is an explicit switch (`go/internal/runtime/microvm.go:117-126`) whose error copy already anticipates growth ("accepted values are \"podman\" (default) and \"microvm\"", `microvm.go:124`). A third case + impl type is the designed extension path. Apple `container` is a DISTINCT non-podman CLI (its own argv grammar, its own `container-apiserver` service), so it - is a new `ContainerRuntime` implementation — NOT a + is a new `WorkloadRuntime` implementation — NOT a `PodmanCLI.WithProgram` swap, which only substitutes a podman-compatible binary path (`podman.go:433-435`: "WithProgram uses an explicit engine binary (e.g. an absolute path, or `docker` in a pinch)"). @@ -177,7 +177,7 @@ Why spike-first, then flip (not default-the-instant-it-builds): (`microvm.go:63-69`) gains an `AppleContainer AppleContainerConfig` field mirroring how `MicroVM MicroVMConfig` rides beside `Backend`. - **The impl type** is `AppleContainerCLI`, a subprocess-driving - `ContainerRuntime` shaped like `PodmanCLI` (program + timeout, + `WorkloadRuntime` shaped like `PodmanCLI` (program + timeout, `podman.go:421-425`), speaking the `container` CLI: `create`/`start`/ `exec`/`stop`/`rm`/`inspect` exist with familiar semantics (). @@ -334,8 +334,8 @@ trivially satisfiable (a version-floor probe on one binary, like `container system start` command") — inside the invariant. The installer requiring admin once to place files under /usr/local is an install-time cost, not a runtime posture. -- **The `ContainerRuntime` interface stays frozen.** The new backend - implements all nine `ContainerRuntime` verbs, Resize included as the +- **The `WorkloadRuntime` interface stays frozen.** The new backend + implements all nine `WorkloadRuntime` verbs, Resize included as the additively-reserved one (`podman.go:348-397`), and adds NO verbs. Any backend-specific need rides the off-interface marker pattern (`podman.go:399-406`) or the config struct, never an interface change. @@ -437,7 +437,7 @@ Matt ruled OQ-9 A (the mac mini on Woodpecker, ssh access provisioned).** plain Go): type `AppleContainerCLI{program string, timeout time.Duration}` mirroring `PodmanCLI` (`podman.go:421-425`), argv builders split from spawning (the `createArgs` discipline, `podman.go:455-462`), implementing - all nine `ContainerRuntime` verbs (`podman.go:348-397`): Create/Start/ + all nine `WorkloadRuntime` verbs (`podman.go:348-397`): Create/Start/ Exec/ExecStreaming/Stop/Remove/Exists/MountLabel/Resize. Additionally the two OFF-interface podman surfaces the embedded stack drives on macOS. (1) The image adapter's `imageCLI` requires `ImageExists` + `Pull` @@ -467,10 +467,10 @@ Matt ruled OQ-9 A (the mac mini on Woodpecker, ssh access provisioned).** `container --version`, the `VerifyUsernsRemapSupport` shape (`podman.go:497-518`). - **Interfaces:** produces `NewAppleContainerCLI(cfg AppleContainerConfig) - *AppleContainerCLI` satisfying `runtime.ContainerRuntime` + *AppleContainerCLI` satisfying `runtime.WorkloadRuntime` (`podman.go:348-397`), `func (a *AppleContainerCLI) VerifyAppleContainerSupport(ctx context.Context) error`, and the widened - `SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`. Consumes + `SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)`. Consumes T-1's findings for argv specifics. - **Test cycle:** unit tests over the argv builders (no binary spawned — the `TestCreateArgsRemapsUserns` pattern, `podman_test.go:99-104`); diff --git a/docs/designs/platform/apple-container-macos-runner/spike-findings.md b/docs/designs/platform/apple-container-macos-runner/spike-findings.md index d1aac04b..71a2af13 100644 --- a/docs/designs/platform/apple-container-macos-runner/spike-findings.md +++ b/docs/designs/platform/apple-container-macos-runner/spike-findings.md @@ -470,7 +470,7 @@ changes. Unaffected: the exec/kill contract, the host-side-runner topology, and T-5's flip criteria — now backed by real numbers. On the -`SelectBackend`/`ContainerRuntime` seam, all nine verbs are accounted for: six +`SelectBackend`/`WorkloadRuntime` seam, all nine verbs are accounted for: six were exercised incidentally through the `run`/`exec` probes (Create, Start, Exec, ExecStreaming, Stop, and Remove), and `Exists` was driven directly (`container inspect ` exits 0 in any state and 1 with a distinguishable diff --git a/docs/designs/repo/compass-agent-effect-otel/design.md b/docs/designs/repo/compass-agent-effect-otel/design.md index d545619d..6fed8a24 100644 --- a/docs/designs/repo/compass-agent-effect-otel/design.md +++ b/docs/designs/repo/compass-agent-effect-otel/design.md @@ -575,7 +575,7 @@ merges. record, no cross-package split.** Matt ruled fold-it-in; compass-runner confirmed the injection path is the TS CLI env-file sourcing (`packages/compass-agent/src/cli.ts:96-105,500-523`) — the compass-agent TS - package (this lane), sitting ABOVE the Go `ContainerRuntime` seam, NOT the + package (this lane), sitting ABOVE the Go `WorkloadRuntime` seam, NOT the Runner's Go lane and NOT in the microVM record's scope. The endpoint key is not `COMPASS_*`-prefixed, so `isReservedEnvKey` (`cli.ts:103-105`) does not drop it and it flows through the generic env-file sourcing diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 77d0d0ff..a0ad6831 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -209,7 +209,7 @@ type canaryBooter interface { // startup error naming the concrete type — never a silent skip, so a backend // added without a preflight surfaces loudly at launch rather than running // unchecked. -func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error { +func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error { switch e := engine.(type) { case microVMPreflighter: return runMicroVMPreflight(ctx, e, engine) @@ -226,7 +226,7 @@ func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime // satisfies microVMPreflighter but not canaryBooter is a fail-closed startup // error naming the type, never a silent skip (same posture as the neither-probe // default). Split out so verifyBackendPreflight stays within funlen. -func runMicroVMPreflight(ctx context.Context, pre microVMPreflighter, engine runtime.ContainerRuntime) error { +func runMicroVMPreflight(ctx context.Context, pre microVMPreflighter, engine runtime.WorkloadRuntime) error { if err := pre.VerifyMicroVMSupport(ctx); err != nil { return err } @@ -330,7 +330,7 @@ func registerBackendFlags() backendFlags { // selectEngine resolves the configured runtime backend from the parsed flags // and their environment fallbacks. -func (f backendFlags) selectEngine() (runtime.ContainerRuntime, error) { +func (f backendFlags) selectEngine() (runtime.WorkloadRuntime, error) { cfg, err := f.backendConfig() if err != nil { return nil, err diff --git a/go/cmd/compass-runner/main_test.go b/go/cmd/compass-runner/main_test.go index 67d0b4e4..d2d8dd94 100644 --- a/go/cmd/compass-runner/main_test.go +++ b/go/cmd/compass-runner/main_test.go @@ -75,9 +75,9 @@ func TestParseMount(t *testing.T) { } // podmanOnlyEngine exposes only the podman probe; its embedded nil -// ContainerRuntime satisfies the param type but is never called. +// WorkloadRuntime satisfies the param type but is never called. type podmanOnlyEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool err error } @@ -92,7 +92,7 @@ func (e podmanOnlyEngine) VerifyUsernsRemapSupport(context.Context) error { // static check passes, so a fake missing BootCanary would trip the fail-closed // canary assertion rather than exercise the static-probe dispatch. type microVMOnlyEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool canaryCalled *bool err error @@ -116,7 +116,7 @@ func (e microVMOnlyEngine) BootCanary(context.Context) (runtime.CanaryReport, er // a microVM backend that cannot boot-canary. The gate must fail closed on it, // naming the type, never silently skipping the canary. type microVMNoCanaryEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool err error } @@ -126,16 +126,16 @@ func (e microVMNoCanaryEngine) VerifyMicroVMSupport(context.Context) error { return e.err } -// neitherEngine exposes no probe: only the embedded (nil) ContainerRuntime. +// neitherEngine exposes no probe: only the embedded (nil) WorkloadRuntime. type neitherEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime } // bothProbesEngine exposes BOTH probes. No real engine does today, but it locks // the microVM-first precedence of verifyBackendPreflight's type switch: the // microVM branch must win and the podman branch must not run. type bothProbesEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime microVMCalled *bool podmanCalled *bool err error diff --git a/go/internal/compute/compute.go b/go/internal/compute/compute.go index ce76bcfc..df9b54f4 100644 --- a/go/internal/compute/compute.go +++ b/go/internal/compute/compute.go @@ -14,7 +14,7 @@ // touching a caller. // - inplace.go — the S1 backend: an in-environment local-exec passthrough that // runs the op in the session's own environment at its current size, by -// delegating to the injected runtime.ContainerRuntime against the session +// delegating to the injected runtime.WorkloadRuntime against the session // container. The genuinely trivial fused configuration the design lands S1 // end to end with. // - routing.go — the fail-closed routing-policy shell (Global Constraint 3): a @@ -24,7 +24,7 @@ // invariants and are implemented and tested now. // // Reserved-not-implemented surface: ExecStreaming is declared in the interface -// now (mirroring runtime.ContainerRuntime.ExecStreaming) so a later streaming +// now (mirroring runtime.WorkloadRuntime.ExecStreaming) so a later streaming // consumer lands no interface change, but the S1 backend returns an honest // not-implemented sentinel rather than a silent no-op. // @@ -105,7 +105,7 @@ type ComputeRuntime interface { // ExecStreaming is reserved: it will run a long-lived streaming op returning // its live stdio pipes plus a kill/wait handle, mirroring - // runtime.ContainerRuntime.ExecStreaming. It is declared in the interface now + // runtime.WorkloadRuntime.ExecStreaming. It is declared in the interface now // so a later streaming consumer lands no interface change; the S1 backend // returns ErrExecStreamingNotImplemented rather than silently succeeding. ExecStreaming(ctx context.Context, spec ComputeSpec) (*runtime.StreamingExec, error) diff --git a/go/internal/compute/compute_test.go b/go/internal/compute/compute_test.go index 19489439..33f6a793 100644 --- a/go/internal/compute/compute_test.go +++ b/go/internal/compute/compute_test.go @@ -78,18 +78,18 @@ func TestInPlaceSatisfiesContract(t *testing.T) { for _, tc := range contractCases { t.Run(tc.name, func(t *testing.T) { eng := &recordingEngine{output: runtime.ExecOutput{ExitCode: 0}} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) tc.run(t, cr) }) } } -// recordingEngine is a runtime.ContainerRuntime that records the id and ExecSpec +// recordingEngine is a runtime.WorkloadRuntime that records the id and ExecSpec // its Exec receives, so the in-place backend's mapping (command, workdir, env) // and its target container can be asserted without a real container. Every other // method is an unused stub — the in-place backend only ever calls Exec. type recordingEngine struct { - gotID runtime.ContainerID + gotID runtime.WorkloadID gotSpec runtime.ExecSpec gotCtx context.Context execN int @@ -97,7 +97,7 @@ type recordingEngine struct { execErr error } -func (e *recordingEngine) Exec(ctx context.Context, id runtime.ContainerID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { +func (e *recordingEngine) Exec(ctx context.Context, id runtime.WorkloadID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { e.gotID = id e.gotSpec = spec e.gotCtx = ctx @@ -105,25 +105,25 @@ func (e *recordingEngine) Exec(ctx context.Context, id runtime.ContainerID, spec return e.output, e.execErr } -func (e *recordingEngine) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { +func (e *recordingEngine) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { return "", errors.New("recordingEngine: Create unused") } -func (e *recordingEngine) Start(context.Context, runtime.ContainerID) error { +func (e *recordingEngine) Start(context.Context, runtime.WorkloadID) error { return errors.New("recordingEngine: Start unused") } -func (e *recordingEngine) ExecStreaming(context.Context, runtime.ContainerID, runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (e *recordingEngine) ExecStreaming(context.Context, runtime.WorkloadID, runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { return nil, errors.New("recordingEngine: ExecStreaming unused") } -func (e *recordingEngine) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (e *recordingEngine) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return errors.New("recordingEngine: Stop unused") } -func (e *recordingEngine) Remove(context.Context, runtime.ContainerID) error { +func (e *recordingEngine) Remove(context.Context, runtime.WorkloadID) error { return errors.New("recordingEngine: Remove unused") } func (e *recordingEngine) Exists(context.Context, string) (bool, error) { return false, errors.New("recordingEngine: Exists unused") } -func (e *recordingEngine) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (e *recordingEngine) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", errors.New("recordingEngine: MountLabel unused") } @@ -131,7 +131,7 @@ func (e *recordingEngine) MountLabel(context.Context, runtime.ContainerID) (stri // Resize verb the in-place backend never calls, so the fake carries it to stay // a total implementation of the engine interface as that interface freezes the // verb in. -func (e *recordingEngine) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (e *recordingEngine) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return errors.New("recordingEngine: Resize unused") } @@ -141,7 +141,7 @@ func (e *recordingEngine) Resize(context.Context, runtime.ContainerID, runtime.R // targeted the wrong container would fail here. func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { eng := &recordingEngine{output: runtime.ExecOutput{Stdout: "ok", ExitCode: 0}} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) spec := ComputeSpec{ Command: []string{"go", "test", "./..."}, @@ -158,7 +158,7 @@ func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { if eng.execN != 1 { t.Fatalf("engine.Exec called %d times, want 1", eng.execN) } - if eng.gotID != runtime.ContainerID("sess-container") { + if eng.gotID != runtime.WorkloadID("sess-container") { t.Fatalf("delegated to container %q, want session container", eng.gotID) } if !slices.Equal(eng.gotSpec.Command, spec.Command) { @@ -182,7 +182,7 @@ func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { // directory is the intended behavior. func TestInPlaceExecOmitsWorkdirWhenDirEmpty(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"true"}}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -200,7 +200,7 @@ func TestInPlaceExecOmitsWorkdirWhenDirEmpty(t *testing.T) { func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { t.Run("positive timeout sets a deadline", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"go", "test"}, Timeout: 30 * time.Second}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -215,7 +215,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { }) t.Run("zero timeout leaves ctx untouched", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"true"}}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -226,7 +226,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { }) t.Run("caller deadline shorter than timeout wins", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) // A caller ctx already bounded tighter than spec.Timeout: the effective // deadline must stay the caller's, never be pushed out to now+Timeout. @@ -249,7 +249,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { // fake and the real backend (a compile-time check that would break if the // interface drifted from the implementations). var ( - _ ComputeRuntime = fakeCompute{} - _ ComputeRuntime = (*InPlace)(nil) - _ runtime.ContainerRuntime = (*recordingEngine)(nil) + _ ComputeRuntime = fakeCompute{} + _ ComputeRuntime = (*InPlace)(nil) + _ runtime.WorkloadRuntime = (*recordingEngine)(nil) ) diff --git a/go/internal/compute/inplace.go b/go/internal/compute/inplace.go index 6ac623fc..1b630fc2 100644 --- a/go/internal/compute/inplace.go +++ b/go/internal/compute/inplace.go @@ -27,14 +27,14 @@ var ErrExecStreamingNotImplemented = errors.New("compute: ExecStreaming not impl // handle is exercised (the passthrough runs in place, arming nothing new); the // engine and egress are held for the heavier backends behind the same seam. type InPlace struct { - engine runtime.ContainerRuntime - container runtime.ContainerID + engine runtime.WorkloadRuntime + container runtime.WorkloadID egress runtime.EgressPolicy } // NewInPlace builds the S1 in-place backend bound to a session's container- // runtime engine, its container handle, and its egress policy. -func NewInPlace(engine runtime.ContainerRuntime, container runtime.ContainerID, egress runtime.EgressPolicy) *InPlace { +func NewInPlace(engine runtime.WorkloadRuntime, container runtime.WorkloadID, egress runtime.EgressPolicy) *InPlace { return &InPlace{engine: engine, container: container, egress: egress} } diff --git a/go/internal/gen/compass/v1/guest_control.pb.go b/go/internal/gen/compass/v1/guest_control.pb.go index 3442c840..0d0d07d7 100644 --- a/go/internal/gen/compass/v1/guest_control.pb.go +++ b/go/internal/gen/compass/v1/guest_control.pb.go @@ -833,10 +833,10 @@ type ProvisionRequest struct { // skips the arm and is a hermetic test seam (the host production path always // sends a non-empty default-deny ruleset). NftScript string `protobuf:"bytes,1,opt,name=nft_script,json=nftScript,proto3" json:"nft_script,omitempty"` - // default_exec_uid is the session's agent uid (ContainerSpec.UID), the + // default_exec_uid is the session's agent uid (WorkloadSpec.UID), the // default for an exec with no uid. Validated non-zero. DefaultExecUid uint32 `protobuf:"varint,2,opt,name=default_exec_uid,json=defaultExecUid,proto3" json:"default_exec_uid,omitempty"` - // base_env is the base environment every exec inherits (ContainerSpec.Env). + // base_env is the base environment every exec inherits (WorkloadSpec.Env). BaseEnv map[string]string `protobuf:"bytes,3,rep,name=base_env,json=baseEnv,proto3" json:"base_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/go/internal/runner/agent_exec.go b/go/internal/runner/agent_exec.go index 1e149cb9..402a320d 100644 --- a/go/internal/runner/agent_exec.go +++ b/go/internal/runner/agent_exec.go @@ -133,7 +133,7 @@ func (s *AgentStream) SessionID() string { return s.sessionID } // carries the identity and configuration the exec runs with. The returned // AgentStream lives until Stop or ctx cancellation terminates the in-container // agent. -func (l *ServerLink) StartAgent(ctx context.Context, sessionID string, id runtime.ContainerID, engine runtime.ContainerRuntime, env AgentEnv, log *slog.Logger) (*AgentStream, error) { +func (l *ServerLink) StartAgent(ctx context.Context, sessionID string, id runtime.WorkloadID, engine runtime.WorkloadRuntime, env AgentEnv, log *slog.Logger) (*AgentStream, error) { if log == nil { log = slog.Default() } diff --git a/go/internal/runner/agent_exec_test.go b/go/internal/runner/agent_exec_test.go index 2852cd12..a66c2b2f 100644 --- a/go/internal/runner/agent_exec_test.go +++ b/go/internal/runner/agent_exec_test.go @@ -71,7 +71,7 @@ func TestStderrFloodDoesNotStallTheAgent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-flood", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-flood", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -127,7 +127,7 @@ func TestStdoutFrameIsNotPublishedAfterCutover(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-cutover", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-cutover", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -180,7 +180,7 @@ func TestStdoutIsDrainedToDiagnosticLog(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-drain", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-drain", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -216,7 +216,7 @@ func TestStderrIsDrainedUnderItsOwnLabel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-err", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-err", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -262,7 +262,7 @@ func TestBareTrailingCarriageReturnIsPayload(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-cr", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-cr", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -294,7 +294,7 @@ func TestOverlongLineTruncatesButKeepsDraining(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-long", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-long", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -407,7 +407,7 @@ func TestCleanStopEmitsNoDrainWarning(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-quiet", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()) + stream, err := link.StartAgent(ctx, "sess-quiet", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()) if err != nil { t.Fatalf("StartAgent = %v", err) } @@ -446,7 +446,7 @@ func TestStopArmsTheStoppingDiscriminator(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-armed", runtime.ContainerID("c1"), engine, testAgentEnv(), discardLoggerRunner()) + stream, err := link.StartAgent(ctx, "sess-armed", runtime.WorkloadID("c1"), engine, testAgentEnv(), discardLoggerRunner()) if err != nil { t.Fatalf("StartAgent = %v", err) } @@ -483,7 +483,7 @@ func TestSelfExitReleasesTheDrainCtx(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-selfexit", runtime.ContainerID("c1"), engine, testAgentEnv(), discardLoggerRunner()) + stream, err := link.StartAgent(ctx, "sess-selfexit", runtime.WorkloadID("c1"), engine, testAgentEnv(), discardLoggerRunner()) if err != nil { t.Fatalf("StartAgent = %v", err) } diff --git a/go/internal/runner/config_refresh_test.go b/go/internal/runner/config_refresh_test.go index fc0b1311..b7623fc6 100644 --- a/go/internal/runner/config_refresh_test.go +++ b/go/internal/runner/config_refresh_test.go @@ -26,7 +26,7 @@ import ( "github.com/RigelBuild/compass/go/internal/runtime" ) -// configFanoutRuntime is a ContainerRuntime for the RefreshConfig fan-out tests. +// configFanoutRuntime is a WorkloadRuntime for the RefreshConfig fan-out tests. // Create returns the container NAME as its engine id (per-container-unique, so a // per-container label and a per-container Reload count are distinguishable — // stubStreamingRuntime's fixed "fake-id" would alias every container onto one), @@ -50,15 +50,15 @@ func newConfigFanoutRuntime(t *testing.T) *configFanoutRuntime { } } -func (r *configFanoutRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (r *configFanoutRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { r.mu.Lock() r.calls = append(r.calls, "create") r.created = append(r.created, spec) r.mu.Unlock() - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } -func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.ContainerID) (string, error) { +func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.WorkloadID) (string, error) { r.mu.Lock() defer r.mu.Unlock() if err := r.labelErrs[string(id)]; err != nil { @@ -67,7 +67,7 @@ func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.Container return r.labels[string(id)], nil } -func (r *configFanoutRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (r *configFanoutRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { r.mu.Lock() r.execByID[string(id)]++ r.mu.Unlock() diff --git a/go/internal/runner/e2e_vsock_gateway_microvm_test.go b/go/internal/runner/e2e_vsock_gateway_microvm_test.go index f4918ae5..582c21fb 100644 --- a/go/internal/runner/e2e_vsock_gateway_microvm_test.go +++ b/go/internal/runner/e2e_vsock_gateway_microvm_test.go @@ -442,7 +442,7 @@ type probeResult struct { // single line `RESULT ` the host parses for the echoed call id. A // transport/exec error is a harness fault (t.Fatalf); a completed exec whose // body carries the result is the round-trip proof. -func runInGuestProbe(t *testing.T, m *runtime.MicroVMRuntime, id runtime.ContainerID, callID string) probeResult { +func runInGuestProbe(t *testing.T, m *runtime.MicroVMRuntime, id runtime.WorkloadID, callID string) probeResult { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), inGuestProbeTimeout) defer cancel() @@ -526,7 +526,7 @@ func parseProbeResult(t *testing.T, stdout string) probeResult { // dropped SYN hangs until the guest timeout fires (exit 124, unreachable); an // allowed host completes (exit 0, "connected"). Any exec/transport error is a // harness fault. -func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.ContainerID, ip string) bool { +func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.WorkloadID, ip string) bool { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), inGuestProbeTimeout) defer cancel() @@ -540,10 +540,10 @@ func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.Conta return reached } -// resolveContainerID reads the engine ContainerID the registry bound for the +// resolveContainerID reads the engine WorkloadID the registry bound for the // provisioned name — the id in-guest execs address. White-box: the registry is // the runner's own, and the id is otherwise unexported from Provision's return. -func resolveContainerID(t *testing.T, h *agentHost, name string) runtime.ContainerID { +func resolveContainerID(t *testing.T, h *agentHost, name string) runtime.WorkloadID { t.Helper() handle, ok := h.registry.Resolve(name) if !ok { diff --git a/go/internal/runner/helpers_test.go b/go/internal/runner/helpers_test.go index 871bebb8..111ce845 100644 --- a/go/internal/runner/helpers_test.go +++ b/go/internal/runner/helpers_test.go @@ -3,7 +3,7 @@ package runner // Shared scaffolding for the Runner-side seam tests: a pipe-backed fake -// ContainerRuntime (the existing runtime.fakeRuntime.ExecStreaming is a nil-pipe +// WorkloadRuntime (the existing runtime.fakeRuntime.ExecStreaming is a nil-pipe // stub — this one returns a StreamingExec whose IO.Stdout/IO.Stderr are // io.PipeReaders the test writes into), a recording fake runtime for the // Provision→Launch path, a capturing slog handler for the drain's log lines, a @@ -58,7 +58,7 @@ type nopWriteCloser struct{} func (nopWriteCloser) Write(p []byte) (int, error) { return len(p), nil } func (nopWriteCloser) Close() error { return nil } -// pipeRuntime is a ContainerRuntime whose ExecStreaming returns a StreamingExec +// pipeRuntime is a WorkloadRuntime whose ExecStreaming returns a StreamingExec // backed by real in-memory pipes: the test writes lines into stdoutW / stderrW // and the drains read them off IO.Stdout / IO.Stderr. Its // lifecycle methods (Create/Start/Exec/…) are recording no-ops so it also serves @@ -80,19 +80,19 @@ func newPipeRuntime() *pipeRuntime { return &pipeRuntime{stdoutW: outW, stderrW: errW, stdoutR: outR, stderrR: errR} } -func (f *pipeRuntime) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *pipeRuntime) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { f.record("create") - return runtime.ContainerID("fake-id"), nil + return runtime.WorkloadID("fake-id"), nil } -func (f *pipeRuntime) Start(context.Context, runtime.ContainerID) error { +func (f *pipeRuntime) Start(context.Context, runtime.WorkloadID) error { f.record("start") return nil } -func (f *pipeRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *pipeRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { f.record("exec") return runtime.ExecOutput{}, nil } -func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.ContainerID, _ runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.WorkloadID, _ runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { f.record("exec_streaming") if f.execErr != nil { return nil, f.execErr @@ -103,19 +103,19 @@ func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.ContainerID, _ // fake must not call AgentStream.Stop. }, nil } -func (f *pipeRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *pipeRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { f.record("stop") return nil } -func (f *pipeRuntime) Remove(context.Context, runtime.ContainerID) error { +func (f *pipeRuntime) Remove(context.Context, runtime.WorkloadID) error { f.record("remove") return nil } func (f *pipeRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *pipeRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *pipeRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *pipeRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *pipeRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } @@ -129,7 +129,7 @@ func (f *pipeRuntime) record(call string) { // exit / EOF). func (f *pipeRuntime) closeStdout() { _ = f.stdoutW.Close() } -// stubStreamingRuntime is a ContainerRuntime whose lifecycle methods are +// stubStreamingRuntime is a WorkloadRuntime whose lifecycle methods are // recording no-ops and whose ExecStreaming delegates to a real PodmanCLI driving // a shell stub — so it returns a StreamingExec with a REAL, terminatable // Process (the host's Reload / live-session Stop call Process.Terminate, which a @@ -142,14 +142,14 @@ type stubStreamingRuntime struct { calls []string execSpecs []runtime.StreamingExecSpec cli *runtime.PodmanCLI - stopErr error // when set, engine Stop fails — models a Teardown partial failure - stopErrByID map[runtime.ContainerID]error // per-container Stop error; overrides stopErr for the keyed id - stopGate chan struct{} // when non-nil, Stop blocks on it (after recording) — test-controlled teardown parking - stopEntered chan runtime.ContainerID // when non-nil, Stop sends id after recording, before parking — a real "reached Stop" event for a test to gate on - callsByID map[runtime.ContainerID][]string // per-container lifecycle calls (stop/remove), for fan-out isolation assertions - execGate chan struct{} // when non-nil, ExecStreaming blocks on it (after recording, ctx-escapable) — parks a Start/Reload relaunch so a concurrent-dispatch test can hold one lifecycle op in flight (docs/designs/infra/runtime/compass-runner-concurrent-dispatch/design.md) - execEntered chan runtime.ContainerID // when non-nil, ExecStreaming sends id after recording, before parking — the real "reached the agent launch" event a test gates on - created []runtime.ContainerSpec + stopErr error // when set, engine Stop fails — models a Teardown partial failure + stopErrByID map[runtime.WorkloadID]error // per-container Stop error; overrides stopErr for the keyed id + stopGate chan struct{} // when non-nil, Stop blocks on it (after recording) — test-controlled teardown parking + stopEntered chan runtime.WorkloadID // when non-nil, Stop sends id after recording, before parking — a real "reached Stop" event for a test to gate on + callsByID map[runtime.WorkloadID][]string // per-container lifecycle calls (stop/remove), for fan-out isolation assertions + execGate chan struct{} // when non-nil, ExecStreaming blocks on it (after recording, ctx-escapable) — parks a Start/Reload relaunch so a concurrent-dispatch test can hold one lifecycle op in flight (docs/designs/infra/runtime/compass-runner-concurrent-dispatch/design.md) + execEntered chan runtime.WorkloadID // when non-nil, ExecStreaming sends id after recording, before parking — the real "reached the agent launch" event a test gates on + created []runtime.WorkloadSpec } func newStubStreamingRuntime(t *testing.T) *stubStreamingRuntime { @@ -163,22 +163,22 @@ func newStubStreamingRuntime(t *testing.T) *stubStreamingRuntime { return &stubStreamingRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog)} } -func (f *stubStreamingRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *stubStreamingRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { f.mu.Lock() f.calls = append(f.calls, "create") f.created = append(f.created, spec) f.mu.Unlock() - return runtime.ContainerID("fake-id"), nil + return runtime.WorkloadID("fake-id"), nil } -func (f *stubStreamingRuntime) Start(context.Context, runtime.ContainerID) error { +func (f *stubStreamingRuntime) Start(context.Context, runtime.WorkloadID) error { f.record("start") return nil } -func (f *stubStreamingRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *stubStreamingRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { f.record("exec") return runtime.ExecOutput{}, nil } -func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { f.mu.Lock() f.calls = append(f.calls, "exec_streaming") f.execSpecs = append(f.execSpecs, spec) @@ -202,7 +202,7 @@ func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.Con } return f.cli.ExecStreaming(ctx, id, spec) } -func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.ContainerID, _ time.Duration) error { +func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.WorkloadID, _ time.Duration) error { f.record("stop") f.recordForID(id, "stop") f.mu.Lock() @@ -226,16 +226,16 @@ func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.ContainerID, _ f.mu.Unlock() return err } -func (f *stubStreamingRuntime) Remove(_ context.Context, id runtime.ContainerID) error { +func (f *stubStreamingRuntime) Remove(_ context.Context, id runtime.WorkloadID) error { f.record("remove") f.recordForID(id, "remove") return nil } func (f *stubStreamingRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *stubStreamingRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *stubStreamingRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *stubStreamingRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *stubStreamingRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } @@ -248,11 +248,11 @@ func (f *stubStreamingRuntime) record(call string) { // recordForID records a lifecycle call against a specific container id, so a // fan-out isolation assertion can prove one container reached remove while // another aborted at stop. -func (f *stubStreamingRuntime) recordForID(id runtime.ContainerID, call string) { +func (f *stubStreamingRuntime) recordForID(id runtime.WorkloadID, call string) { f.mu.Lock() defer f.mu.Unlock() if f.callsByID == nil { - f.callsByID = map[runtime.ContainerID][]string{} + f.callsByID = map[runtime.WorkloadID][]string{} } f.callsByID[id] = append(f.callsByID[id], call) } @@ -260,7 +260,7 @@ func (f *stubStreamingRuntime) recordForID(id runtime.ContainerID, call string) // countCallForID reports how many times the named lifecycle call was recorded // for a specific container id, taken under the lock — mirrors countCall for the // per-container fan-out assertions. -func (f *stubStreamingRuntime) countCallForID(id runtime.ContainerID, call string) int { +func (f *stubStreamingRuntime) countCallForID(id runtime.WorkloadID, call string) int { f.mu.Lock() defer f.mu.Unlock() n := 0 @@ -306,10 +306,10 @@ func (f *stubStreamingRuntime) countCall(call string) int { // createdSpecs returns a copy of the ContainerSpecs the host has created // containers with so far, taken under the lock. -func (f *stubStreamingRuntime) createdSpecs() []runtime.ContainerSpec { +func (f *stubStreamingRuntime) createdSpecs() []runtime.WorkloadSpec { f.mu.Lock() defer f.mu.Unlock() - return append([]runtime.ContainerSpec(nil), f.created...) + return append([]runtime.WorkloadSpec(nil), f.created...) } // --- capturing PublishEvents server ------------------------------------------ diff --git a/go/internal/runner/host.go b/go/internal/runner/host.go index 24f6bdd5..37e19c50 100644 --- a/go/internal/runner/host.go +++ b/go/internal/runner/host.go @@ -54,7 +54,7 @@ type SpecBuilder interface { // RefreshConfig type-assert h.engine against it to gate the microVM-specific // serving/refresh legs; podman and every test fake lack the method, so their // paths stay byte-identical (record §(c), Global Constraints — never a verb on -// the frozen ContainerRuntime interface). +// the frozen WorkloadRuntime interface). type vsockGatewayEngine interface { AgentGatewayEndpoint(name string) (endpoint string, ok bool) } @@ -65,7 +65,7 @@ type agentHost struct { link *ServerLink runtime *runtime.AgentRuntime registry *runtime.AgentRegistry - engine runtime.ContainerRuntime + engine runtime.WorkloadRuntime specs SpecBuilder log *slog.Logger runtimeDir string @@ -110,7 +110,7 @@ type agentHost struct { type liveSession struct { sessionID string containerName string - containerID runtime.ContainerID + containerID runtime.WorkloadID stream *AgentStream state compassv1.AgentSessionState // agentAccountID is the owned agent account this session belongs to, copied @@ -136,7 +136,7 @@ type AgentHostConfig struct { // runtime + registry (so a launched container resolves by name), the container // engine, the spec builder Provision derives its AgentSpec from, and the host's // own config. newID mints session ids; nil uses a monotonic counter. -func NewSessionHost(link *ServerLink, rt *runtime.AgentRuntime, registry *runtime.AgentRegistry, engine runtime.ContainerRuntime, specs SpecBuilder, cfg AgentHostConfig, log *slog.Logger, newID func() string) SessionHost { +func NewSessionHost(link *ServerLink, rt *runtime.AgentRuntime, registry *runtime.AgentRegistry, engine runtime.WorkloadRuntime, specs SpecBuilder, cfg AgentHostConfig, log *slog.Logger, newID func() string) SessionHost { if log == nil { log = slog.Default() } @@ -765,7 +765,7 @@ func (h *agentHost) RefreshConfig(ctx context.Context) error { type target struct { sessionID string containerName string - containerID runtime.ContainerID + containerID runtime.WorkloadID lastVersion string } h.mu.Lock() @@ -857,7 +857,7 @@ func (h *agentHost) teardownContainer(ctx context.Context, containerName string) // without aborting the fleet; the tracked version advances only after a // successful reload. Split out of RefreshConfig so the container lock scopes to // exactly one leg via defer. -func (h *agentHost) refreshOneContainer(ctx context.Context, sessionID, containerName string, containerID runtime.ContainerID, lastVersion string) error { +func (h *agentHost) refreshOneContainer(ctx context.Context, sessionID, containerName string, containerID runtime.WorkloadID, lastVersion string) error { unlock := h.lockContainer(containerName) defer unlock() diff --git a/go/internal/runner/host_concurrency_test.go b/go/internal/runner/host_concurrency_test.go index 40ae3a2f..e958573f 100644 --- a/go/internal/runner/host_concurrency_test.go +++ b/go/internal/runner/host_concurrency_test.go @@ -78,7 +78,7 @@ func TestStartSameContainerSerializesClosingTOCTOU(t *testing.T) { // launch never blocks the send). Released on every exit path so a failing // assertion cannot hang the suite. gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) engine.mu.Lock() engine.execGate = gate engine.execEntered = entered @@ -170,7 +170,7 @@ func TestStartDifferentContainersOverlap(t *testing.T) { } gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) engine.mu.Lock() engine.execGate = gate engine.execEntered = entered @@ -190,7 +190,7 @@ func TestStartDifferentContainersOverlap(t *testing.T) { // overlap, so both entry events fire while both are parked on the shared gate. // A GLOBAL lock would admit only one; the second event never fires and this // times out on the ceiling. - seen := map[runtime.ContainerID]bool{} + seen := map[runtime.WorkloadID]bool{} for range 2 { select { case id := <-entered: diff --git a/go/internal/runner/host_test.go b/go/internal/runner/host_test.go index a5507931..97b7d10c 100644 --- a/go/internal/runner/host_test.go +++ b/go/internal/runner/host_test.go @@ -322,7 +322,7 @@ func TestProvisionPerContainerConfigRootsAreDistinct(t *testing.T) { t.Fatalf("engine created %d containers, want 2", len(created)) } - configMount := func(spec runtime.ContainerSpec) *runtime.Mount { + configMount := func(spec runtime.WorkloadSpec) *runtime.Mount { for i := range spec.Mounts { if spec.Mounts[i].ContainerPath == agentConfigMountPath { return &spec.Mounts[i] @@ -675,11 +675,11 @@ func TestCloseIsBestEffortOnStopError(t *testing.T) { nameA := provisionAndStart(t, host, "a") nameB := provisionAndStart(t, host, "b") - // The engine keys container ids by name (Create returns ContainerID(spec.Name)), + // The engine keys container ids by name (Create returns WorkloadID(spec.Name)), // so fail exactly container A's Stop and leave B healthy. - idA := runtime.ContainerID(nameA) - idB := runtime.ContainerID(nameB) - engine.stopErrByID = map[runtime.ContainerID]error{idA: errors.New("engine stop failed")} + idA := runtime.WorkloadID(nameA) + idB := runtime.WorkloadID(nameB) + engine.stopErrByID = map[runtime.WorkloadID]error{idA: errors.New("engine stop failed")} host.Close(ctx) @@ -727,7 +727,7 @@ func TestCloseJoinsConcurrentTeardowns(t *testing.T) { // and then park without waiting for the test to read. The gate is released on // every exit path (including a failing assertion) so the suite can't hang. gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(gate) }) } t.Cleanup(release) diff --git a/go/internal/runner/host_vsock_gateway_test.go b/go/internal/runner/host_vsock_gateway_test.go index 6ee08038..41423e8b 100644 --- a/go/internal/runner/host_vsock_gateway_test.go +++ b/go/internal/runner/host_vsock_gateway_test.go @@ -30,7 +30,7 @@ import ( "github.com/RigelBuild/compass/go/internal/runtime" ) -// vsockGatewayFakeRuntime is a ContainerRuntime that ALSO implements the +// vsockGatewayFakeRuntime is a WorkloadRuntime that ALSO implements the // vsockGatewayEngine probe (AgentGatewayEndpoint), so agentHost drives its // microVM Provision leg. Create returns the container name as its engine id (so // each container is distinguishable), and AgentGatewayEndpoint hands back a real @@ -57,12 +57,12 @@ func newVsockGatewayFakeRuntime(t *testing.T) *vsockGatewayFakeRuntime { } } -func (r *vsockGatewayFakeRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (r *vsockGatewayFakeRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { r.mu.Lock() r.calls = append(r.calls, "create") r.created = append(r.created, spec) r.mu.Unlock() - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } func (r *vsockGatewayFakeRuntime) AgentGatewayEndpoint(name string) (string, bool) { diff --git a/go/internal/runner/runner.go b/go/internal/runner/runner.go index 1bbb3e31..89d32e46 100644 --- a/go/internal/runner/runner.go +++ b/go/internal/runner/runner.go @@ -41,7 +41,7 @@ type RunnerConfig struct { // Token is the per-Runner bearer token presented on every RPC. Token string // Engine is the container runtime seam the Runner hosts agents on. - Engine runtime.ContainerRuntime + Engine runtime.WorkloadRuntime // RuntimeDir is the Runner-owned base directory under which per-container // agent sockets live (RuntimeDir/containers//agent.sock, OQ-5). // Owner-only; the socket is a local hop that never touches the network. diff --git a/go/internal/runner/secrets_refresh_test.go b/go/internal/runner/secrets_refresh_test.go index f02b71d7..06d1d30f 100644 --- a/go/internal/runner/secrets_refresh_test.go +++ b/go/internal/runner/secrets_refresh_test.go @@ -97,7 +97,7 @@ func TestRefreshSecretsMaterializesForBoundSession(t *testing.T) { } } -// recordingExecRuntime is a ContainerRuntime whose ExecStreaming delegates to a +// recordingExecRuntime is a WorkloadRuntime whose ExecStreaming delegates to a // real terminatable child (so Start's agent relay works) and whose one-shot Exec // records the spec (so a materialize's exec is observable). It composes the // stub-streaming child with an exec recorder. @@ -113,7 +113,7 @@ func newRecordingExecRuntime(t *testing.T) *recordingExecRuntime { return &recordingExecRuntime{stubStreamingRuntime: newStubStreamingRuntime(t)} } -func (r *recordingExecRuntime) Exec(_ context.Context, _ runtime.ContainerID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { +func (r *recordingExecRuntime) Exec(_ context.Context, _ runtime.WorkloadID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { r.mu.Lock() r.execSpecsOneShot = append(r.execSpecsOneShot, spec) err := r.execErr diff --git a/go/internal/runnerhub/integration_pgtest_test.go b/go/internal/runnerhub/integration_pgtest_test.go index b4ffe129..9f949053 100644 --- a/go/internal/runnerhub/integration_pgtest_test.go +++ b/go/internal/runnerhub/integration_pgtest_test.go @@ -22,7 +22,7 @@ package runnerhub_test // Post under it through the SAME PostMessage handler a human takes — so it // commits a Message row to Postgres (observed by reading it back under the agent // account) AND fans MessagePosted onto the comms bus (observed on a live -// subscription). The container is a stub ContainerRuntime (no real compass-agent +// subscription). The container is a stub WorkloadRuntime (no real compass-agent // image exists in CI) whose ExecStreaming spawns a live, terminatable child so // host.Stop reaps a real process. This is the seam terminating a real // agent-initiated wire and committing to a real store — an external-package @@ -352,7 +352,7 @@ func (r *integResolver) resolve(_ context.Context, presented string, want store. return r.subj, nil } -// integStubRuntime is the fake ContainerRuntime backing the Runner: its +// integStubRuntime is the fake WorkloadRuntime backing the Runner: its // ExecStreaming spawns a real, terminatable child (a shell-stub `podman` // exec-ing `sleep`) so host.Stop's Terminate reaps a live process and // StartAgent's pipe drains end on that reap. Post-#16 nothing rides @@ -374,28 +374,28 @@ func newIntegStubRuntime(t *testing.T) *integStubRuntime { return &integStubRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog)} } -func (f *integStubRuntime) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { - return runtime.ContainerID("fake-id"), nil +func (f *integStubRuntime) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { + return runtime.WorkloadID("fake-id"), nil } -func (f *integStubRuntime) Start(context.Context, runtime.ContainerID) error { return nil } -func (f *integStubRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *integStubRuntime) Start(context.Context, runtime.WorkloadID) error { return nil } +func (f *integStubRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { return runtime.ExecOutput{}, nil } -func (f *integStubRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *integStubRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { // A real streaming exec against the shell stub: a live, terminatable Process // (host.Stop → Terminate) whose stdout/stderr pipes StartAgent drains. The // stub just sleeps, so the pipes stay empty until Terminate closes them. return f.cli.ExecStreaming(ctx, id, spec) } -func (f *integStubRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *integStubRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return nil } -func (f *integStubRuntime) Remove(context.Context, runtime.ContainerID) error { return nil } -func (f *integStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *integStubRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *integStubRuntime) Remove(context.Context, runtime.WorkloadID) error { return nil } +func (f *integStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } +func (f *integStubRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *integStubRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *integStubRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index e8aaf85f..6a4699d9 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -60,12 +60,12 @@ type AgentSpec struct { // created from, so callers can exec as the agent user without re-deriving the // workspace. type AgentHandle struct { - id ContainerID + id WorkloadID spec AgentSpec } // ID returns the resolved container id. -func (h *AgentHandle) ID() ContainerID { return h.id } +func (h *AgentHandle) ID() WorkloadID { return h.id } // Name returns the stable container name. func (h *AgentHandle) Name() string { return h.spec.Name } @@ -107,16 +107,16 @@ func (e *StageError) Error() string { // Unwrap exposes the underlying runtime error for errors.Is/As. func (e *StageError) Unwrap() error { return e.Err } -// InContainerError is an in-container exec that ran but exited non-zero, tagged -// with the lifecycle stage and carrying the captured stderr. -type InContainerError struct { +// InWorkloadError is an exec inside the workload that ran but exited non-zero, +// tagged with the lifecycle stage and carrying the captured stderr. +type InWorkloadError struct { Stage string ExitCode int Stderr string } -func (e *InContainerError) Error() string { - return fmt.Sprintf("%s failed inside the container (exit %d): %s", e.Stage, e.ExitCode, e.Stderr) +func (e *InWorkloadError) Error() string { + return fmt.Sprintf("%s failed inside the workload (exit %d): %s", e.Stage, e.ExitCode, e.Stderr) } // InvalidConfigError is an agent configuration the lifecycle rejected before @@ -138,35 +138,35 @@ func atStage(stage string, err error) error { return &StageError{Stage: stage, Err: err} } -// requireSuccess turns a non-zero in-container exec into an InContainerError +// requireSuccess turns a non-zero in-container exec into an InWorkloadError // tagged with the stage, surfacing its captured stderr. func requireSuccess(stage string, out ExecOutput) error { if out.Success() { return nil } - return &InContainerError{Stage: stage, ExitCode: out.ExitCode, Stderr: out.Stderr} + return &InWorkloadError{Stage: stage, ExitCode: out.ExitCode, Stderr: out.Stderr} } -// AgentRuntime drives the per-agent container lifecycle over a ContainerRuntime. +// AgentRuntime drives the per-agent container lifecycle over a WorkloadRuntime. // // When constructed with an AgentRegistry via NewAgentRuntimeWithRegistry, a // successful Launch registers the handle and Teardown deregisters it, so the // Runner's session RPCs can resolve a launched container by name. type AgentRuntime struct { - runtime ContainerRuntime + runtime WorkloadRuntime registry *AgentRegistry } // NewAgentRuntime builds a lifecycle façade with no registry: Launch/Teardown // manage containers but register nothing. -func NewAgentRuntime(runtime ContainerRuntime) *AgentRuntime { +func NewAgentRuntime(runtime WorkloadRuntime) *AgentRuntime { return &AgentRuntime{runtime: runtime} } // NewAgentRuntimeWithRegistry builds a façade that registers each launched // handle in registry so StartAgentSession can resolve the container by name, and // deregisters it on teardown. -func NewAgentRuntimeWithRegistry(runtime ContainerRuntime, registry *AgentRegistry) *AgentRuntime { +func NewAgentRuntimeWithRegistry(runtime WorkloadRuntime, registry *AgentRegistry) *AgentRuntime { return &AgentRuntime{runtime: runtime, registry: registry} } @@ -244,7 +244,7 @@ func (r *AgentRuntime) Teardown(ctx context.Context, handle *AgentHandle) error // as the aggregate env file. The path components are positional args to a fixed // sh script, never interpolated into the script text, so a crafted path cannot // inject shell. -func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id ContainerID, uid uint32, homeDir, relPath, body string) error { +func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id WorkloadID, uid uint32, homeDir, relPath, body string) error { script := `set -eu; umask 077; dir=$(dirname "$1"); mkdir -p "$dir"; cat > "$1"; chmod 600 "$1"` spec := NewExecSpec("sh", "-c", script, "sh", filepath.Join(homeDir, relPath)). AsUser(strconv.FormatUint(uint64(uid), 10)). @@ -259,8 +259,8 @@ func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id ContainerID, uid u // createAndStart creates then starts the container, cleaning up a created but // unstarted container so a retry with the same name starts clean. -func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (ContainerID, error) { - container := ContainerSpec{ +func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (WorkloadID, error) { + container := WorkloadSpec{ Image: spec.Image, Name: spec.Name, CapAdd: []string{capNetAdmin}, @@ -291,7 +291,7 @@ func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (Cont // inGuestEgressArmer is a backend that arms the egress firewall itself, inside // its isolation boundary (as guest root, before the exec gate opens), so the // host-side armEgress exec must be skipped. It is a marker, deliberately NOT a -// verb on the frozen ContainerRuntime interface (podman.go): AgentRuntime probes +// verb on the frozen WorkloadRuntime interface (podman.go): AgentRuntime probes // for it and skips arming when a backend self-arms (design §(c)). Only // MicroVMRuntime implements it; PodmanCLI and the test fakes do not, so the // host-side arm runs byte-identically for them. @@ -304,7 +304,7 @@ type inGuestEgressArmer interface { // backend that self-arms egress in-guest (inGuestEgressArmer, the microVM // backend) has already armed by Start, so the host-side armEgress exec — which // on that backend would run capability-less and fail — is skipped. -func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { +func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentSpec) error { if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err @@ -319,7 +319,7 @@ func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec Agent // armEgress arms the egress firewall as the image's default user (uid 1000) // with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with no // capabilities — cannot alter the ruleset. -func (r *AgentRuntime) armEgress(ctx context.Context, id ContainerID, egress EgressPolicy) error { +func (r *AgentRuntime) armEgress(ctx context.Context, id WorkloadID, egress EgressPolicy) error { out, err := r.runtime.Exec(ctx, id, NewExecSpec("sh", "-c", egress.NftScript())) if err != nil { return atStage("arm egress", err) @@ -329,7 +329,7 @@ func (r *AgentRuntime) armEgress(ctx context.Context, id ContainerID, egress Egr // installCredentials installs the scoped git credential helper into the agent's // $HOME, as the agent user. A no-op when the workspace has no credentials. -func (r *AgentRuntime) installCredentials(ctx context.Context, id ContainerID, workspace Workspace) error { +func (r *AgentRuntime) installCredentials(ctx context.Context, id WorkloadID, workspace Workspace) error { script, err := workspace.CredentialSetupScript() if err != nil { return &InvalidConfigError{Err: err} @@ -355,7 +355,7 @@ func (r *AgentRuntime) installCredentials(ctx context.Context, id ContainerID, w // user, so an agent that self-clones post-launch has an owned working dir. Run // as the agent uid (not root) so the directory is owned by the agent. Its // precondition: CheckoutDir's parent must be writable by the agent uid. -func (r *AgentRuntime) ensureCheckoutDir(ctx context.Context, id ContainerID, workspace Workspace) error { +func (r *AgentRuntime) ensureCheckoutDir(ctx context.Context, id WorkloadID, workspace Workspace) error { spec := NewExecSpec("mkdir", "-p", workspace.CheckoutDir). AsUser(strconv.FormatUint(uint64(workspace.UID), 10)) out, err := r.runtime.Exec(ctx, id, spec) diff --git a/go/internal/runtime/agent_test.go b/go/internal/runtime/agent_test.go index 18b27702..d3f1b928 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -34,21 +34,21 @@ func newFakeRuntime(t *testing.T) *fakeRuntime { return &fakeRuntime{t: t} } -func (f *fakeRuntime) Create(_ context.Context, spec ContainerSpec) (ContainerID, error) { +func (f *fakeRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, error) { f.record("create:" + spec.Name) // The container must carry NET_ADMIN so the entrypoint can arm nft. if !slices.Contains(spec.CapAdd, "NET_ADMIN") { f.t.Errorf("Create spec.CapAdd = %v, must contain NET_ADMIN so the entrypoint can arm the firewall", spec.CapAdd) } - return ContainerID("fake-id"), nil + return WorkloadID("fake-id"), nil } -func (f *fakeRuntime) Start(_ context.Context, _ ContainerID) error { +func (f *fakeRuntime) Start(_ context.Context, _ WorkloadID) error { f.record("start") return nil } -func (f *fakeRuntime) Exec(_ context.Context, _ ContainerID, spec ExecSpec) (ExecOutput, error) { +func (f *fakeRuntime) Exec(_ context.Context, _ WorkloadID, spec ExecSpec) (ExecOutput, error) { joined := strings.Join(spec.Command, " ") f.mu.Lock() f.calls = append(f.calls, "exec:"+joined) @@ -61,21 +61,21 @@ func (f *fakeRuntime) Exec(_ context.Context, _ ContainerID, spec ExecSpec) (Exe return ExecOutput{}, nil } -func (f *fakeRuntime) ExecStreaming(_ context.Context, _ ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (f *fakeRuntime) ExecStreaming(_ context.Context, _ WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { f.record("exec_streaming:" + strings.Join(spec.Command, " ")) // No T6 lifecycle test drives a streaming exec. Rather than synthesize pipe // handles it can't honestly back (or spawn a real `sh -c cat` the way the - // Rust fake does), the fake refuses — keeping the ContainerRuntime interface + // Rust fake does), the fake refuses — keeping the WorkloadRuntime interface // satisfied without leaking a real process. return nil, errors.New("fakeRuntime does not support streaming exec") } -func (f *fakeRuntime) Stop(_ context.Context, _ ContainerID, _ time.Duration) error { +func (f *fakeRuntime) Stop(_ context.Context, _ WorkloadID, _ time.Duration) error { f.record("stop") return nil } -func (f *fakeRuntime) Remove(_ context.Context, _ ContainerID) error { +func (f *fakeRuntime) Remove(_ context.Context, _ WorkloadID) error { f.record("remove") return nil } @@ -84,11 +84,11 @@ func (f *fakeRuntime) Exists(_ context.Context, _ string) (bool, error) { return false, nil } -func (f *fakeRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { +func (f *fakeRuntime) MountLabel(_ context.Context, _ WorkloadID) (string, error) { return "", nil } -func (f *fakeRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (f *fakeRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return nil } @@ -260,12 +260,12 @@ func TestFailedProvisionRemovesThePartialContainer(t *testing.T) { _, err := rt.Launch(t.Context(), specWithCreds(true)) - var inContainer *InContainerError + var inContainer *InWorkloadError if !errors.As(err, &inContainer) { - t.Fatalf("Launch error = %v, want *InContainerError", err) + t.Fatalf("Launch error = %v, want *InWorkloadError", err) } if inContainer.Stage != "arm egress" { - t.Fatalf("InContainerError.Stage = %q, want %q", inContainer.Stage, "arm egress") + t.Fatalf("InWorkloadError.Stage = %q, want %q", inContainer.Stage, "arm egress") } if !slices.Contains(fake.callsSnapshot(), "remove") { t.Errorf("a failed launch must remove the partial container; calls = %v", fake.callsSnapshot()) @@ -338,11 +338,11 @@ func TestInGuestArmerSkipsHostArmEgress(t *testing.T) { } // TestCreateArgsIgnoresEgress pins the podman byte-identical constraint: setting -// ContainerSpec.Egress must not change the `podman create` argv at all. The +// WorkloadSpec.Egress must not change the `podman create` argv at all. The // podman backend arms via AgentRuntime.armEgress, never from the spec field, so // createArgs output for a spec with Egress set equals its output without. func TestCreateArgsIgnoresEgress(t *testing.T) { - base := ContainerSpec{Name: "c", Image: "img", UID: 1000, CapAdd: []string{capNetAdmin}} + base := WorkloadSpec{Name: "c", Image: "img", UID: 1000, CapAdd: []string{capNetAdmin}} withEgress := base withEgress.Egress = MustAllowEgress("github.com", "example.com") diff --git a/go/internal/runtime/contract_microvm_test.go b/go/internal/runtime/contract_microvm_test.go index 15e614ce..c0c002bf 100644 --- a/go/internal/runtime/contract_microvm_test.go +++ b/go/internal/runtime/contract_microvm_test.go @@ -2,7 +2,7 @@ package runtime -// The microVM leg of the shared ContainerRuntime contract suite (record §U5): +// The microVM leg of the shared WorkloadRuntime contract suite (record §U5): // runs runContractSuite against a real MicroVMRuntime on live hardware, gated on // microvmtest.Require(t) (skip-on-absent-KVM, hard-fail under // COMPASS_REQUIRE_MICROVM=1). It supplies the microVM caps encoding all 6 @@ -26,7 +26,7 @@ import ( ) // TestContractSuite_MicroVM drives the shared contract rows against a live -// MicroVMRuntime through the ContainerRuntime interface. The factory builds a +// MicroVMRuntime through the WorkloadRuntime interface. The factory builds a // runtime from the resolved test env; sessions are created with a single // /workspace virtio-fs share and uid 1000. All divergence caps are ON, so the // microVM-specific rows (output cap, non-numeric user, empty MountLabel, ignored @@ -36,9 +36,9 @@ func TestContractSuite_MicroVM(t *testing.T) { caps := backendCaps{ name: "microvm", - makeSpec: func(t *testing.T, name string) ContainerSpec { + makeSpec: func(t *testing.T, name string) WorkloadSpec { t.Helper() - return ContainerSpec{ + return WorkloadSpec{ Name: name, UID: 1000, Mounts: []Mount{{HostPath: t.TempDir(), ContainerPath: "/workspace"}}, @@ -62,7 +62,7 @@ func TestContractSuite_MicroVM(t *testing.T) { }, } - runContractSuite(t, func(t *testing.T) ContainerRuntime { + runContractSuite(t, func(t *testing.T) WorkloadRuntime { t.Helper() return NewMicroVMRuntime(e2eConfig(t, env)) }, caps) @@ -80,7 +80,7 @@ func TestMicroVMQBudget(t *testing.T) { m := NewMicroVMRuntime(e2eConfig(t, env)) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "qbudget-agent", UID: 1000, Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, diff --git a/go/internal/runtime/contract_podman_test.go b/go/internal/runtime/contract_podman_test.go index b1dab4ce..d54a0e68 100644 --- a/go/internal/runtime/contract_podman_test.go +++ b/go/internal/runtime/contract_podman_test.go @@ -2,7 +2,7 @@ package runtime -// The podman leg of the shared ContainerRuntime contract suite (record §U5): +// The podman leg of the shared WorkloadRuntime contract suite (record §U5): // runs runContractSuite against a real rootless-podman PodmanCLI, gated on // podmanUsable() (skip-not-fail where podman is absent, the existing suite's // pattern, lifecycle_test.go:54-60). It supplies the podman caps: the @@ -18,7 +18,7 @@ import ( ) // TestContractSuite_Podman drives the shared contract rows against rootless -// podman through the ContainerRuntime interface. It builds the agent image once +// podman through the WorkloadRuntime interface. It builds the agent image once // (buildImage) and hands runContractSuite a factory minting a fresh PodmanCLI, // with containers created from that image running `sleep infinity` as uid 1000 // — the production keep-alive-plus-exec shape (lifecycle_test.go). @@ -30,9 +30,9 @@ func TestContractSuite_Podman(t *testing.T) { caps := backendCaps{ name: "podman", - makeSpec: func(t *testing.T, name string) ContainerSpec { + makeSpec: func(t *testing.T, name string) WorkloadSpec { t.Helper() - return ContainerSpec{ + return WorkloadSpec{ Image: imageTag, Name: name, Command: []string{"sleep", "infinity"}, @@ -62,7 +62,7 @@ func TestContractSuite_Podman(t *testing.T) { }, } - runContractSuite(t, func(t *testing.T) ContainerRuntime { + runContractSuite(t, func(t *testing.T) WorkloadRuntime { t.Helper() return NewPodmanCLI() }, caps) diff --git a/go/internal/runtime/contract_suite_test.go b/go/internal/runtime/contract_suite_test.go index 5cd718a0..9753caac 100644 --- a/go/internal/runtime/contract_suite_test.go +++ b/go/internal/runtime/contract_suite_test.go @@ -1,8 +1,8 @@ package runtime -// The shared ContainerRuntime contract suite (record §U5, the V2b acceptance +// The shared WorkloadRuntime contract suite (record §U5, the V2b acceptance // gate): one table-driven body proving MicroVMRuntime and PodmanCLI behave -// identically through the runtime.ContainerRuntime interface, run against BOTH +// identically through the runtime.WorkloadRuntime interface, run against BOTH // backends. It is UNTAGGED (package runtime, no build tag) so it compiles on // every platform: it references ONLY untagged production symbols plus the // backendCaps descriptor, never a KVM/podman-only symbol (microvmtest, @@ -12,7 +12,7 @@ package runtime // //go:build microvm && unix) supply the factory + caps and gate on their // backend's availability. // -// The suite drives ContainerRuntime DIRECTLY — a different, lower layer than +// The suite drives WorkloadRuntime DIRECTLY — a different, lower layer than // TestPerAgentContainerLifecycle (lifecycle_test.go), which drives // AgentRuntime.Launch. The two do not overlap. // @@ -40,12 +40,12 @@ type backendCaps struct { // name identifies the backend under test, for subtest / failure messages. name string - // makeSpec builds a ContainerSpec for the backend: the podman leg bakes an + // makeSpec builds a WorkloadSpec for the backend: the podman leg bakes an // Image + a `sleep infinity` keep-alive Command, the microVM leg a // /workspace virtio-fs mount; both bake UID 1000. Backend-specific container // creation is encapsulated HERE, never in the shared body (record 122-123). // t supplies t.TempDir() for a per-session workspace. - makeSpec func(t *testing.T, name string) ContainerSpec + makeSpec func(t *testing.T, name string) WorkloadSpec // refusesRootExec: a uid-0 exec is refused with a host/guest error (microVM // §(b) uid enforcement, record 576). When false (podman) the equivalent @@ -115,7 +115,7 @@ var _ = runContractSuite // lifecycle rows (duplicate-name, idempotence, exists, stop-grace) each manage // their own so an identity/teardown row never perturbs another. A thin // dispatcher: each row is its own helper. -func runContractSuite(t *testing.T, newRuntime func(t *testing.T) ContainerRuntime, caps backendCaps) { +func runContractSuite(t *testing.T, newRuntime func(t *testing.T) WorkloadRuntime, caps backendCaps) { t.Helper() rt := newRuntime(t) primary := startRunning(t, rt, caps, "contract-primary") @@ -149,7 +149,7 @@ func runContractSuite(t *testing.T, newRuntime func(t *testing.T) ContainerRunti // echoed body; a non-zero exit is a SUCCESSFUL call returning the code, NEVER an // error. A regression that folded a non-zero exit into err would turn every // expected-failure probe (a denied firewall check) into a fatal. -func rowExecExitCodes(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "echo hello-body").AsUser("1000")) if err != nil { @@ -176,7 +176,7 @@ func rowExecExitCodes(t *testing.T, rt ContainerRuntime, primary ContainerID) { // rowExecStdin — row 2 (record 567-569, agent.go:238-246): the script-over-stdin // shape end to end (the secret-safe channel). `sh -s` reads the script from // stdin, so the body never appears in the argv / process list. -func rowExecStdin(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowExecStdin(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-s").WithStdin("echo from-stdin").AsUser("1000")) if err != nil { @@ -194,7 +194,7 @@ func rowExecStdin(t *testing.T, rt ContainerRuntime, primary ContainerID) { // Stdout, proving live bidirectional interleaving over the pipes; then // Terminate. stderr is drained in a goroutine so a full pipe never deadlocks the // terminate. -func rowStreamingStdio(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowStreamingStdio(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("cat").AsUser("1000")) if err != nil { @@ -225,7 +225,7 @@ func rowStreamingStdio(t *testing.T, rt ContainerRuntime, caps backendCaps, prim // portable *ExitStatusError, podman the byte-identical *exec.ExitError — both // prove a signalled exit isDeliberateKill accepts, so the podman byte-path stays // unregressed AND the microVM portable path works. -func rowKillWait(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowKillWait(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) if err != nil { @@ -244,7 +244,7 @@ func rowKillWait(t *testing.T, rt ContainerRuntime, caps backendCaps, primary Co // no orphan survives a host-side cancel. Wait returning IS the reap signal (Wait // reaps). A bounded select fails loudly rather than hanging the suite if the // child is never reaped. -func rowCtxCancelReaps(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowCtxCancelReaps(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() cctx, cancel := context.WithCancel(t.Context()) stream, err := rt.ExecStreaming(cctx, primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) @@ -268,7 +268,7 @@ func rowCtxCancelReaps(t *testing.T, rt ContainerRuntime, primary ContainerID) { // exec is refused on the microVM backend; the podman row asserts its equivalent // posture — a directed unprivileged exec runs as the requested uid, never // silently escalated to root. -func rowUIDEnforcement(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowUIDEnforcement(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() if caps.refusesRootExec { if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("0")); err == nil { @@ -288,7 +288,7 @@ func rowUIDEnforcement(t *testing.T, rt ContainerRuntime, caps backendCaps, prim // rowResize — row 11 (record 577-578): Resize returns ErrResizeNotImplemented on // both backends until C3. The S1-frozen verb must refuse legibly, never fake a // limit change that never happened. -func rowResize(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowResize(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() if err := rt.Resize(t.Context(), primary, ResourceLimits{CPUShares: 512}); !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) @@ -298,7 +298,7 @@ func rowResize(t *testing.T, rt ContainerRuntime, primary ContainerID) { // rowMountLabel — row 12: "" on microVM (record 587, capability-gated); podman // returns its real label, which may legitimately be "" on a non-SELinux host, so // there the row asserts only a no-error read. -func rowMountLabel(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowMountLabel(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() label, err := rt.MountLabel(t.Context(), primary) if err != nil { @@ -312,7 +312,7 @@ func rowMountLabel(t *testing.T, rt ContainerRuntime, caps backendCaps, primary // rowNonNumericUser — divergence 2 (microVM only, record 585-587): a non-numeric // ExecSpec.User is a host-side error. Asserting the refusal fails a backend that // started resolving names (silently widening). -func rowNonNumericUser(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowNonNumericUser(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("not-a-number")); err == nil { t.Fatal("a non-numeric ExecSpec.User must be a host-side error on this backend; got no error") @@ -323,7 +323,7 @@ func rowNonNumericUser(t *testing.T, rt ContainerRuntime, primary ContainerID) { // one-shot exec returns the truncation error, not a clipped tail. A backend that // started truncating silently (widening) would pass a caller a partial output as // if whole — this row fails that. -func rowOutputCap(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowOutputCap(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() // 9 MiB > the 8 MiB cap; content is irrelevant, only the byte count. if _, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "head -c 9437184 /dev/zero").AsUser("1000")); err == nil { @@ -336,7 +336,7 @@ func rowOutputCap(t *testing.T, rt ContainerRuntime, primary ContainerID) { // Command and an added capability still boots (Command is not the keep-alive), // still execs, and the workload has an EMPTY capability set (CapAdd granted // nothing). A backend that started honoring either would widen the divergence. -func rowCommandCapAddIgnored(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowCommandCapAddIgnored(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() spec := caps.makeSpec(t, "contract-cmd-capadd") spec.Command = []string{"/nonexistent-entrypoint-must-be-ignored"} @@ -372,7 +372,7 @@ func rowCommandCapAddIgnored(t *testing.T, rt ContainerRuntime, caps backendCaps // refused with the backend's typed collision error keyed on spec.Name. The // second Create of a live name must fail, and with the expected type (gated via // caps). -func rowDuplicateName(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowDuplicateName(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() const name = "contract-dup" id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) @@ -390,7 +390,7 @@ func rowDuplicateName(t *testing.T, rt ContainerRuntime, caps backendCaps) { // rowStopRemoveIdempotence — row 8 (record 577): a double Stop is not an error, a // Remove of an already-removed id is nil, and a Remove of a never-created id is // nil. -func rowStopRemoveIdempotence(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowStopRemoveIdempotence(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() id := startRunning(t, rt, caps, "contract-idem") if err := rt.Stop(t.Context(), id, 5*time.Second); err != nil { @@ -405,14 +405,14 @@ func rowStopRemoveIdempotence(t *testing.T, rt ContainerRuntime, caps backendCap if err := rt.Remove(t.Context(), id); err != nil { t.Fatalf("Remove of an already-removed id must be nil: %v", err) } - if err := rt.Remove(t.Context(), ContainerID("contract-never-created")); err != nil { + if err := rt.Remove(t.Context(), WorkloadID("contract-never-created")); err != nil { t.Fatalf("Remove of a never-created id must be nil: %v", err) } } // rowExistsBeforeAfterRemove — row 10 (record 587-590 lineage): Exists is true // after Create, false after Remove, keyed on spec.Name. -func rowExistsBeforeAfterRemove(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowExistsBeforeAfterRemove(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() const name = "contract-exists" id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) @@ -444,7 +444,7 @@ func rowExistsBeforeAfterRemove(t *testing.T, rt ContainerRuntime, caps backendC // escalation, proving the graceful preamble is not dead weight that always burns // the full timeout. Observable through the interface as Stop completing far under // its grace. -func rowStopGrace(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowStopGrace(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() id := startRunning(t, rt, caps, "contract-stopgrace") const grace = 30 * time.Second @@ -462,7 +462,7 @@ func rowStopGrace(t *testing.T, rt ContainerRuntime, caps backendCaps) { // startRunning Creates + Starts a container from caps.makeSpec and registers a // Remove backstop, the shared happy-path setup for the exec/stream rows. A Create // or Start failure is fatal (the row cannot run). -func startRunning(t *testing.T, rt ContainerRuntime, caps backendCaps, name string) ContainerID { +func startRunning(t *testing.T, rt WorkloadRuntime, caps backendCaps, name string) WorkloadID { t.Helper() id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) if err != nil { @@ -480,7 +480,7 @@ func startRunning(t *testing.T, rt ContainerRuntime, caps backendCaps, name stri // the test's own ctx is cancelled (t.Context() is cancelled before cleanups run) // — a leaked container would collide with the next run's name (the existing e2e // pattern, brief §Go house rules). -func registerRemove(t *testing.T, rt ContainerRuntime, id ContainerID, label string) { +func registerRemove(t *testing.T, rt WorkloadRuntime, id WorkloadID, label string) { t.Helper() t.Cleanup(func() { if err := rt.Remove(context.WithoutCancel(t.Context()), id); err != nil { diff --git a/go/internal/runtime/egress_inguest_microvm_test.go b/go/internal/runtime/egress_inguest_microvm_test.go index 558aecf5..77e972ca 100644 --- a/go/internal/runtime/egress_inguest_microvm_test.go +++ b/go/internal/runtime/egress_inguest_microvm_test.go @@ -78,13 +78,13 @@ const ( // execs. It registers teardown. The session is armed in-guest by guestd during // Start (the Provision RPC carries the recorded nft_script), so by the time this // returns the firewall is live. -func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroVMRuntime, ContainerID) { +func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroVMRuntime, WorkloadID) { t.Helper() env := microvmtest.Require(t) m := NewMicroVMRuntime(e2eConfig(t, env)) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Egress: egress, @@ -119,7 +119,7 @@ func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroV // unreachable; an allowed host completes (exit 0, "connected"). A non-zero exit // with no "connected" is unreachable; any transport/exec error fails the test // (that is a harness fault, not a firewall verdict). -func canReachIPv4(t *testing.T, m *MicroVMRuntime, id ContainerID, ip string) bool { +func canReachIPv4(t *testing.T, m *MicroVMRuntime, id WorkloadID, ip string) bool { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), egressProbeTimeout) defer cancel() @@ -190,7 +190,7 @@ func TestInGuestEgressAgentCannotAlterRuleset(t *testing.T) { // TestInGuestEgressAlwaysArmedDefaultDeny is W3(4) / the §(e)/OQ-3 always-arm // verification: a session created with the ZERO-VALUE egress policy still boots // armed default-deny, so external egress is blocked even though no allowlist was -// set. This is the load-bearing always-arm claim — every ContainerSpec-created +// set. This is the load-bearing always-arm claim — every WorkloadSpec-created // microVM session is firewalled at Start whether or not a caller set Egress — // proven live. A regression that skipped the arm on an empty policy (a silent // open-egress VM) fails here: the deniedIP would become reachable. diff --git a/go/internal/runtime/egress_integrity_podman_test.go b/go/internal/runtime/egress_integrity_podman_test.go index 6877c35f..3f6aa696 100644 --- a/go/internal/runtime/egress_integrity_podman_test.go +++ b/go/internal/runtime/egress_integrity_podman_test.go @@ -37,7 +37,7 @@ import ( // ExecSpec identity and returns the hex effective-capability mask (the token // after "CapEff:"). It drives the real PodmanCLI.Exec so the --user plumbing // under test is the one exercised in production. -func capEffOf(t *testing.T, ctx context.Context, cli *PodmanCLI, id ContainerID, spec ExecSpec) string { +func capEffOf(t *testing.T, ctx context.Context, cli *PodmanCLI, id WorkloadID, spec ExecSpec) string { t.Helper() out, err := cli.Exec(ctx, id, spec) if err != nil { @@ -73,7 +73,7 @@ func TestAgentExecDropsNetAdminInNetAdminContainer(t *testing.T) { // A NET_ADMIN container remapped to the baked agent uid, mirroring a real // agent container's create (agent.go createAndStart). const agentUID uint32 = agentuid.AgentUID - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-egress-integrity-" + strconv.Itoa(os.Getpid()), UID: agentUID, diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index 9a5735aa..9bd42022 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -1,6 +1,6 @@ package runtime -// microvm.go is the microVM ContainerRuntime backend seam: the operator config, +// microvm.go is the microVM WorkloadRuntime backend seam: the operator config, // the MicroVMRuntime type + its per-session state table, and the config-driven // backend selection the Runner startup uses to choose between the microVM and // podman backends. The lifecycle method bodies — which boot a VMM, wire the @@ -87,20 +87,20 @@ type BackendConfig struct { MicroVM MicroVMConfig } -// MicroVMRuntime is a ContainerRuntime that isolates each agent in its own +// MicroVMRuntime is a WorkloadRuntime that isolates each agent in its own // microVM instead of a rootless container. It holds the operator wiring plus a -// per-session state table (keyed by the ContainerID Create mints), guarded by +// per-session state table (keyed by the WorkloadID Create mints), guarded by // mu against concurrent lifecycle calls. Its method bodies live in // microvm_lifecycle.go (//go:build unix); the microvmSession type they operate // on is declared there too. type MicroVMRuntime struct { config MicroVMConfig mu sync.Mutex - // sessions maps each live ContainerID to its session state. Every read and + // sessions maps each live WorkloadID to its session state. Every read and // write is guarded by mu. Name lookups (Exists, duplicate-name refusal) scan // this map for a matching spec.Name — a scan is cheap at one-VM-per-session // scale and keeps a single source of truth. - sessions map[ContainerID]*microvmSession + sessions map[WorkloadID]*microvmSession // launchFunc boots a session guest behind the guestVM seam; newGuestClient // dials the guest control plane. Both default to the real microvm // implementations (installSeamDefaults, //go:build unix) and are overridden @@ -115,7 +115,7 @@ type MicroVMRuntime struct { func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { m := &MicroVMRuntime{ config: cfg, - sessions: make(map[ContainerID]*microvmSession), + sessions: make(map[WorkloadID]*microvmSession), } m.installSeamDefaults() return m @@ -133,7 +133,7 @@ func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { // collapses to microVM guarded by a VerifyMicroVMSupport hard gate at startup — // a legible refusal when the host cannot run microVMs, with no fallback to the // container path. -func SelectBackend(cfg BackendConfig) (ContainerRuntime, error) { +func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error) { switch strings.TrimSpace(cfg.Backend) { case "", "podman": return NewPodmanCLI(), nil diff --git a/go/internal/runtime/microvm_isolation_microvm_test.go b/go/internal/runtime/microvm_isolation_microvm_test.go index d265e644..c4807815 100644 --- a/go/internal/runtime/microvm_isolation_microvm_test.go +++ b/go/internal/runtime/microvm_isolation_microvm_test.go @@ -82,14 +82,14 @@ const ( // failed assertion still tears the VM down. The volume is a t.TempDir() child so // it is removed with the test; the SHORT runroot comes from e2eConfig (the // AF_UNIX sun_path budget, microvm_lifecycle_microvm_test.go). -func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMRuntime, ContainerID, string) { +func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMRuntime, WorkloadID, string) { t.Helper() m := NewMicroVMRuntime(e2eConfig(t, env)) volume := filepath.Join(t.TempDir(), "volume") if err := os.MkdirAll(volume, 0o700); err != nil { t.Fatalf("creating session volume %s: %v", volume, err) } - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: volume, ContainerPath: workspaceMountPath}}, @@ -112,7 +112,7 @@ func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMR // combined stdout+stderr and exit code. A transport/refusal error is fatal; a // NON-ZERO EXIT IS NOT — a denied escape attempt is expected to exit non-zero, // and that is the outcome under test (mirrors rowExecExitCodes' posture). -func guestSh(t *testing.T, m *MicroVMRuntime, id ContainerID, script string) (string, int) { +func guestSh(t *testing.T, m *MicroVMRuntime, id WorkloadID, script string) (string, int) { t.Helper() out, err := m.Exec(t.Context(), id, NewExecSpec("sh", "-s").WithStdin(script).AsUser(strconv.Itoa(int(agentuid.AgentUID)))) @@ -680,7 +680,7 @@ func TestMicroVMVolumeQuotaEnforcedInGuest(t *testing.T) { } m := NewMicroVMRuntime(e2eConfig(t, env)) - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "iso-quota", UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: volume, ContainerPath: workspaceMountPath}}, diff --git a/go/internal/runtime/microvm_lifecycle.go b/go/internal/runtime/microvm_lifecycle.go index f2a7e276..e5df4328 100644 --- a/go/internal/runtime/microvm_lifecycle.go +++ b/go/internal/runtime/microvm_lifecycle.go @@ -3,7 +3,7 @@ package runtime // microvm_lifecycle.go fills the eight MicroVMRuntime lifecycle verbs behind the -// frozen ContainerRuntime signatures (microvm.go holds the type + config + +// frozen WorkloadRuntime signatures (microvm.go holds the type + config + // SelectBackend). It is //go:build unix because the microvm package it drives // (Launch/GuestExec/VM/GuestClient, all //go:build unix) is unix-only; keeping // the bodies here lets the untagged runtime package still type-check backend @@ -143,8 +143,8 @@ func (m *MicroVMRuntime) installSeamDefaults() { // Start, and dropped by Remove. All fields are read/written under // MicroVMRuntime.mu. type microvmSession struct { - // id is the ContainerID Create minted (also the runtime-dir leaf name). - id ContainerID + // id is the WorkloadID Create minted (also the runtime-dir leaf name). + id WorkloadID // name is spec.Name — the Runner's stable handle, answered by Exists and // used to refuse a duplicate-name Create (matching podman's engine). name string @@ -159,7 +159,7 @@ type microvmSession struct { nonce []byte // nftScript is the egress ruleset delivered to guestd on Start's Provision // RPC (as ProvisionRequest.nft_script). Recorded at Create from - // spec.Egress.NftScript(); NEVER empty for a ContainerSpec-created session, + // spec.Egress.NftScript(); NEVER empty for a WorkloadSpec-created session, // since the zero-value EgressPolicy still emits the full default-deny base // ruleset (design §(e), egress.go). guestd arms it as guest root before the // exec gate opens. @@ -207,7 +207,7 @@ func (e *UnsupportedMountError) Error() string { // keep-alive is the VMM + guestd PID 1, not a sleep-loop entrypoint, and // CAP_NET_ADMIN is never granted to the workload boundary (record §(c)). No VM // is booted here; Start does that. -func (m *MicroVMRuntime) Create(_ context.Context, spec ContainerSpec) (ContainerID, error) { +func (m *MicroVMRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, error) { shared, err := workspaceShare(spec.Mounts) if err != nil { return "", err @@ -235,7 +235,7 @@ func (m *MicroVMRuntime) Create(_ context.Context, spec ContainerSpec) (Containe env: spec.Env, nonce: nonce, // Never empty: the zero-value EgressPolicy still emits the default-deny - // base ruleset, so every ContainerSpec-created session boots armed (§(e)). + // base ruleset, so every WorkloadSpec-created session boots armed (§(e)). nftScript: spec.Egress.NftScript(), runtimeDir: runtimeDir, } @@ -333,15 +333,15 @@ func workspaceShare(mounts []Mount) (Mount, error) { } } -// mintSessionID mints a random 16-byte hex session id used as the ContainerID +// mintSessionID mints a random 16-byte hex session id used as the WorkloadID // and the runtime-dir leaf. There is no engine to print an id, so the backend // generates one; hex keeps it filesystem-safe. -func mintSessionID() (ContainerID, error) { +func mintSessionID() (WorkloadID, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("microvm: minting session id: %w", err) } - return ContainerID(hex.EncodeToString(b[:])), nil + return WorkloadID(hex.EncodeToString(b[:])), nil } // mintNonce mints a random 16-byte boot nonce (raw bytes; the cmdline carries @@ -362,7 +362,7 @@ func mintNonce() ([]byte, error) { // before returning — on this backend the boot IS Start, so Start cleans its own // partial boot and Remove stays idempotent (record §(c)). On success the VM // handle + GuestExec are stored on the session under the lock. -func (m *MicroVMRuntime) Start(ctx context.Context, id ContainerID) error { +func (m *MicroVMRuntime) Start(ctx context.Context, id WorkloadID) error { session, err := m.session(id) if err != nil { return err @@ -424,7 +424,7 @@ func (m *MicroVMRuntime) Start(ctx context.Context, id ContainerID) error { // before the exec gate opens (§(b)/(c)). AgentRuntime.provision probes for this // marker (the unexported inGuestEgressArmer, agent.go) and skips its host-side // armEgress exec — which on this backend would run capability-less and fail. -// Deliberately NOT a verb on the frozen ContainerRuntime interface (podman.go). +// Deliberately NOT a verb on the frozen WorkloadRuntime interface (podman.go). func (m *MicroVMRuntime) EgressArmedInGuest() bool { return true } // awaitHealthy polls the guest's Health until it reports net_provisioned && @@ -477,7 +477,7 @@ func bootPollContext(ctx context.Context) (context.Context, context.CancelFunc) // refusal or transport failure is an error, and a host-side timeout is mapped // to a *runtime.TimeoutError so requireSuccess/atStage callers behave // identically to the podman path (record §(c)). -func (m *MicroVMRuntime) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) { +func (m *MicroVMRuntime) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { guestExec, err := m.startedExec(id) if err != nil { return ExecOutput{}, err @@ -567,7 +567,7 @@ func exitError(st microvm.ExitStatus) error { // blocking teardown), and waitFunc maps the guest exit onto nil / a // *runtime.ExitStatusError so the runner's isDeliberateKill recognizes a // signalled exit as a deliberate kill (OQ-G/U3b, record §(c)). -func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { guestExec, err := m.startedExec(id) if err != nil { return nil, err @@ -607,7 +607,7 @@ func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id ContainerID, spec // real VMM exit up to timeout. Past the timeout it kills the VMM outright via // vm.Shutdown (which also reaps the daemons and removes the sockets). A session // that never started (no VM handle) is a no-op success (record §(d)). -func (m *MicroVMRuntime) Stop(ctx context.Context, id ContainerID, timeout time.Duration) error { +func (m *MicroVMRuntime) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { session, err := m.session(id) if err != nil { return err @@ -657,7 +657,7 @@ func stopGuest(ctx context.Context, client compassv1internalconnect.GuestControl // the session-table entry. It is idempotent: a Remove of an unknown or // already-removed id is not an error (matching `podman rm --force`), and a // session that never started is torn down to just its dir + entry (record §(d)). -func (m *MicroVMRuntime) Remove(ctx context.Context, id ContainerID) error { +func (m *MicroVMRuntime) Remove(ctx context.Context, id WorkloadID) error { m.mu.Lock() session, ok := m.sessions[id] if !ok { @@ -699,7 +699,7 @@ func (m *MicroVMRuntime) Exists(_ context.Context, name string) (bool, error) { // the session's own vsock socket base and the fixed gateway port (record // §(b)/§(c)/§(e)). An unknown name returns ("", false). It keys on spec.Name // like Exists, so the Runner's stable handle resolves. Deliberately NOT a verb -// on the frozen ContainerRuntime interface: agentHost probes for it via an +// on the frozen WorkloadRuntime interface: agentHost probes for it via an // unexported single-method assertion, so the podman backend (which lacks it) is // unaffected (record §(c), Global Constraints). func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool) { @@ -718,7 +718,7 @@ func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool) { // relabeled bind mount), and the config materializer treats an empty label as // skip-chcon (the parent's Q-mountlabel deferral, record §(c)). An unknown id // is not distinguished — the empty answer is correct for it too. -func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { +func (m *MicroVMRuntime) MountLabel(_ context.Context, _ WorkloadID) (string, error) { return "", nil } @@ -726,13 +726,13 @@ func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, e // sentinel until C3 fills in resize-in-place behind the S1-frozen seam (the // C3/D5 deferral, record §(c)). It is not a microVM-specific unimplemented // verb, so it shares the podman backend's sentinel. -func (m *MicroVMRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (m *MicroVMRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return ErrResizeNotImplemented } // session looks up a session by id under the lock, returning a stage-agnostic // error if it is absent. -func (m *MicroVMRuntime) session(id ContainerID) (*microvmSession, error) { +func (m *MicroVMRuntime) session(id WorkloadID) (*microvmSession, error) { m.mu.Lock() defer m.mu.Unlock() session, ok := m.sessions[id] @@ -745,7 +745,7 @@ func (m *MicroVMRuntime) session(id ContainerID) (*microvmSession, error) { // startedExec looks up a session's GuestExec client under the lock, erroring if // the session is absent or not yet started (Exec/ExecStreaming both require a // booted, provisioned guest). -func (m *MicroVMRuntime) startedExec(id ContainerID) (*microvm.GuestExec, error) { +func (m *MicroVMRuntime) startedExec(id WorkloadID) (*microvm.GuestExec, error) { m.mu.Lock() defer m.mu.Unlock() session, ok := m.sessions[id] @@ -758,4 +758,4 @@ func (m *MicroVMRuntime) startedExec(id ContainerID) (*microvm.GuestExec, error) return session.guestExec, nil } -var _ ContainerRuntime = (*MicroVMRuntime)(nil) +var _ WorkloadRuntime = (*MicroVMRuntime)(nil) diff --git a/go/internal/runtime/microvm_lifecycle_microvm_test.go b/go/internal/runtime/microvm_lifecycle_microvm_test.go index 16357914..e93783d8 100644 --- a/go/internal/runtime/microvm_lifecycle_microvm_test.go +++ b/go/internal/runtime/microvm_lifecycle_microvm_test.go @@ -82,7 +82,7 @@ func TestMicroVMStartFailureLeavesNoState(t *testing.T) { m := NewMicroVMRuntime(cfg) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "e2e-badboot", UID: 1000, Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, diff --git a/go/internal/runtime/microvm_lifecycle_test.go b/go/internal/runtime/microvm_lifecycle_test.go index 58555a6a..b5a46196 100644 --- a/go/internal/runtime/microvm_lifecycle_test.go +++ b/go/internal/runtime/microvm_lifecycle_test.go @@ -84,7 +84,7 @@ func TestBootConfigAssembly(t *testing.T) { // booting (no VM handle, no exec client), and the returned id resolves. func TestCreateAllocatesWithoutBoot(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -107,10 +107,10 @@ func TestCreateAllocatesWithoutBoot(t *testing.T) { // table is refused with a typed DuplicateNameError naming the collision. func TestCreateRefusesDuplicateName(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}); err != nil { + if _, err := m.Create(context.Background(), WorkloadSpec{Name: "dup", UID: 1000}); err != nil { t.Fatalf("first Create: %v", err) } - _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}) + _, err := m.Create(context.Background(), WorkloadSpec{Name: "dup", UID: 1000}) var dupErr *DuplicateNameError if !errors.As(err, &dupErr) { t.Fatalf("second Create err = %v, want *DuplicateNameError", err) @@ -151,7 +151,7 @@ func TestCreateRefusesInexpressibleMount(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := m.Create(context.Background(), ContainerSpec{Name: tt.name, UID: 1000, Mounts: tt.mounts}) + _, err := m.Create(context.Background(), WorkloadSpec{Name: tt.name, UID: 1000, Mounts: tt.mounts}) var mountErr *UnsupportedMountError if !errors.As(err, &mountErr) { t.Fatalf("Create err = %v, want *UnsupportedMountError", err) @@ -266,7 +266,7 @@ func TestParseUID(t *testing.T) { // session id is NOT a name match. func TestExistsByName(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-x", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-x", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -290,11 +290,11 @@ func TestExistsByName(t *testing.T) { // of a never-started session tears down its dir + table entry without error. func TestRemoveIdempotent(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Remove(context.Background(), ContainerID("never-existed")); err != nil { + if err := m.Remove(context.Background(), WorkloadID("never-existed")); err != nil { t.Fatalf("Remove(unknown) = %v, want nil", err) } - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-r", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-r", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -314,7 +314,7 @@ func TestRemoveIdempotent(t *testing.T) { // id, per the parent's Q-mountlabel deferral. func TestMountLabelEmpty(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - label, err := m.MountLabel(context.Background(), ContainerID("anything")) + label, err := m.MountLabel(context.Background(), WorkloadID("anything")) if err != nil || label != "" { t.Fatalf("MountLabel = (%q, %v), want (\"\", nil)", label, err) } @@ -324,7 +324,7 @@ func TestMountLabelEmpty(t *testing.T) { // sentinel, matching PodmanCLI.Resize (the C3/D5 deferral). func TestResizeNotImplemented(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Resize(context.Background(), ContainerID("c"), ResourceLimits{}); !errors.Is(err, ErrResizeNotImplemented) { + if err := m.Resize(context.Background(), WorkloadID("c"), ResourceLimits{}); !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) } } @@ -333,7 +333,7 @@ func TestResizeNotImplemented(t *testing.T) { // booting. func TestStartUnknownSession(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Start(context.Background(), ContainerID("ghost")); err == nil { + if err := m.Start(context.Background(), WorkloadID("ghost")); err == nil { t.Fatal("Start(unknown) err = nil, want a no-session error") } } @@ -342,7 +342,7 @@ func TestStartUnknownSession(t *testing.T) { // (no exec client yet) rather than panicking. func TestExecUnstartedSession(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-e", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-e", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -390,7 +390,7 @@ func TestExitErrorMapping(t *testing.T) { // agentHost's vsock leg relies on (record §(b)/§(c)/§(e)). func TestAgentGatewayEndpoint(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -460,7 +460,7 @@ func TestCreateRejectsOverLongGatewaySocketPath(t *testing.T) { t.Fatalf("creating runroot: %v", err) } m := NewMicroVMRuntime(MicroVMConfig{RunRoot: runRoot}) - _, createErr := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + _, createErr := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if tt.wantReject { if createErr == nil { t.Fatal("Create succeeded; want the pre-boot budget error") diff --git a/go/internal/runtime/microvm_preflight.go b/go/internal/runtime/microvm_preflight.go index ba9c7455..eb4b29e2 100644 --- a/go/internal/runtime/microvm_preflight.go +++ b/go/internal/runtime/microvm_preflight.go @@ -410,7 +410,7 @@ func (m *MicroVMRuntime) BootCanary(ctx context.Context) (report CanaryReport, e } }() - id, err := m.Create(ctx, ContainerSpec{ + id, err := m.Create(ctx, WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: workspace, ContainerPath: workspaceMountPath}}, diff --git a/go/internal/runtime/microvm_start_test.go b/go/internal/runtime/microvm_start_test.go index fbb596d0..7cb5dc4e 100644 --- a/go/internal/runtime/microvm_start_test.go +++ b/go/internal/runtime/microvm_start_test.go @@ -120,7 +120,7 @@ var _ compassv1internalconnect.GuestControlClient = (*fakeGuestClient)(nil) // and creates one session, returning the runtime, the created id, and the fakes. // The Create records the zero-value default-deny script (unless spec overrides), // which Start must then deliver verbatim. -func seamStart(t *testing.T, spec ContainerSpec, provErr error) (*MicroVMRuntime, ContainerID, *fakeGuestVM, *fakeGuestClient) { +func seamStart(t *testing.T, spec WorkloadSpec, provErr error) (*MicroVMRuntime, WorkloadID, *fakeGuestVM, *fakeGuestClient) { t.Helper() m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) id, err := m.Create(t.Context(), spec) @@ -161,10 +161,10 @@ func shortRunRoot(t *testing.T) string { // TestCreateRecordsDefaultDenyScript pins §(e): Create records the zero-value // EgressPolicy's full default-deny base ruleset on the session (never empty), so -// every ContainerSpec-created session boots armed even with no allowlist set. +// every WorkloadSpec-created session boots armed even with no allowlist set. func TestCreateRecordsDefaultDenyScript(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) - id, err := m.Create(t.Context(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(t.Context(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -184,7 +184,7 @@ func TestCreateRecordsDefaultDenyScript(t *testing.T) { // Start's Provision RPC carries the exact script Create recorded — the host→guest // egress delivery contract (§(a)), hermetic with no real VMM or vsock dial. func TestStartDeliversScriptVerbatim(t *testing.T) { - spec := ContainerSpec{Name: "agent-1", UID: 1000, Egress: MustAllowEgress("github.com")} + spec := WorkloadSpec{Name: "agent-1", UID: 1000, Egress: MustAllowEgress("github.com")} m, id, vm, client := seamStart(t, spec, nil) want := spec.Egress.NftScript() @@ -220,7 +220,7 @@ func TestStartDeliversScriptVerbatim(t *testing.T) { // booted by launchFunc must not be left running when the arm/provision fails. func TestStartProvisionErrorFailsAndTearsDown(t *testing.T) { provErr := connect.NewError(connect.CodeInternal, errors.New("arm failed")) - spec := ContainerSpec{Name: "agent-1", UID: 1000} + spec := WorkloadSpec{Name: "agent-1", UID: 1000} m, id, vm, _ := seamStart(t, spec, provErr) err := m.Start(t.Context(), id) @@ -247,7 +247,7 @@ func TestStartProvisionErrorFailsAndTearsDown(t *testing.T) { // so this guards only against a broken test seam, but it must fail loud. func TestStartNilGuestHandleFailsClosed(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) - id, err := m.Create(t.Context(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(t.Context(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/go/internal/runtime/podman.go b/go/internal/runtime/podman.go index fc1fcffa..388c30b2 100644 --- a/go/internal/runtime/podman.go +++ b/go/internal/runtime/podman.go @@ -8,7 +8,7 @@ // container, so neither the image build nor the clone is the Runner's job. // // The layering, bottom to top: -// - podman.go — a thin ContainerRuntime over the podman CLI: the only place a +// - podman.go — a thin WorkloadRuntime over the podman CLI: the only place a // subprocess is spawned. Everything above depends on the interface, so a // libpod-REST backend can replace it without touching a caller. // - egress.go — the default-deny + allowlist firewall applied inside the @@ -19,7 +19,7 @@ // - registry.go — AgentRegistry, the Runner's live-container handle cache the // session RPCs resolve a launched container by name through. // -// This file is the container-runtime seam: a ContainerRuntime interface plus +// This file is the container-runtime seam: a WorkloadRuntime interface plus // PodmanCLI, its rootless-podman-CLI implementation. Rootless is a hard // requirement (design: architecture-lineage): no daemon, no root, no rootful fallback. // Containers run with --userns=keep-id:uid=,gid= so the @@ -50,12 +50,13 @@ import ( "time" ) -// ContainerID is a running (or created) container, identified by the full id -// podman prints. -type ContainerID string +// WorkloadID identifies a running (or created) workload — a container, a +// microVM guest, or a host process group — as the backend that made it names +// it. The podman backend uses the full id podman prints. +type WorkloadID string -// String returns the raw container id. -func (c ContainerID) String() string { return string(c) } +// String returns the raw workload id. +func (c WorkloadID) String() string { return string(c) } // Mount is a host→container bind mount. ReadOnly maps to :ro and every mount // gets SELinux relabelling (:Z) so the substrate works on enforcing hosts. @@ -82,10 +83,10 @@ type ResourceLimits struct { MemoryBytes int64 } -// ContainerSpec is everything needed to create one agent container. Kept -// engine-agnostic: the podman-specific argv is assembled in PodmanCLI.Create, +// WorkloadSpec is everything needed to create one agent workload. Kept +// backend-agnostic: the podman-specific argv is assembled in PodmanCLI.Create, // not here. -type ContainerSpec struct { +type WorkloadSpec struct { // Image reference in local container storage (e.g. compass-agent:latest — // one shared base image, not a per-repo tag). Image string @@ -340,67 +341,71 @@ func (e *TimeoutError) Error() string { return fmt.Sprintf("%q timed out after %ds", e.Summary, int(e.Timeout.Seconds())) } -// ContainerRuntime is the container engine seam. Every operation is a subprocess -// (or, later, a socket round-trip), so each threads the caller's context for -// cancellation and carries the per-command timeout the implementation applies. -// An interface so the Runner can hold a ContainerRuntime and tests can -// substitute a fake. -type ContainerRuntime interface { - // Create makes a container from spec without starting it, returning its id. - Create(ctx context.Context, spec ContainerSpec) (ContainerID, error) +// WorkloadRuntime is the execution-backend seam: the interface every runtime +// backend implements, whatever it actually runs — a podman container, a microVM +// guest, or a direct host process. Every operation is a subprocess (or, later, a +// socket round-trip), so each threads the caller's context for cancellation and +// carries the per-command timeout the implementation applies. An interface so +// the Runner can hold a WorkloadRuntime and tests can substitute a fake. +type WorkloadRuntime interface { + // Create makes a workload from spec without starting it, returning its id. + Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error) - // Start starts a created container. - Start(ctx context.Context, id ContainerID) error + // Start starts a created workload. + Start(ctx context.Context, id WorkloadID) error - // Exec runs a command in a running container, capturing its output. A + // Exec runs a command in a running workload, capturing its output. A // non-zero exit is a successful runtime call returning a failed command // (ExecOutput.ExitCode), not an error; only a spawn failure or timeout is an // error. - Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) + Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) // ExecStreaming starts a long-lived streaming command in a running - // container, returning its live stdio pipes plus a kill/wait handle rather + // workload, returning its live stdio pipes plus a kill/wait handle rather // than awaiting completion. The transport for the long-running agent // process: its stdout/stderr stay open and are drained as diagnostics for - // the process's life (the agent's protocol rides the per-container socket). + // the process's life (the agent's protocol rides the per-workload socket). // Unlike Exec there is no wall-clock timeout — the process is meant to run // indefinitely — but the exec is still bound to ctx, so cancelling it // terminates the process. - ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) + ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) - // Stop stops a running container, allowing timeout for graceful exit before - // podman kills it. - Stop(ctx context.Context, id ContainerID, timeout time.Duration) error + // Stop stops a running workload, allowing timeout for graceful exit before + // the backend kills it. + Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error - // Remove removes a container (force-kills if still running). - Remove(ctx context.Context, id ContainerID) error + // Remove removes a workload (force-kills if still running). + Remove(ctx context.Context, id WorkloadID) error - // Exists reports whether a container with name currently exists (any state). + // Exists reports whether a workload with name currently exists (any state). Exists(ctx context.Context, name string) (bool, error) - // MountLabel reports the container's SELinux mount label (its private MCS - // category), read from `podman inspect`. The config-update path relabels a - // freshly materialized version dir into this category so a confined agent - // can read it (agentHost.RefreshConfig -> ConfigMaterializer relabel). - MountLabel(ctx context.Context, id ContainerID) (string, error) - - // Resize changes a live container's cgroup resource limits in place (a - // `podman update`-class operation) — the resize-in-place elastic-compute - // path (C3). The verb is frozen into the interface at S1 (additively + // MountLabel reports the workload's SELinux mount label (its private MCS + // category); the podman backend reads it from `podman inspect`. The + // config-update path relabels a freshly materialized version dir into this + // category so a confined agent can read it (agentHost.RefreshConfig -> + // ConfigMaterializer relabel). A backend with no SELinux confinement + // reports an empty label. + MountLabel(ctx context.Context, id WorkloadID) (string, error) + + // Resize changes a live workload's cgroup resource limits in place (a + // `podman update`-class operation on the container backend) — the + // resize-in-place elastic-compute path (C3). The verb is frozen into the + // interface at S1 (additively // reserved, the same discipline as ExecStreaming) so every backend and fake // carries the full surface from the start and no interface change lands // after S1; the resize BEHAVIOR — actually applying and later restoring the // limits around a heavy op — is C3's to fill in behind this signature. // PodmanCLI.Resize therefore returns ErrResizeNotImplemented until C3, and // no caller invokes it yet, so the existing session path is unchanged. - Resize(ctx context.Context, id ContainerID, limits ResourceLimits) error + Resize(ctx context.Context, id WorkloadID, limits ResourceLimits) error } -// ContainerRuntime is frozen (the Resize reservation above): a backend that +// WorkloadRuntime is frozen (the Resize reservation above): a backend that // self-arms egress does NOT grow a verb here. Instead MicroVMRuntime carries an // off-interface marker method, EgressArmedInGuest(), and AgentRuntime.provision // type-asserts the unexported inGuestEgressArmer (agent.go) to skip armEgress on -// such a backend (design §(c)). A future backend — or any ContainerRuntime +// such a backend (design §(c)). A future backend — or any WorkloadRuntime // decorator, which would otherwise swallow the marker and silently re-enable // armEgress on the microVM backend — must re-expose EgressArmedInGuest to keep // the probe working. @@ -418,7 +423,7 @@ const ( argFormat = "--format" ) -// PodmanCLI is a ContainerRuntime over the podman CLI. +// PodmanCLI is a WorkloadRuntime over the podman CLI. type PodmanCLI struct { program string timeout time.Duration @@ -444,18 +449,18 @@ func (p *PodmanCLI) WithTimeout(timeout time.Duration) *PodmanCLI { } // Create assembles and runs `podman create`, returning the new container id. -func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID, error) { +func (p *PodmanCLI) Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error) { stdout, err := p.run(ctx, "podman create", createArgs(spec)) if err != nil { return "", err } - return ContainerID(strings.TrimSpace(string(stdout))), nil + return WorkloadID(strings.TrimSpace(string(stdout))), nil } // createArgs assembles the argv for `podman create`. Split out so the argv // assembly is unit-testable without spawning podman, mirroring // execStreamingArgs. -func createArgs(spec ContainerSpec) []string { +func createArgs(spec WorkloadSpec) []string { // Preallocate: 4 fixed tokens (create, --name+value, --userns) + 2 per // cap/mount/env pair + image + command tokens, so the appends below don't // reallocate. @@ -541,7 +546,7 @@ func parsePodmanVersion(s string) (major, minor int, err error) { } // Start starts a created container. -func (p *PodmanCLI) Start(ctx context.Context, id ContainerID) error { +func (p *PodmanCLI) Start(ctx context.Context, id WorkloadID) error { _, err := p.run(ctx, "podman start", []string{"start", id.String()}) return err } @@ -549,7 +554,7 @@ func (p *PodmanCLI) Start(ctx context.Context, id ContainerID) error { // Exec runs a command in a running container, capturing its output. A non-zero // exit is captured in ExecOutput, not folded into an error (a denied firewall // probe is an expected non-zero); a spawn failure or timeout is an error. -func (p *PodmanCLI) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) { +func (p *PodmanCLI) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { args := []string{argExec} // Forward stdin only when there's input to feed, so `sh -s` reads the script // from the pipe rather than the argv. @@ -584,7 +589,7 @@ func (p *PodmanCLI) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (Ex // Cancel SIGKILLs the process and WaitDelay bounds the reap, so cancelling the // parent context or calling ChildHandle.Kill terminates the in-container agent // even without a Go Drop. -func (p *PodmanCLI) ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (p *PodmanCLI) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { execCtx, cancel := context.WithCancel(ctx) //nolint:gosec // G204: the container-engine seam — see spawnCapture. The // engine binary is operator-set and the exec argv is Runner-assembled. @@ -632,7 +637,7 @@ func stopGraceSeconds(timeout time.Duration) int64 { } // Stop stops a running container, allowing timeout for graceful exit. -func (p *PodmanCLI) Stop(ctx context.Context, id ContainerID, timeout time.Duration) error { +func (p *PodmanCLI) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { // podman's --time is whole seconds; the interface takes a Duration for idiom // and callsite clarity, converted at this CLI boundary. _, err := p.run(ctx, "podman stop", []string{ @@ -644,7 +649,7 @@ func (p *PodmanCLI) Stop(ctx context.Context, id ContainerID, timeout time.Durat } // Remove removes a container (force-kills if still running). -func (p *PodmanCLI) Remove(ctx context.Context, id ContainerID) error { +func (p *PodmanCLI) Remove(ctx context.Context, id WorkloadID) error { _, err := p.run(ctx, "podman rm", removeArgs(id)) return err } @@ -655,7 +660,7 @@ func (p *PodmanCLI) Remove(ctx context.Context, id ContainerID) error { // exhaust podman's num_locks and wedge the host. Harmless when the container // has none. Sister argv in internal/pgtest (removeContainerArgs); the two are // deliberately independent (no prod->test-harness dependency) — keep in sync. -func removeArgs(id ContainerID) []string { +func removeArgs(id WorkloadID) []string { return []string{"rm", "--force", "--volumes", id.String()} } @@ -665,14 +670,14 @@ func removeArgs(id ContainerID) []string { // lands no interface change); the podman `container update` wiring is C3's, so // calling it today is a programming error the sentinel names explicitly rather // than a silent no-op that would fake a limit change that never happened. -var ErrResizeNotImplemented = errors.New("runtime: ContainerRuntime.Resize is reserved at S1 and implemented in C3") +var ErrResizeNotImplemented = errors.New("runtime: WorkloadRuntime.Resize is reserved at S1 and implemented in C3") // Resize is the S1-frozen resize-in-place verb, unimplemented until C3. It // returns ErrResizeNotImplemented rather than silently succeeding: a no-op that // reported success would let a future caller believe a container was resized // when its cgroup limits never moved. C3 replaces this body with the real // `podman update`-class limit change. -func (p *PodmanCLI) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (p *PodmanCLI) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return ErrResizeNotImplemented } @@ -732,7 +737,7 @@ func (p *PodmanCLI) ImageExists(ctx context.Context, image string) (bool, error) // MountLabel reads the container's SELinux mount label via `podman inspect`, // trimming the trailing newline the CLI prints. A one-shot fire-and-check like // Start/Remove: a non-zero exit becomes a CommandError through run. -func (p *PodmanCLI) MountLabel(ctx context.Context, id ContainerID) (string, error) { +func (p *PodmanCLI) MountLabel(ctx context.Context, id WorkloadID) (string, error) { out, err := p.run(ctx, "podman inspect", inspectMountLabelArgs(id)) if err != nil { return "", err @@ -815,7 +820,7 @@ func (p *PodmanCLI) run(ctx context.Context, summary string, args []string) ([]b // --interactive keeps stdin open for the process's life; there is deliberately // no --tty (the agent is a headless process draining diagnostic pipes, not a // terminal session). -func execStreamingArgs(id ContainerID, spec StreamingExecSpec) []string { +func execStreamingArgs(id WorkloadID, spec StreamingExecSpec) []string { args := []string{argExec, argInteractive} if spec.User != nil { args = append(args, "--user", *spec.User) @@ -834,7 +839,7 @@ func execStreamingArgs(id ContainerID, spec StreamingExecSpec) []string { // inspectMountLabelArgs assembles the argv for reading a container's SELinux // mount label. Split out so the argv assembly is unit-testable without spawning // podman, mirroring execStreamingArgs. -func inspectMountLabelArgs(id ContainerID) []string { +func inspectMountLabelArgs(id WorkloadID) []string { return []string{"inspect", argFormat, "{{.MountLabel}}", id.String()} } diff --git a/go/internal/runtime/podman_test.go b/go/internal/runtime/podman_test.go index 97f8fa20..94034df4 100644 --- a/go/internal/runtime/podman_test.go +++ b/go/internal/runtime/podman_test.go @@ -76,7 +76,7 @@ func TestExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { spec.Env["COMPASS_WORKDIR"] = "/work" spec.Env["COMPASS_MODEL"] = "test-model" - args := execStreamingArgs(ContainerID("ctr123"), spec) + args := execStreamingArgs(WorkloadID("ctr123"), spec) want := []string{ "exec", "--interactive", @@ -97,7 +97,7 @@ func TestExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { // --userns=keep-id token silently reintroduces the arbitrary-host-uid defect // (the agent ends up as the host uid, not 1000, and cannot own /nix). func TestCreateArgsRemapsUserns(t *testing.T) { - args := createArgs(ContainerSpec{Name: "c", Image: "img", UID: 1000}) + args := createArgs(WorkloadSpec{Name: "c", Image: "img", UID: 1000}) if !slices.Contains(args, "--userns=keep-id:uid=1000,gid=1000") { t.Fatalf("createArgs = %q, want it to contain %q", args, "--userns=keep-id:uid=1000,gid=1000") } @@ -145,7 +145,7 @@ func TestParsePodmanVersion(t *testing.T) { func TestExecStreamingArgsMinimalOmitsUserAndWorkdir(t *testing.T) { spec := NewStreamingExecSpec("compass-agent") - args := execStreamingArgs(ContainerID("c"), spec) + args := execStreamingArgs(WorkloadID("c"), spec) want := []string{"exec", "--interactive", "c", "compass-agent"} if !slices.Equal(args, want) { @@ -158,7 +158,7 @@ func TestExecStreamingArgsMinimalOmitsUserAndWorkdir(t *testing.T) { // wrong Go template would silently read the wrong field (or the whole inspect // JSON), so the relabel would target the wrong MCS category. func TestInspectMountLabelArgsPinsFormat(t *testing.T) { - args := inspectMountLabelArgs(ContainerID("ctr123")) + args := inspectMountLabelArgs(WorkloadID("ctr123")) want := []string{"inspect", "--format", "{{.MountLabel}}", "ctr123"} if !slices.Equal(args, want) { @@ -172,7 +172,7 @@ func TestInspectMountLabelArgsPinsFormat(t *testing.T) { // wedge the host. A dropped --volumes silently reintroduces that leak on the // production removal path, which has no other guard. func TestRemoveArgsCarriesVolumes(t *testing.T) { - args := removeArgs(ContainerID("ctr123")) + args := removeArgs(WorkloadID("ctr123")) want := []string{"rm", "--force", "--volumes", "ctr123"} if !slices.Equal(args, want) { @@ -180,7 +180,7 @@ func TestRemoveArgsCarriesVolumes(t *testing.T) { } } -// Resize is frozen into the ContainerRuntime seam at S1 but its behavior is +// Resize is frozen into the WorkloadRuntime seam at S1 but its behavior is // C3's: PodmanCLI.Resize must return ErrResizeNotImplemented, never a silent // nil. A no-op success would let a future caller believe a container's cgroup // limits were raised when they never moved — the exact false-positive the @@ -188,7 +188,7 @@ func TestRemoveArgsCarriesVolumes(t *testing.T) { // so C3 (which replaces the body with the real `podman update` change) // deliberately deletes this test rather than silently regressing past it. func TestResizeReservedUntilC3(t *testing.T) { - err := NewPodmanCLI().Resize(context.Background(), ContainerID("ctr123"), ResourceLimits{CPUShares: 512, MemoryBytes: 1 << 30}) + err := NewPodmanCLI().Resize(context.Background(), WorkloadID("ctr123"), ResourceLimits{CPUShares: 512, MemoryBytes: 1 << 30}) if !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) } @@ -201,7 +201,7 @@ func TestExecStreamingArgsCarriesInlineEnvNotEnvFile(t *testing.T) { spec := NewStreamingExecSpec("compass-agent").AsUser("1000").InDir("/work") spec.Env["HOME"] = "/home/agent" - args := execStreamingArgs(ContainerID("ctr123"), spec) + args := execStreamingArgs(WorkloadID("ctr123"), spec) want := []string{ "exec", "--interactive", @@ -265,7 +265,7 @@ func TestSpawnCaptureWaitDelayBoundsLeakedPipeHang(t *testing.T) { // goroutine once the test has already failed on the safety deadline. done := make(chan error, 1) go func() { - _, err := cli.Exec(ctx, ContainerID("c"), NewExecSpec("true")) + _, err := cli.Exec(ctx, WorkloadID("c"), NewExecSpec("true")) done <- err }() @@ -340,7 +340,7 @@ func TestChildHandleTerminateKillsAndReaps(t *testing.T) { cli := NewPodmanCLI().WithProgram(prog) ctx := t.Context() - se, err := cli.ExecStreaming(ctx, ContainerID("c"), NewStreamingExecSpec("compass-agent")) + se, err := cli.ExecStreaming(ctx, WorkloadID("c"), NewStreamingExecSpec("compass-agent")) if err != nil { t.Fatalf("ExecStreaming: %v", err) } diff --git a/go/internal/runtime/secrets_materialize.go b/go/internal/runtime/secrets_materialize.go index 778d8ea2..44e20c0b 100644 --- a/go/internal/runtime/secrets_materialize.go +++ b/go/internal/runtime/secrets_materialize.go @@ -142,13 +142,13 @@ func (e SecretEnv) GoString() string { return e.String() } // RIG-1327 T5, driven from the SecretsVersion dispatch hook (initial materialize // and rotation ride the same signal path). type SecretMaterializer struct { - runtime ContainerRuntime + runtime WorkloadRuntime log *slog.Logger } // NewSecretMaterializer builds a materializer over the container engine. A nil // log falls back to slog.Default. -func NewSecretMaterializer(runtime ContainerRuntime, log *slog.Logger) *SecretMaterializer { +func NewSecretMaterializer(runtime WorkloadRuntime, log *slog.Logger) *SecretMaterializer { if log == nil { log = slog.Default() } @@ -358,7 +358,7 @@ func EnvFileScript(homeDir string, envs []SecretEnv) (string, error) { // install per host, and generic file-delivery secrets write to // $HOME/.compass/secrets/. Each setup script is fed to `sh -s` over stdin // as the agent uid in the agent's $HOME, the git-credential posture. -func (m *SecretMaterializer) Install(ctx context.Context, id ContainerID, homeDir string, uid uint32, resolved []secrets.ResolvedSecret) error { +func (m *SecretMaterializer) Install(ctx context.Context, id WorkloadID, homeDir string, uid uint32, resolved []secrets.ResolvedSecret) error { seed := ProviderSeed{Entries: map[string]ProviderSeedEntry{}} var files []SecretFile var ghCreds []GHCredentials @@ -431,7 +431,7 @@ func (m *SecretMaterializer) Install(ctx context.Context, id ContainerID, homeDi // runScript feeds one setup script to `sh -s` over stdin as the agent uid in the // agent's $HOME — never `sh -c`, never argv (the secret is in the script body, // and argv is visible in the container's process list while stdin is not). -func (m *SecretMaterializer) runScript(ctx context.Context, id ContainerID, homeDir string, uid uint32, stage, script string) error { +func (m *SecretMaterializer) runScript(ctx context.Context, id WorkloadID, homeDir string, uid uint32, stage, script string) error { spec := NewExecSpec("sh", "-s"). AsUser(strconv.FormatUint(uint64(uid), 10)). InDir(homeDir). diff --git a/go/internal/runtime/secrets_materialize_test.go b/go/internal/runtime/secrets_materialize_test.go index 65b9bf21..0d96a0a9 100644 --- a/go/internal/runtime/secrets_materialize_test.go +++ b/go/internal/runtime/secrets_materialize_test.go @@ -29,7 +29,7 @@ import ( "github.com/RigelBuild/compass/go/internal/secrets" ) -// scriptRunner is a ContainerRuntime whose Exec actually runs the setup script +// scriptRunner is a WorkloadRuntime whose Exec actually runs the setup script // through /bin/sh over stdin (as the real container would run `sh -s`), against // the host filesystem — so the files a script writes are real and inspectable. // AsUser is ignored (a test can't setuid); every other effect is genuine. The @@ -39,12 +39,12 @@ type scriptRunner struct { specs []ExecSpec } -func (r *scriptRunner) Create(context.Context, ContainerSpec) (ContainerID, error) { - return ContainerID("fake"), nil +func (r *scriptRunner) Create(context.Context, WorkloadSpec) (WorkloadID, error) { + return WorkloadID("fake"), nil } -func (r *scriptRunner) Start(context.Context, ContainerID) error { return nil } +func (r *scriptRunner) Start(context.Context, WorkloadID) error { return nil } -func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) (ExecOutput, error) { +func (r *scriptRunner) Exec(ctx context.Context, _ WorkloadID, spec ExecSpec) (ExecOutput, error) { r.mu.Lock() r.specs = append(r.specs, spec) r.mu.Unlock() @@ -58,7 +58,7 @@ func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) ( err := cmd.Run() if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { // A non-zero script exit is a successful runtime call returning a failed - // command (the ContainerRuntime contract), never a spawn error. + // command (the WorkloadRuntime contract), never a spawn error. return ExecOutput{Stderr: stderr.String(), ExitCode: exitErr.ExitCode()}, nil } if err != nil { @@ -67,16 +67,16 @@ func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) ( return ExecOutput{}, nil } -func (r *scriptRunner) ExecStreaming(context.Context, ContainerID, StreamingExecSpec) (*StreamingExec, error) { +func (r *scriptRunner) ExecStreaming(context.Context, WorkloadID, StreamingExecSpec) (*StreamingExec, error) { return nil, errors.New("scriptRunner does not support streaming exec") } -func (r *scriptRunner) Stop(context.Context, ContainerID, time.Duration) error { return nil } -func (r *scriptRunner) Remove(context.Context, ContainerID) error { return nil } -func (r *scriptRunner) Exists(context.Context, string) (bool, error) { return false, nil } -func (r *scriptRunner) MountLabel(context.Context, ContainerID) (string, error) { +func (r *scriptRunner) Stop(context.Context, WorkloadID, time.Duration) error { return nil } +func (r *scriptRunner) Remove(context.Context, WorkloadID) error { return nil } +func (r *scriptRunner) Exists(context.Context, string) (bool, error) { return false, nil } +func (r *scriptRunner) MountLabel(context.Context, WorkloadID) (string, error) { return "", nil } -func (r *scriptRunner) Resize(context.Context, ContainerID, ResourceLimits) error { +func (r *scriptRunner) Resize(context.Context, WorkloadID, ResourceLimits) error { return nil } @@ -224,7 +224,7 @@ func TestInstallRejectsBadGenericName(t *testing.T) { home := t.TempDir() rt := &scriptRunner{} m := NewSecretMaterializer(rt, discardLog()) - err := m.Install(context.Background(), ContainerID("c"), home, 1000, []secrets.ResolvedSecret{ + err := m.Install(context.Background(), WorkloadID("c"), home, 1000, []secrets.ResolvedSecret{ {Name: "../evil", Value: "v", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, }) if err == nil { @@ -272,7 +272,7 @@ func TestInstallRoutesByKind(t *testing.T) { {Name: "GH", Value: "gho_token", Kind: secrets.SecretGH, Host: "github.com", Delivery: secrets.DeliveryFile}, {Name: "DB_URL", Value: "postgres://db", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -338,7 +338,7 @@ func TestInstallWritesEnvDeliveryToEnvFile(t *testing.T) { {Name: "ENV_ONLY", Value: "env-secret", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryEnv}, {Name: "FILE_ONE", Value: "file-secret", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install with an env secret = %v, want nil", err) } @@ -405,7 +405,7 @@ func TestInstallMultipleGHHostsAllLand(t *testing.T) { {Name: "GH_DOTCOM", Value: "gho_dotcom", Kind: secrets.SecretGH, Host: "github.com", Delivery: secrets.DeliveryFile}, {Name: "GH_ENTERPRISE", Value: "gho_ghe", Kind: secrets.SecretGH, Host: "ghe.example.com", Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -507,7 +507,7 @@ func TestInstallMultipleProvidersAllLand(t *testing.T) { {Name: "OPENAI", Value: "sk-openai", Kind: secrets.SecretProvider, Provider: "openai", Delivery: secrets.DeliveryFile}, {Name: "ANTHROPIC", Value: "sk-anthropic", Kind: secrets.SecretProvider, Provider: "anthropic", Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -530,7 +530,7 @@ func TestInstallEmptySetWritesOnlyTheEmptyEnvFile(t *testing.T) { rt := &scriptRunner{} m := NewSecretMaterializer(rt, discardLog()) - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, nil); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, nil); err != nil { t.Fatalf("Install(empty) = %v, want nil", err) } // Exactly one exec: the env-file write. diff --git a/go/internal/runtime/userns_remap_test.go b/go/internal/runtime/userns_remap_test.go index 97b82358..9780e1d2 100644 --- a/go/internal/runtime/userns_remap_test.go +++ b/go/internal/runtime/userns_remap_test.go @@ -44,7 +44,7 @@ func agentRemapImageExists() bool { // createStartExec creates + starts a container from spec, execs command inside // it (as the container's default user), and returns the trimmed stdout. It // registers teardown so a leaked container never collides with the next run. -func createStartExec(t *testing.T, ctx context.Context, cli *PodmanCLI, spec ContainerSpec, command ...string) ExecOutput { +func createStartExec(t *testing.T, ctx context.Context, cli *PodmanCLI, spec WorkloadSpec, command ...string) ExecOutput { t.Helper() // Force-remove any leftover from a crashed run so the name is free, then @@ -90,7 +90,7 @@ func TestKeepIDRemapMapsHostUIDToSpecUID(t *testing.T) { t.Fatalf("target uid %d must differ from the host uid %d for the mapping to be observable", targetUID, hostUID) } - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-usernsremap-map-" + strconv.Itoa(os.Getpid()), UID: targetUID, @@ -118,7 +118,7 @@ func TestKeepIDRemapBindMountRoundTrip(t *testing.T) { ctx := context.Background() dir := t.TempDir() - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-usernsremap-mount-" + strconv.Itoa(os.Getpid()), UID: 2000, @@ -156,7 +156,7 @@ func TestKeepIDRemapAgentOwnsNix(t *testing.T) { } ctx := context.Background() - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: agentRemapImage, Name: "compass-usernsremap-nix-" + strconv.Itoa(os.Getpid()), UID: 1000, diff --git a/go/server/lifecycle_e2e_pgtest_test.go b/go/server/lifecycle_e2e_pgtest_test.go index 55baad00..c2d90d42 100644 --- a/go/server/lifecycle_e2e_pgtest_test.go +++ b/go/server/lifecycle_e2e_pgtest_test.go @@ -648,7 +648,7 @@ func (r *e2eResolver) resolve(_ context.Context, presented string, want store.Su return r.subj, nil } -// e2eStubRuntime is the fake ContainerRuntime backing the Runner: ExecStreaming +// e2eStubRuntime is the fake WorkloadRuntime backing the Runner: ExecStreaming // spawns a real, terminatable child (a shell-stub `podman` exec-ing `sleep`) so // the session's exec reaps on ctx cancel / Stop and StartAgent's pipe drains end // on that reap. Post-#16 nothing rides stdout/stderr — the agent's protocol @@ -682,36 +682,36 @@ func newE2EStubRuntime(t *testing.T) *e2eStubRuntime { return &e2eStubRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog), removed: map[string]bool{}} } -func (f *e2eStubRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *e2eStubRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { // Per-call-unique engine id: the container name (NamePrefix+accountID), which // already differs per account — see the type doc for why a fixed id collides. - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } -func (f *e2eStubRuntime) Start(context.Context, runtime.ContainerID) error { return nil } -func (f *e2eStubRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *e2eStubRuntime) Start(context.Context, runtime.WorkloadID) error { return nil } +func (f *e2eStubRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { return runtime.ExecOutput{}, nil } -func (f *e2eStubRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *e2eStubRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { // A real streaming exec against the shell stub: a live, terminatable Process // whose stdout/stderr pipes StartAgent drains. The stub just sleeps, so the // pipes stay empty until the exec's context cancels (loop teardown) or Stop // terminates it (despawn/Stop). return f.cli.ExecStreaming(ctx, id, spec) } -func (f *e2eStubRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *e2eStubRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return nil } -func (f *e2eStubRuntime) Remove(_ context.Context, id runtime.ContainerID) error { +func (f *e2eStubRuntime) Remove(_ context.Context, id runtime.WorkloadID) error { f.mu.Lock() defer f.mu.Unlock() f.removed[string(id)] = true return nil } func (f *e2eStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *e2eStubRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *e2eStubRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *e2eStubRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *e2eStubRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } diff --git a/proto/compass/v1/guest_control.proto b/proto/compass/v1/guest_control.proto index 19649380..40ea5fb5 100644 --- a/proto/compass/v1/guest_control.proto +++ b/proto/compass/v1/guest_control.proto @@ -182,10 +182,10 @@ message ProvisionRequest { // skips the arm and is a hermetic test seam (the host production path always // sends a non-empty default-deny ruleset). string nft_script = 1; - // default_exec_uid is the session's agent uid (ContainerSpec.UID), the + // default_exec_uid is the session's agent uid (WorkloadSpec.UID), the // default for an exec with no uid. Validated non-zero. uint32 default_exec_uid = 2; - // base_env is the base environment every exec inherits (ContainerSpec.Env). + // base_env is the base environment every exec inherits (WorkloadSpec.Env). map base_env = 3; } From d390cee3401a5c4fcbe36d3777600dfdccaae599 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 8 Sep 2026 17:23:10 -0400 Subject: [PATCH 6/6] refactor(runtime): generalize seam comment prose to workload vocabulary (RIG-3553) The type rename left five comments describing backend-agnostic constructs as container-specific: the seam file header, requireSuccess, AgentRuntime, BackendConfig, and SelectBackend. Comments on the podman implementation itself are unchanged - those genuinely describe podman containers - as are the two references to "the container path" in SelectBackend, which name the podman backend specifically. Comment-only; no code change. Co-authored-by: Matt Wilkinson --- go/internal/runtime/agent.go | 4 ++-- go/internal/runtime/microvm.go | 4 ++-- go/internal/runtime/podman.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index 6a4699d9..02025682 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -138,7 +138,7 @@ func atStage(stage string, err error) error { return &StageError{Stage: stage, Err: err} } -// requireSuccess turns a non-zero in-container exec into an InWorkloadError +// requireSuccess turns a non-zero in-workload exec into an InWorkloadError // tagged with the stage, surfacing its captured stderr. func requireSuccess(stage string, out ExecOutput) error { if out.Success() { @@ -147,7 +147,7 @@ func requireSuccess(stage string, out ExecOutput) error { return &InWorkloadError{Stage: stage, ExitCode: out.ExitCode, Stderr: out.Stderr} } -// AgentRuntime drives the per-agent container lifecycle over a WorkloadRuntime. +// AgentRuntime drives the per-agent workload lifecycle over a WorkloadRuntime. // // When constructed with an AgentRegistry via NewAgentRuntimeWithRegistry, a // successful Launch registers the handle and Teardown deregisters it, so the diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index 9bd42022..8933ed28 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -76,7 +76,7 @@ type MicroVMConfig struct { VolumeRoot string } -// BackendConfig selects and configures the container runtime backend. Backend +// BackendConfig selects and configures the workload runtime backend. Backend // is the chosen backend name ("podman" or "microvm"); MicroVM carries the // microVM-specific wiring, consulted only when Backend selects it. type BackendConfig struct { @@ -121,7 +121,7 @@ func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { return m } -// SelectBackend chooses the container runtime backend from cfg. An empty or +// SelectBackend chooses the workload runtime backend from cfg. An empty or // "podman" backend returns the podman CLI runtime; "microvm" returns the // microVM runtime; any other value is an error naming the unknown backend and // the accepted values. diff --git a/go/internal/runtime/podman.go b/go/internal/runtime/podman.go index 388c30b2..64af5823 100644 --- a/go/internal/runtime/podman.go +++ b/go/internal/runtime/podman.go @@ -19,7 +19,7 @@ // - registry.go — AgentRegistry, the Runner's live-container handle cache the // session RPCs resolve a launched container by name through. // -// This file is the container-runtime seam: a WorkloadRuntime interface plus +// This file is the workload-runtime seam: a WorkloadRuntime interface plus // PodmanCLI, its rootless-podman-CLI implementation. Rootless is a hard // requirement (design: architecture-lineage): no daemon, no root, no rootful fallback. // Containers run with --userns=keep-id:uid=,gid= so the