USB Passthrough — Operator Guide
Step-by-step recipe for getting a USB device on a host machine to
respond to traffic from a remote viewer. Host-side end-to-end works on
Linux libusb; Windows WinUSB and macOS IOKit are implemented but
hardware-unverified — both must pass the Phase 2e hardware test
matrix before production use. default_passthrough_backend() picks
the right backend for the current OS.
If you’re a security reviewer instead of an operator, you want USB Passthrough — Phase 2e Security Review Checklist. If you’re a developer wanting the protocol details, USB Passthrough — Phase 2 Design.
Prerequisites
On the host (the machine with the physical USB device):
Python 3.10+ with AutoControl installed.
The optional
webrtcextra:pip install je_auto_control[webrtc].pyusbinstalled if you want the libusb backend:pip install pyusb.The USB device the viewer will use, plugged in.
Per-OS setup (see Driver setup below).
On the viewer (the remote machine that will use the device):
Python 3.10+ with AutoControl installed.
Network reach to the host’s REST API port (default 9939) and to the WebRTC signalling / TURN endpoints if the viewer is behind NAT.
The host’s bearer token (operator hands it over out-of-band).
Driver setup (per OS)
Linux (libusb)
The libusb backend is the most-tested path today. Steps:
Install
libusb-1.0development files (e.g.apt install libusb-1.0-0).Add a
udevrule so the AutoControl host process can claim the device without root. Example for a YubiKey 5 (vendor1050, product0407):# /etc/udev/rules.d/99-autocontrol-usb.rules SUBSYSTEM=="usb", ATTRS{idVendor}=="1050", ATTRS{idProduct}=="0407", MODE="0660", GROUP="plugdev"
Then
sudo udevadm control --reload && sudo udevadm trigger.Make sure your AutoControl user is in
plugdev.If the device is a HID, AutoControl’s libusb wrapper detaches
usbhidonopenand re-attaches onclose. Don’t be alarmed if your local keyboard input briefly hiccups during a claim of a HID device.
Windows (WinUSB) — hardware-unverified
The ctypes wiring exists but has not been validated against real hardware. Treat as alpha. Steps:
Use Zadig or libwdi to associate the target device with the WinUSB driver. Do not do this for devices the host OS already manages (printers, hubs, keyboards).
After binding, the device should appear in
WinusbBackend().list().Hardware testing is required before relying on transfers. See the security review checklist for the expected test matrix.
macOS (IOKit) — hardware-unverified
IokitBackend enumerates USB devices natively through IOKit
(ctypes; no pyobjc needed), so IokitBackend().list() works.
Claiming a device for transfers delegates to libusb, so install it:
pip install pyusb and brew install libusb. Notes:
A directly distributed (non App Store) build must be notarised. libusb device access needs no special entitlement.
System Integrity Protection hides Apple internal devices and some USB-C peripherals — they will not appear in
list()and cannot be claimed. This is expected.Transfers are hardware-unverified; see the security review checklist for the expected test matrix before relying on them.
Enabling the feature
USB passthrough is off by default. Two ways to opt in:
Environment variable, picked up at process start:
export JE_AUTOCONTROL_USB_PASSTHROUGH=1 python -m je_auto_control.cli start-rest
Programmatic, in your bootstrap script (overrides env):
from je_auto_control.utils.usb.passthrough import enable_usb_passthrough enable_usb_passthrough(True)
Confirm with is_usb_passthrough_enabled():
from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled
assert is_usb_passthrough_enabled()
ACL setup
The ACL defaults to "deny" so a viewer cannot claim a device the
operator hasn’t approved. Add per-device rules:
From the GUI — the USB tab on the host shows the prompt dialog on first OPEN of an unknown device. Tick Remember this decision to persist a permanent allow rule.
From Python:
from je_auto_control.utils.usb.passthrough import ( AclRule, UsbAcl, ) acl = UsbAcl() acl.add_rule(AclRule( vendor_id="1050", product_id="0407", serial=None, # match any serial label="YubiKey 5", allow=True, prompt_on_open=False, # silent allow once approved ))
By editing
~/.je_auto_control/usb_acl.jsondirectly. The file is permission-checked (mode0600on POSIX). Bad JSON or an unknownversionfalls back to default-deny. If you hand-edit the file, the HMAC signature will no longer match and the ACL fails closed (see below) — re-save throughUsbAclinstead, which refreshes the signature.
Decision precedence:
First matching rule wins.
prompt_on_open=Truemeans re-ask the operator each time, even if the rule isallow=True.If no rule matches, the file’s
default("deny"out of the box) applies.
ACL file integrity (HMAC)
The ACL is protected by an HMAC-SHA256 signature stored in a sidecar
usb_acl.json.sig. On load the signature is verified against the
file bytes; a mismatch makes the ACL fail closed (default-deny,
UsbAcl.integrity_ok reports False). This stops a process that
silently rewrites the JSON from granting itself access.
By default the signing key is a random 32-byte file
usb_acl.json.key(mode0600on POSIX), created on first save.For higher assurance, derive the key from a platform keychain and pass it explicitly:
UsbAcl(hmac_key=<bytes>). A same-user process that can read the key file could otherwise forge a signature.Pass
UsbAcl(require_signature=True)to reject even legacy unsigned files outright.
Starting the host
The host needs the REST API running (so the viewer can enumerate) and a WebRTC peer connection to the viewer (so transfers can flow).
REST:
from je_auto_control.utils.rest_api import start_rest_api_server
server = start_rest_api_server(host="0.0.0.0", port=9939)
print("Bearer:", server.token)
WebRTC: use the existing remote desktop pipeline (see
Operations & Admin Layer) to bring up a session. The viewer’s
UsbPassthroughClient then plugs into the negotiated DataChannel.
Viewer-side: claim and transfer
Enumerate
From Python:
import urllib.request, json
req = urllib.request.Request(
"http://host:9939/usb/devices",
headers={"Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(req) as r:
body = json.loads(r.read())
for d in body["devices"]:
print(d["vendor_id"], d["product_id"], d.get("product"))
Or via the USB Browser GUI tab on the viewer side: paste the host’s REST URL + token, click Fetch devices.
Open + transfer
from je_auto_control.utils.usb.passthrough import (
UsbPassthroughClient, encode_frame, decode_frame,
)
# `data_channel` is your WebRTC RTCDataChannel for the "usb" channel.
def send(frame):
data_channel.send(encode_frame(frame))
client = UsbPassthroughClient(send_frame=send)
# Wire the channel's on-message callback:
data_channel.on("message")(lambda raw: client.feed_frame(decode_frame(raw)))
handle = client.open(vendor_id="1050", product_id="0407")
response = handle.control_transfer(
bm_request_type=0xC0, b_request=6, w_value=0x0100, length=18,
)
print("device descriptor:", response.hex())
handle.close()
client.shutdown()
Errors:
UsbClientTimeout— the host took longer thanreply_timeout_s(default 10s) to respond. Check the network / host process.UsbClientError— the host replied with{ok: false, error: ...}. The most common case is denied by ACL policy — go check the prompt dialog or the ACL rule on the host.UsbClientClosed— the client or its handle was already shut down.
Troubleshooting matrix
Symptom |
Likely cause / fix |
|---|---|
|
No allow rule + |
|
Device not enumerated. Check |
|
Viewer sent more frames than the host’s
|
Transfer |
Host process is busy or the WebRTC channel is broken. Inspect the Packet Inspector tab for RTT / packet loss. |
After OPEN, host’s keyboard stops working |
Linux: a HID device was claimed and
|
Audit chain shows |
Someone edited |
Headless control (AC_usb_* commands)
Everything the GUI does is also an executor command, so JSON action files, the socket server, and the scheduler can drive USB passthrough with no GUI:
Example JSON action:
[
["AC_usb_passthrough_enable", {"enabled": true}],
["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}],
["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
]
The same operations are exposed over two more surfaces:
REST API —
GET/POST /usb/passthrough/...,/usb/acl...,/usb/loopback/...,/usb/remote/...(bearer-token gated; see/openapi.json). ACL export/import are intentionally not on REST (server-side file paths).MCP — first-class
ac_usb_*tools (ac_usb_loopback_open…) with JSON Schemas, so an agent can call them directly.
What is not shipped yet
The USB Sharing tab is the simple, AnyDesk-style surface: enable sharing on the left and Allow / Block local devices in the ACL; on the right, list the shared devices over the in-process channel and Open one (a descriptor read proves the full stack). The USB Browser tab’s Open button now also works against a localhost target via the same loopback path.
Cross-machine is fully wired: the WebRTC host creates a
usbDataChannel and the viewer exposesviewer.usb_client()(aUsbChannelClientwithlist_devices/open/resume). The USB Sharing panel has a Source selector — pick Remote (WebRTC) and the List / Open buttons run against the live WebRTC viewer’s host (viaregistry.webrtc_usb_client()); pick Local (loopback) for same-machine use. You can also drive it from Python.Windows WinUSB and macOS IOKit transfer paths are written but not yet validated against real hardware. Do not use in production until the Phase 2e hardware test matrix passes.
Phase 2e external security review has not been signed; the feature flag must remain explicit opt-in.