- Status: done
- Date: 2026-06-12
- Specs touched: none here — the spec half landed separately (
7d2d0e6:BRAIN_HOST_PROTOCOL.md# GPU capability query,APP_ISOLATION.md# GPU hard-gate reconciliation); this change implements it verbatim.
Closes the implementation half of #67. permissions.gpu: true was parsed (internal/manifest) and surfaced in the install plan, then silently dropped: writeOverride emitted no GPU stanza and there was no host GPU-capability query, so a GPU app ran on CPU and a no-GPU box failed late at docker compose up. This is the all-native inner-loop slice — brain + protocol + fake host-agent, fully unit-tested with no VM; the OS-image media stack, real /dev/dri detection in cmd/host-agent-real, and on-hardware VA-API verification are the outer-loop half, tracked in #125.
GET /v1/system/gpuacross the wire stack.protocol.SystemGPU(present+vendor+render_gid),hostclient.Client.SystemGPU, and the matching method on lifecycle'sHostDriverseam. One Pattern A probe answers both install-time questions: is there a usable GPU (the gate), and which render group opens it (the override stanza).- host-agent: new
GPUReporterseam + handler. Consumer-side interface onAgent(same shape asDiskReporter); when nil the endpoint reportspresent: falserather than erroring — "no detector wired" means "no usable GPU to offer", so the brain refuses instead of emitting an override against unknown hardware. The real/dev/driscanner plugs in here in #125. - Fake host-agent reports a synthetic Intel iGPU.
FakeGPUReporter(settable, zero value = no GPU);cmd/host-agentwires{present: true, vendor: intel, render_gid: 104}so the whole path runs undermake dev, withMALMO_FAKE_NO_GPU=1flipping it to "no usable GPU" so the refusal path is exercisable in dev too. - Install capacity gate (
internal/lifecycle, step 2b). For agpu: truemanifest the brain queries the host right after admission — before the instance row, any Docker work, or the override exist, so a refusal has nothing to roll back.present: false→ the new typedErrNoGPU, whose message is what the failed install job shows ("this app needs a GPU, and no usable GPU was detected on this box"); a host error fails the install as a host fault rather than silently falling back to CPU. Apresent: truereport carrying no render group (render_gid: 0, a malformed host answer) is rejected as a host fault at the same gate, so the override nevergroup_adds GID 0 (the root group) onto thecap_drop: ALLcontainer. The gate reads the manifest, so Door-2 custom installs are covered by the same line. - Override stanza (
writeOverride), main service only.devices: /dev/dri:/dev/dri+group_add: <render_gid>onmain_service, merged with the existing shared-foldergroup_addand declared-devices lists (theentry["group_add"]/entry["devices"]assignments became append-then-assign so the three sources compose). Sidecars get nothing. v1 is the Intel iGPU / VA-API path; the identical stanza serves AMD later, NVIDIA (Container Toolkit +deploy.resources.reservations.devices) is a structurally different follow-on.
- Seven new tests: lifecycle — stanza on
webonly across the multi-servicemigrateJobComposefixture (/dev/dribind + render GID 104, sidecars clean), no-GPU refusal iserrors.Is(err, ErrNoGPU)with zero instance rows and zero Docker calls, a GPU-query host error fails the install without masquerading as the refusal, and a no-gpuapp gets no stanza and triggers no host query; hostagent — nil reporter reportspresent: false, wired reporter round-trips and toggles; hostclient — the wire seam decodes the report. make checkgreen (gofmt, vet, OpenAPI freshness — no brain API surface changed — full Go suite).- Live shape check against real Docker on a box with an Intel iGPU: a throwaway compose project carrying the exact generated override shape (
cap_drop: ALL,user:non-root,devices: /dev/dri:/dev/dri,group_add: "992"— the box's real render GID) boots underdocker compose up; inside the container the supplementary groups include 992 and opening/dev/dri/renderD128for read succeeds, proving thegroup_addis what grants a cap-dropped container the render node.
- Dev loop on a box without
/dev/dri: the fake defaults to reporting a GPU, so installing agpu: trueapp undermake devon a machine with no/dev/dri(e.g. some Docker Desktop VMs) passes the gate and then fails atcompose upon the missing device path. SetMALMO_FAKE_NO_GPU=1there to get the refusal path instead. The real agent never has this skew — it reports what actually exists. - No catalog app declares
gpu: trueyet, so the path is exercised by tests and hand-written manifests only. - The install-plan's advisory GPU-availability figure (
APP_ISOLATION.md# GPU: "may additionally report") is not in this slice — the refusal is the contract; the pre-commit dashboard warning is a UI nicety that can ride a later install-plan change. vendoris reported but not yet dispatched on — v1 hosts only ever emitintel, and the DRI stanza is vendor-independent until the NVIDIA follow-on.
- #125: real
/dev/dri/renderD*+ PCI-vendor detection and render-GID lookup incmd/host-agent-real, the OS-image media stack (drivers,rendergroup/udev), and on-hardware VA-API verification behind the same op. - A first real
gpu: truecatalog app (hardware-transcoding media server is the natural candidate) to exercise the path end-to-end. - AMD vendor reporting, then the NVIDIA runtime path, as the spec's explicit follow-ons.