feat(frank-dragoon): open the NPU node the way rootless can actually reach it

The armed boot reached the engine and died in its own preflight:

  halogen npu: /dev/accel/accel0 is here, but this process cannot open it
    (crw-rw---- nobody:nogroup).

The node is root:render 0660 and a rootless container maps exactly one uid/gid,
so `render` is unmapped inside it and `--group-add keep-groups` — the vendor's
advice, and the ComfyUI pattern for /dev/kfd — cannot grant a group the
namespace does not map. Measured 2026-10-19 from a `dank` shell already in
`render`: without the flag denied, with the flag denied, with the udev mode
0666 the open succeeds (OPEN_OK). Same wall 99-comfyui-gpu-access.rules already
solved for the GPU compute nodes, so the accel node gets its counterpart:

  dankclaw/udev/98-halogen-npu-access.rules — KERNEL=="accel[0-9]*",
  SUBSYSTEM=="accel", MODE="0666"

The deployer installs the rule, reloads rules, re-triggers the node and only
then checks the mode; the serve script refuses any node that is not
other-writable, naming the rule, so this cannot return as a crash loop (189
restarts while chat was down is the failure mode being closed).

Both checks read the permission bit directly: `[ -o FILE ]` is not
"other-writable" — it means "exists" and `-o` doubles as the OR operator inside
test, which is why the first cut of the gate refused everything.

Dragoon seam: 33 -> 35 tests. The fixture node is 0666 like the installed one,
a 0660 node is refused with the rule named and nothing rendered, and the rule
file's shape plus the deployer's install/trigger are asserted.
This commit is contained in:
Frank
2026-10-19 22:35:06 -07:00
parent 3ef711f146
commit fcf94562d1
5 changed files with 91 additions and 2 deletions
+30
View File
@@ -30,6 +30,7 @@ setup() {
HOST_UNIT="$ROOT/dankclaw/host/halogen-fabric-clock"
HOST_SERVICE="$ROOT/dankclaw/host/halogen-fabric-clock.service"
NPU_FETCH="$ROOT/scripts/download-frank-dragoon-npu.sh"
NPU_RULE="$ROOT/dankclaw/udev/98-halogen-npu-access.rules"
BUILD="$ROOT/scripts/build-rocmfpx.sh"
TEST_TMP="$(mktemp -d)"
touch "$TEST_TMP/empty.env"
@@ -418,7 +419,10 @@ $(cat "$TEST_TMP/serve.log")"
# refused BY NAME instead of surfacing as an engine crash-loop.
npu_fixture() {
# 0666 like the installed node: a rootless container's namespace cannot open
# the stock root:render 0660 mode, and the serve gate checks exactly that.
touch "$TEST_TMP/accel0"
chmod 666 "$TEST_TMP/accel0"
mkdir -p "$TEST_TMP/xrt"
for l in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
touch "$TEST_TMP/xrt/$l"
@@ -526,6 +530,32 @@ $(cat "$TEST_TMP/serve.log")"
[[ "$output" == *"iommu=pt"* ]]
}
@test "the lane refuses an NPU node the container namespace could not open, and names the rule" {
# Stock Debian mode is root:render 0660. The engine's own first words on such
# a node are "this process cannot open it (crw-rw---- nobody:nogroup)" and no
# flag fixes a mode an unmapped gid cannot cross (keep-groups measured dead
# 2026-10-19), so the unit refuses instead of crash-looping 189 times.
podman_stub
good_env
npu_fixture
chmod 660 "$TEST_TMP/accel0"
run_with_log
[ "$status" -ne 0 ]
[[ "$output" == *"$TEST_TMP/accel0 is 660"* ]]
[[ "$output" == *"98-halogen-npu-access.rules"* ]]
[[ "$output" != *"--device"* ]]
}
@test "the NPU access rule is the accel counterpart of the GPU compute rule" {
[ -f "$NPU_RULE" ]
grep -qE '^KERNEL=="accel\[0-9\]\*", SUBSYSTEM=="accel", MODE="0666"$' "$NPU_RULE"
# The deployer installs the rule and re-triggers the node BEFORE it trusts
# the mode, so a machine that never had it self-heals instead of refusing.
grep -qE '98-halogen-npu-access\.rules' "$DEPLOYER"
grep -qE 'udevadm control --reload-rules' "$DEPLOYER"
grep -qE 'udevadm trigger --sysname-match=' "$DEPLOYER"
}
@test "the lane refuses when an XRT NPU library is missing" {
podman_stub
good_env