diff --git a/docs/plans/2026-03-17-usb-passthrough-plan.md b/docs/plans/2026-03-17-usb-passthrough-plan.md new file mode 100644 index 0000000..b25e987 --- /dev/null +++ b/docs/plans/2026-03-17-usb-passthrough-plan.md @@ -0,0 +1,751 @@ +# USB Device Passthrough Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add USB device passthrough to vmsilo VMs via crosvm's USB hotplug interface, with persistent config-based attachment and runtime `vmsilo-usb` CLI. + +**Architecture:** USB devices are attached/detached at runtime via `crosvm usb attach/detach` over the VM's control socket. A global state file (`/run/vmsilo/usb-state.json`) tracks which physical device is in which VM. The `vmsilo-usb` CLI dispatches attach/detach through polkit-authorized systemd oneshot services. See `docs/plans/2026-03-17-usb-passthrough-design.md` for full design. + +**Tech Stack:** Nix/NixOS modules, bash scripts, jq for JSON state, crosvm USB hotplug, systemd, polkit + +**Key crosvm details:** +- `crosvm usb attach BUS_ID:ADDR:VID:PID DEV_PATH SOCKET_PATH` — first arg is dead code (pass `1:1:0000:0000`), only DEV_PATH and SOCKET_PATH matter. Opens device fd client-side, sends over socket. Outputs `ok ` on success. +- `crosvm usb detach PORT SOCKET_PATH` — outputs `ok ` on success. +- `crosvm usb list SOCKET_PATH` — outputs `devices ...` for each attached device. +- Control socket: `/run/vmsilo/-crosvm-control.socket` + +--- + +### Task 1: Add `usbDevices` option to `modules/options.nix` + +**Files:** +- Modify: `modules/options.nix:181-680` (inside `vmSubmodule` options block) + +**Step 1: Add the usbDevices option** + +Add after the `pciDevices` option (after line 603), before `guestPrograms`: + +```nix + usbDevices = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + vendorId = lib.mkOption { + type = lib.types.str; + description = "USB vendor ID (hex, e.g., '17ef')."; + example = "17ef"; + }; + productId = lib.mkOption { + type = lib.types.str; + description = "USB product ID (hex, e.g., '60e0')."; + example = "60e0"; + }; + serial = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "USB serial number to match a specific device. If null, matches all devices with this VID:PID."; + example = "A02019100900"; + }; + }; + } + ); + default = [ ]; + description = '' + USB devices to pass through to this VM. Devices are attached when the VM starts + and detached when it stops. Matched by VID:PID, optionally narrowed by serial number. + If multiple physical devices match a VID:PID (and no serial filter), all are attached. + ''; + example = lib.literalExpression '' + [ + { vendorId = "17ef"; productId = "60e0"; } + { vendorId = "046d"; productId = "c52b"; serial = "A02019100900"; } + ] + ''; + }; +``` + +**Step 2: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` (module loads without errors) + +**Step 3: Commit** + +```bash +git add modules/options.nix +git commit -m "Add usbDevices option for USB device passthrough" +``` + +--- + +### Task 2: Add USB duplicate assertion to `modules/assertions.nix` + +**Files:** +- Modify: `modules/assertions.nix:52-226` (inside assertions list) + +**Step 1: Add the assertion** + +Add after the shared directory assertions block (after line 184), before the netvm assertions. Build a list of all `(vid, pid, serial)` tuples across all VMs and assert no duplicates: + +```nix + # USB device assertions: no duplicate VID:PID+serial across VMs + ++ ( + let + allUsbDeviceKeys = lib.concatMap ( + vm: + map ( + dev: + "${dev.vendorId}:${dev.productId}:${toString (dev.serial or "")}" + ) vm.usbDevices + ) (lib.attrValues cfg.nixosVms); + in + [ + { + assertion = lib.length allUsbDeviceKeys == lib.length (lib.unique allUsbDeviceKeys); + message = "USB devices (VID:PID+serial) cannot be assigned to multiple VMs"; + } + ] + ) +``` + +**Step 2: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` + +**Step 3: Commit** + +```bash +git add modules/assertions.nix +git commit -m "Add assertion for duplicate USB device assignments across VMs" +``` + +--- + +### Task 3: Add USB helper script to `modules/scripts.nix` + +This is the core USB logic: sysfs enumeration, state file management with flock, and crosvm attach/detach wrappers. Written as a single reusable shell library sourced by all USB-related scripts. + +**Files:** +- Modify: `modules/scripts.nix:10-27` (let block, add USB helper) +- Modify: `modules/scripts.nix:581-598` (config block, add to userScripts) + +**Step 1: Add the USB helper library script** + +Add in the `let` block (after `vms = assignVmIds cfg.nixosVms;` around line 29): + +```nix + # USB helper library — sourced by vmsilo-usb and USB service scripts + usbHelperLib = pkgs.writeShellScript "vmsilo-usb-lib" '' + USB_STATE_FILE="/run/vmsilo/usb-state.json" + USB_STATE_LOCK="/run/vmsilo/usb-state.lock" + CROSVM="${cfg._internal.crosvm}/bin/crosvm" + + # Acquire exclusive lock on state file (fd 9) + usb_lock() { + exec 9>"$USB_STATE_LOCK" + ${pkgs.util-linux}/bin/flock 9 + } + + # Release lock + usb_unlock() { + exec 9>&- + } + + # Read state file (returns {} if missing) + usb_read_state() { + if [ -f "$USB_STATE_FILE" ]; then + ${pkgs.jq}/bin/jq '.' "$USB_STATE_FILE" + else + echo '{}' + fi + } + + # Write state file + usb_write_state() { + local state="$1" + echo "$state" > "$USB_STATE_FILE.tmp" + mv "$USB_STATE_FILE.tmp" "$USB_STATE_FILE" + } + + # Enumerate host USB devices from sysfs + # Output: JSON array of {devpath, vid, pid, serial, busnum, devnum, manufacturer, product, dev_file} + usb_enumerate() { + local devices="[]" + for dev in /sys/bus/usb/devices/[0-9]*-[0-9]*; do + [ -f "$dev/idVendor" ] || continue + # Skip interface entries (contain :) + case "$(basename "$dev")" in + *:*) continue ;; + esac + local devpath busnum devnum vid pid serial manufacturer product + devpath=$(basename "$dev") + vid=$(cat "$dev/idVendor" 2>/dev/null || echo "") + pid=$(cat "$dev/idProduct" 2>/dev/null || echo "") + serial=$(cat "$dev/serial" 2>/dev/null || echo "") + busnum=$(cat "$dev/busnum" 2>/dev/null || echo "0") + devnum=$(cat "$dev/devnum" 2>/dev/null || echo "0") + manufacturer=$(cat "$dev/manufacturer" 2>/dev/null || echo "") + product=$(cat "$dev/product" 2>/dev/null || echo "") + dev_file="/dev/bus/usb/$(printf '%03d' "$busnum")/$(printf '%03d' "$devnum")" + devices=$(echo "$devices" | ${pkgs.jq}/bin/jq \ + --arg dp "$devpath" --arg v "$vid" --arg p "$pid" \ + --arg s "$serial" --arg bn "$busnum" --arg dn "$devnum" \ + --arg mfg "$manufacturer" --arg prod "$product" --arg df "$dev_file" \ + '. + [{devpath: $dp, vid: $v, pid: $p, serial: $s, busnum: ($bn|tonumber), devnum: ($dn|tonumber), manufacturer: $mfg, product: $prod, dev_file: $df}]') + done + echo "$devices" + } + + # Find devices matching a VID:PID + # Args: $1=vid, $2=pid, $3=serial (optional, "" for any) + usb_find_by_vidpid() { + local vid="$1" pid="$2" serial="''${3:-}" + local devices + devices=$(usb_enumerate) + if [ -n "$serial" ]; then + echo "$devices" | ${pkgs.jq}/bin/jq --arg v "$vid" --arg p "$pid" --arg s "$serial" \ + '[.[] | select(.vid == $v and .pid == $p and .serial == $s)]' + else + echo "$devices" | ${pkgs.jq}/bin/jq --arg v "$vid" --arg p "$pid" \ + '[.[] | select(.vid == $v and .pid == $p)]' + fi + } + + # Find device by devpath + usb_find_by_devpath() { + local devpath="$1" + usb_enumerate | ${pkgs.jq}/bin/jq --arg dp "$devpath" '[.[] | select(.devpath == $dp)]' + } + + # Attach a single device to a VM + # Args: $1=vm_name, $2=devpath, $3=dev_file, $4=vid, $5=pid, $6=serial, $7=busnum, $8=devnum + # State file must already be locked + usb_do_attach() { + local vm_name="$1" devpath="$2" dev_file="$3" vid="$4" pid="$5" serial="$6" busnum="$7" devnum="$8" + local socket="/run/vmsilo/''${vm_name}-crosvm-control.socket" + local state result port + + state=$(usb_read_state) + + # Check if already attached somewhere — detach first + local existing_vm existing_port + existing_vm=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].vm // empty') + if [ -n "$existing_vm" ]; then + existing_port=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].port') + local existing_socket="/run/vmsilo/''${existing_vm}-crosvm-control.socket" + if [ -S "$existing_socket" ]; then + $CROSVM usb detach "$existing_port" "$existing_socket" >/dev/null 2>&1 || true + fi + state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg dp "$devpath" 'del(.[$dp])') + fi + + # Attach to target VM + result=$($CROSVM usb attach "1:1:0000:0000" "$dev_file" "$socket" 2>&1) || { + echo "Failed to attach $devpath ($vid:$pid) to $vm_name: $result" >&2 + usb_write_state "$state" + return 1 + } + + # Parse port from "ok " + port=$(echo "$result" | ${pkgs.sed}/bin/sed -n 's/^ok //p') + if [ -z "$port" ]; then + echo "Unexpected crosvm response: $result" >&2 + usb_write_state "$state" + return 1 + fi + + # Update state + state=$(echo "$state" | ${pkgs.jq}/bin/jq \ + --arg dp "$devpath" --arg v "$vid" --arg p "$pid" --arg s "$serial" \ + --arg bn "$busnum" --arg dn "$devnum" --arg vm "$vm_name" --argjson pt "$port" \ + '.[$dp] = {vid: $v, pid: $p, serial: $s, busnum: ($bn|tonumber), devnum: ($dn|tonumber), vm: $vm, port: $pt}') + usb_write_state "$state" + echo "Attached $devpath ($vid:$pid) to $vm_name on port $port" + } + + # Detach a single device from a VM + # Args: $1=vm_name, $2=devpath + # State file must already be locked + usb_do_detach() { + local vm_name="$1" devpath="$2" + local socket="/run/vmsilo/''${vm_name}-crosvm-control.socket" + local state port + + state=$(usb_read_state) + port=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].port // empty') + + if [ -z "$port" ]; then + echo "Device $devpath not attached to $vm_name" >&2 + return 1 + fi + + if [ -S "$socket" ]; then + $CROSVM usb detach "$port" "$socket" >/dev/null 2>&1 || true + fi + + state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg dp "$devpath" 'del(.[$dp])') + usb_write_state "$state" + echo "Detached $devpath from $vm_name" + } + + # Clean up all state entries for a VM (used on VM stop) + usb_cleanup_vm() { + local vm_name="$1" + usb_lock + local state + state=$(usb_read_state) + state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg vm "$vm_name" 'with_entries(select(.value.vm != $vm))') + usb_write_state "$state" + usb_unlock + } + ''; +``` + +Note: `${pkgs.sed}` is used for the `sed` call above. Add `sed` from `pkgs.gnused` or just use `pkgs.gnused`. Actually, the `sed` in NixOS is already available. Use `${pkgs.gnused}/bin/sed` to be explicit in the script. + +**Step 2: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` + +**Step 3: Commit** + +```bash +git add modules/scripts.nix +git commit -m "Add USB helper library for state management and crosvm interaction" +``` + +--- + +### Task 4: Add `vmsilo-usb` user-facing script to `modules/scripts.nix` + +**Files:** +- Modify: `modules/scripts.nix` (let block, add script; config block, add to userScripts) + +**Step 1: Add the vmsilo-usb script** + +Add in the `let` block, after `usbHelperLib`: + +```nix + # vmsilo-usb: User-facing USB management CLI + vmsiloUsbScript = pkgs.writeShellScript "vmsilo-usb" '' + source ${usbHelperLib} + + usage() { + echo "Usage: vmsilo-usb List USB devices" >&2 + echo " vmsilo-usb attach Attach device to VM" >&2 + echo " vmsilo-usb detach Detach device from VM" >&2 + exit 1 + } + + # List mode (no args) + cmd_list() { + local devices state + devices=$(usb_enumerate) + if [ -f "$USB_STATE_FILE" ]; then + state=$(cat "$USB_STATE_FILE") + else + state='{}' + fi + + # Header + printf "%-10s %-6s %-14s %-14s %-24s %s\n" "VID:PID" "Port" "Serial" "Manufacturer" "Product" "VM" + + # Format each device + echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.vid, .pid, .devpath, .serial, .manufacturer, .product] | @tsv' | \ + while IFS=$'\t' read -r vid pid devpath serial manufacturer product; do + local vm + vm=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].vm // "-"') + [ -z "$serial" ] && serial="-" + [ -z "$manufacturer" ] && manufacturer="-" + [ -z "$product" ] && product="-" + printf "%-10s %-6s %-14s %-14s %-24s %s\n" "$vid:$pid" "$devpath" "$serial" "$manufacturer" "$product" "$vm" + done + } + + # Parse vid:pid string + parse_vidpid() { + local arg="$1" + case "$arg" in + *:*) + USB_VID="''${arg%%:*}" + USB_PID="''${arg#*:}" + ;; + *) + return 1 + ;; + esac + } + + # Check if argument looks like a devpath (contains dash, no colon-only pattern) + is_devpath() { + case "$1" in + [0-9]*-[0-9]*) return 0 ;; + *) return 1 ;; + esac + } + + cmd_attach() { + local vm_name="$1" identifier="$2" + local devices + + if is_devpath "$identifier"; then + devices=$(usb_find_by_devpath "$identifier") + elif parse_vidpid "$identifier"; then + devices=$(usb_find_by_vidpid "$USB_VID" "$USB_PID") + else + echo "Invalid identifier: $identifier (expected VID:PID or devpath like 1-3)" >&2 + exit 1 + fi + + local count + count=$(echo "$devices" | ${pkgs.jq}/bin/jq 'length') + if [ "$count" -eq 0 ]; then + echo "No USB devices found matching $identifier" >&2 + exit 1 + fi + + # Dispatch through systemd for polkit authorization + echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.devpath, .dev_file, .vid, .pid, .serial, .busnum, .devnum] | @tsv' | \ + while IFS=$'\t' read -r devpath dev_file vid pid serial busnum devnum; do + systemctl start "vmsilo-usb-attach@$(systemd-escape "''${vm_name}:''${devpath}:''${dev_file}:''${vid}:''${pid}:''${serial}:''${busnum}:''${devnum}").service" + done + } + + cmd_detach() { + local vm_name="$1" identifier="$2" + + if is_devpath "$identifier"; then + systemctl start "vmsilo-usb-detach@$(systemd-escape "''${vm_name}:''${identifier}").service" + elif parse_vidpid "$identifier"; then + # Find devpaths matching this VID:PID that are attached to this VM + local state + if [ -f "$USB_STATE_FILE" ]; then + state=$(cat "$USB_STATE_FILE") + else + echo "No USB devices are attached" >&2 + exit 1 + fi + local devpaths + devpaths=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg vm "$vm_name" --arg v "$USB_VID" --arg p "$USB_PID" \ + 'to_entries[] | select(.value.vm == $vm and .value.vid == $v and .value.pid == $p) | .key') + if [ -z "$devpaths" ]; then + echo "No devices matching $identifier attached to $vm_name" >&2 + exit 1 + fi + for devpath in $devpaths; do + systemctl start "vmsilo-usb-detach@$(systemd-escape "''${vm_name}:''${devpath}").service" + done + else + echo "Invalid identifier: $identifier (expected VID:PID or devpath like 1-3)" >&2 + exit 1 + fi + } + + case "''${1:-}" in + "") + cmd_list + ;; + attach) + [ $# -eq 3 ] || usage + cmd_attach "$2" "$3" + ;; + detach) + [ $# -eq 3 ] || usage + cmd_detach "$2" "$3" + ;; + *) + usage + ;; + esac + ''; +``` + +**Step 2: Register in userScripts** + +In the `config` block (around line 590), add `vmsilo-usb` to the userScripts attrset: + +```nix + userScripts = { + vm-run = vmRunScript; + vm-start = vmStartScript; + vm-start-debug = vmStartDebugScript; + vm-stop = vmStopScript; + vm-shell = vmShellScript; + vmsilo-usb = vmsiloUsbScript; + }; +``` + +**Step 3: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` + +**Step 4: Commit** + +```bash +git add modules/scripts.nix +git commit -m "Add vmsilo-usb CLI script for runtime USB attach/detach/list" +``` + +--- + +### Task 5: Add USB systemd services to `modules/services.nix` + +Three additions: (a) USB attach/detach oneshot templates for polkit, (b) persistent USB attach service per VM, (c) state cleanup on VM stop. + +**Files:** +- Modify: `modules/services.nix:10-13` (let block, add imports) +- Modify: `modules/services.nix:104-298` (systemd.services, add USB services) +- Modify: `modules/services.nix:134-137` (VM service ExecStopPost, add USB cleanup) + +**Step 1: Add usbHelperLib import** + +In the `let` block at the top, we need access to the USB helper lib. Since it's defined in scripts.nix, we need to reference it through `_internal`. However, to keep things simple, we can define the USB service scripts inline in services.nix, or add a new `_internal` option. The cleanest approach: add `usbHelperLib` to `_internal` in scripts.nix, then consume it in services.nix. + +First, add to `modules/options.nix` (inside `_internal`): + +```nix + usbHelperLib = lib.mkOption { + type = lib.types.path; + description = "USB helper library script."; + internal = true; + }; +``` + +Then in `modules/scripts.nix` config block, add: + +```nix + usbHelperLib = usbHelperLib; +``` + +**Step 2: Add USB attach/detach oneshot template services** + +Add to the `systemd.services` list (after the tray proxy services, before the closing `]`): + +```nix + ++ + # USB attach oneshot template (invoked by vmsilo-usb attach via polkit/systemctl) + # Instance string: "VM_NAME:DEVPATH:DEV_FILE:VID:PID:SERIAL:BUSNUM:DEVNUM" + [ + (lib.nameValuePair "vmsilo-usb-attach@" { + description = "vmsilo USB attach: %i"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "vmsilo-usb-do-attach" '' + source ${cfg._internal.usbHelperLib} + # Parse instance string (systemd-escaped, already unescaped by systemd) + IFS=':' read -r vm_name devpath dev_file vid pid serial busnum devnum <<< "$1" + usb_lock + usb_do_attach "$vm_name" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum" + usb_unlock + ''; + }; + }) + (lib.nameValuePair "vmsilo-usb-detach@" { + description = "vmsilo USB detach: %i"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "vmsilo-usb-do-detach" '' + source ${cfg._internal.usbHelperLib} + IFS=':' read -r vm_name devpath <<< "$1" + usb_lock + usb_do_detach "$vm_name" "$devpath" + usb_unlock + ''; + }; + }) + ] +``` + +Wait — template services receive the instance string as `%i`, not as `$1`. We need to use `%i` in the ExecStart or pass it differently. Actually, for template services the instance is available as `%i` in systemd unit fields, but not directly as a shell argument. We need `ExecStart` to accept it. + +Revised approach: use `%I` (unescaped instance) in the ExecStart command line: + +```nix + (lib.nameValuePair "vmsilo-usb-attach@" { + description = "vmsilo USB attach: %I"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.writeShellScript "vmsilo-usb-do-attach" '' + source ${cfg._internal.usbHelperLib} + IFS=':' read -r vm_name devpath dev_file vid pid serial busnum devnum <<< "$1" + usb_lock + usb_do_attach "$vm_name" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum" + usb_unlock + ''} %I"; + }; + }) +``` + +This passes the unescaped instance string as `$1` to the script. + +**Step 3: Add persistent USB attach service per VM** + +Add to the `systemd.services` list: + +```nix + ++ + # Persistent USB attach services (one per VM with usbDevices configured) + lib.concatMap ( + vm: + lib.optional (vm.usbDevices != []) ( + lib.nameValuePair "vmsilo-${vm.name}-usb-attach" { + description = "USB device attach for VM ${vm.name}"; + requires = [ "vmsilo-${vm.name}-vm.service" ]; + after = [ "vmsilo-${vm.name}-vm.service" ]; + wantedBy = [ "vmsilo-${vm.name}-vm.service" ]; + bindsTo = [ "vmsilo-${vm.name}-vm.service" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "vmsilo-usb-attach-${vm.name}" '' + source ${cfg._internal.usbHelperLib} + + SOCKET="/run/vmsilo/${vm.name}-crosvm-control.socket" + + # Wait for control socket (up to 30s) + ELAPSED=0 + while [ ! -S "$SOCKET" ] && [ $ELAPSED -lt 30 ]; do + sleep 0.5 + ELAPSED=$((ELAPSED + 1)) + done + if [ ! -S "$SOCKET" ]; then + echo "Timeout waiting for control socket: $SOCKET" >&2 + exit 1 + fi + + usb_lock + + ${lib.concatMapStringsSep "\n" (dev: '' + # Attach ${dev.vendorId}:${dev.productId}${lib.optionalString (dev.serial != null) " serial=${dev.serial}"} + devices=$(usb_find_by_vidpid "${dev.vendorId}" "${dev.productId}" "${toString (dev.serial or "")}") + count=$(echo "$devices" | ${pkgs.jq}/bin/jq 'length') + if [ "$count" -eq 0 ]; then + echo "Warning: USB device ${dev.vendorId}:${dev.productId} not found" >&2 + else + echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.devpath, .dev_file, .vid, .pid, .serial, .busnum, .devnum] | @tsv' | \ + while IFS=$'\t' read -r devpath dev_file vid pid serial busnum devnum; do + usb_do_attach "${vm.name}" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum" || true + done + fi + '') vm.usbDevices} + + usb_unlock + ''; + }; + } + ) + ) vms +``` + +**Step 4: Add USB state cleanup to VM ExecStopPost** + +In the VM service definition (around line 134), add a USB cleanup script to `stopPostScripts`: + +```nix + usbCleanup = pkgs.writeShellScript "usb-cleanup-${vm.name}" '' + source ${cfg._internal.usbHelperLib} + usb_cleanup_vm "${vm.name}" + ''; +``` + +And add `"${usbCleanup}"` to the `stopPostScripts` list. + +**Step 5: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` + +**Step 6: Commit** + +```bash +git add modules/options.nix modules/scripts.nix modules/services.nix +git commit -m "Add USB systemd services: persistent attach, runtime oneshot templates, cleanup" +``` + +--- + +### Task 6: Add `vmsilo-usb` to package and bash completion + +**Files:** +- Modify: `modules/package.nix:14-37` (vmPackage) +- Modify: `modules/package.nix:32-36` (bash completion section) + +**Step 1: Add vmsilo-usb to bash completion list** + +In `package.nix`, the bash completion loop (line 33) currently lists: +``` +for cmd in vm-run vm-start vm-start-debug vm-stop vm-shell; do +``` + +Add `vmsilo-usb`: +``` +for cmd in vm-run vm-start vm-start-debug vm-stop vm-shell vmsilo-usb; do +``` + +(The `vmsilo-usb` script is already included via the `userScripts` loop on lines 24-26 — no additional symlink needed since we added it to `userScripts` in Task 4.) + +**Step 2: Verify syntax** + +Run: `nix eval .#nixosModules.default --apply 'x: "ok"'` +Expected: `"ok"` + +**Step 3: Commit** + +```bash +git add modules/package.nix +git commit -m "Add vmsilo-usb to bash completion" +``` + +--- + +### Task 7: Update README.md and CLAUDE.md + +**Files:** +- Modify: `README.md` (add USB passthrough section) +- Modify: `CLAUDE.md` (update Generated Scripts section) + +**Step 1: Update README.md** + +Add a USB Passthrough section to README.md documenting: +- The `usbDevices` option with examples +- `vmsilo-usb` CLI usage (list, attach, detach) +- How persistent vs runtime attachment works + +**Step 2: Update CLAUDE.md** + +Add `vmsilo-usb` to the Generated Scripts section: +``` +- `vmsilo-usb` — list USB devices, attach/detach USB devices to VMs +``` + +**Step 3: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "Document USB device passthrough in README and CLAUDE.md" +``` + +--- + +### Task 8: Format and final verification + +**Step 1: Format** + +Run: `nix fmt` + +**Step 2: Commit formatting if needed** + +```bash +git add -u +git commit -m "Format with nixfmt" +``` + +**Step 3: Build** + +Run: `nix build .#` to verify the full build succeeds. + +Note: `nix build` uses git index — ensure all new files are `git add`'d first.