Skip to content

Commit 46065c9

Browse files
committed
Expose USB passthrough over REST API and first-class MCP tools
Extract the passthrough commands into a single source of truth (passthrough/commands.py) and drive every surface from it: - REST: /usb/passthrough/{status,enable}, /usb/acl{,/add,/remove,/default}, /usb/loopback/{devices,open}, /usb/remote/{devices,open} (bearer-gated; ACL export/import deliberately omitted to avoid server-side file paths). OpenAPI spec updated. - MCP: first-class ac_usb_* tools with JSON Schemas and read-only/non- destructive annotations, so an agent calls them directly instead of via ac_execute_actions. - The AC_usb_* executor adapters now delegate to the same module. Tests cover REST (auth, 400/500 paths) and MCP (registration, schema, read-only filtering). Docs updated; import je_auto_control stays Qt-free.
1 parent 3b35b47 commit 46065c9

11 files changed

Lines changed: 775 additions & 88 deletions

File tree

docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,15 @@ Example JSON action::
301301
["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
302302
]
303303

304+
The same operations are exposed over two more surfaces:
305+
306+
* **REST API** — ``GET/POST /usb/passthrough/...``, ``/usb/acl...``,
307+
``/usb/loopback/...``, ``/usb/remote/...`` (bearer-token gated; see
308+
``/openapi.json``). ACL export/import are intentionally *not* on REST
309+
(server-side file paths).
310+
* **MCP** — first-class ``ac_usb_*`` tools (``ac_usb_loopback_open`` …)
311+
with JSON Schemas, so an agent can call them directly.
312+
304313

305314
What is *not* shipped yet
306315
=========================

docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,15 @@ JSON action 範例::
281281
["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
282282
]
283283

284+
同樣的操作另外提供兩個介面:
285+
286+
* **REST API** — ``GET/POST /usb/passthrough/...``、``/usb/acl...``、
287+
``/usb/loopback/...``、``/usb/remote/...``\ (需 bearer token;見
288+
``/openapi.json``\ )。ACL 匯入/匯出刻意 **** 開 REST(伺服器端
289+
檔案路徑風險)。
290+
* **MCP** — 一級 ``ac_usb_*`` 工具(``ac_usb_loopback_open`` …),帶
291+
JSON Schema,agent 可直接呼叫。
292+
284293

285294
尚未發布的部分
286295
==============

je_auto_control/utils/executor/action_executor.py

Lines changed: 28 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -957,134 +957,74 @@ def _usb_recent_events(since: int = 0,
957957
)
958958

959959

960-
# --- USB passthrough (Phase 2) ------------------------------------------
961-
960+
# --- USB passthrough (Phase 2) — delegate to the shared command module --
962961

963962
def _usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]:
964-
"""Toggle the USB passthrough feature flag (default off)."""
965-
from je_auto_control.utils.usb.passthrough import (
966-
enable_usb_passthrough, is_usb_passthrough_enabled,
967-
)
968-
enable_usb_passthrough(bool(enabled))
969-
return {"enabled": is_usb_passthrough_enabled()}
963+
from je_auto_control.utils.usb.passthrough import commands
964+
return commands.passthrough_enable(enabled)
970965

971966

972967
def _usb_passthrough_status() -> Dict[str, Any]:
973-
from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled
974-
return {"enabled": is_usb_passthrough_enabled()}
968+
from je_auto_control.utils.usb.passthrough import commands
969+
return commands.passthrough_status()
975970

976971

977972
def _usb_acl_list() -> Dict[str, Any]:
978-
from je_auto_control.utils.usb.passthrough import UsbAcl
979-
acl = UsbAcl()
980-
return {
981-
"default": acl.default_policy,
982-
"integrity_ok": acl.integrity_ok,
983-
"rules": [r.to_dict() for r in acl.list_rules()],
984-
}
973+
from je_auto_control.utils.usb.passthrough import commands
974+
return commands.acl_list()
985975

986976

987977
def _usb_acl_add(vendor_id: str, product_id: str,
988978
serial: Optional[str] = None, allow: bool = True,
989979
prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]:
990-
from je_auto_control.utils.usb.passthrough import AclRule, UsbAcl
991-
acl = UsbAcl()
992-
acl.add_rule(AclRule(
993-
vendor_id=str(vendor_id), product_id=str(product_id),
994-
serial=(None if serial is None else str(serial)),
995-
label=str(label), allow=bool(allow),
996-
prompt_on_open=bool(prompt_on_open),
997-
))
998-
return {"added": True, "rules": len(acl.list_rules())}
980+
from je_auto_control.utils.usb.passthrough import commands
981+
return commands.acl_add(
982+
vendor_id, product_id, serial=serial, allow=allow,
983+
prompt_on_open=prompt_on_open, label=label,
984+
)
999985

1000986

1001987
def _usb_acl_remove(vendor_id: str, product_id: str,
1002988
serial: Optional[str] = None) -> Dict[str, Any]:
1003-
from je_auto_control.utils.usb.passthrough import UsbAcl
1004-
removed = UsbAcl().remove_rule(
1005-
vendor_id=str(vendor_id), product_id=str(product_id),
1006-
serial=(None if serial is None else str(serial)),
1007-
)
1008-
return {"removed": removed}
989+
from je_auto_control.utils.usb.passthrough import commands
990+
return commands.acl_remove(vendor_id, product_id, serial=serial)
1009991

1010992

1011993
def _usb_acl_set_default(policy: str) -> Dict[str, Any]:
1012-
from je_auto_control.utils.usb.passthrough import UsbAcl
1013-
acl = UsbAcl()
1014-
acl.set_default_policy(str(policy))
1015-
return {"default": acl.default_policy}
994+
from je_auto_control.utils.usb.passthrough import commands
995+
return commands.acl_set_default(policy)
1016996

1017997

1018998
def _usb_acl_export(path: str) -> Dict[str, Any]:
1019-
from je_auto_control.utils.usb.passthrough import UsbAcl, export_acl_to_file
1020-
from pathlib import Path
1021-
export_acl_to_file(UsbAcl(), Path(path))
1022-
return {"exported": True, "path": str(path)}
999+
from je_auto_control.utils.usb.passthrough import commands
1000+
return commands.acl_export(path)
10231001

10241002

10251003
def _usb_acl_import(path: str, replace: bool = False) -> Dict[str, Any]:
1026-
from je_auto_control.utils.usb.passthrough import UsbAcl, import_acl_from_file
1027-
from pathlib import Path
1028-
count = import_acl_from_file(UsbAcl(), Path(path), replace=bool(replace))
1029-
return {"imported": count}
1030-
1031-
1032-
def _usb_descriptor_probe(client: Any, vendor_id: str, product_id: str,
1033-
serial: Optional[str]) -> Dict[str, Any]:
1034-
"""Open a claim, read the device descriptor, close — return a summary."""
1035-
from je_auto_control.utils.usb.passthrough import describe_descriptor
1036-
handle = client.open(
1037-
vendor_id=str(vendor_id), product_id=str(product_id),
1038-
serial=(None if serial is None else str(serial)),
1039-
)
1040-
try:
1041-
descriptor = handle.control_transfer(
1042-
bm_request_type=0x80, b_request=0x06, w_value=0x0100, length=18,
1043-
)
1044-
finally:
1045-
handle.close()
1046-
return {
1047-
"ok": True, "vendor_id": str(vendor_id), "product_id": str(product_id),
1048-
"descriptor_hex": descriptor.hex(),
1049-
"descriptor": describe_descriptor(descriptor),
1050-
}
1004+
from je_auto_control.utils.usb.passthrough import commands
1005+
return commands.acl_import(path, replace=replace)
10511006

10521007

10531008
def _usb_loopback_list() -> Dict[str, Any]:
1054-
"""List ACL-visible devices over the in-process loopback channel."""
1055-
from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback
1056-
with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop:
1057-
return {"devices": loop.list_devices()}
1009+
from je_auto_control.utils.usb.passthrough import commands
1010+
return commands.loopback_list()
10581011

10591012

10601013
def _usb_loopback_open(vendor_id: str, product_id: str,
10611014
serial: Optional[str] = None) -> Dict[str, Any]:
1062-
"""Claim a local device over loopback and read its descriptor."""
1063-
from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback
1064-
with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop:
1065-
return _usb_descriptor_probe(loop, vendor_id, product_id, serial)
1066-
1067-
1068-
def _usb_remote_client() -> Any:
1069-
"""Return the live WebRTC viewer's USB client or raise a clear error."""
1070-
from je_auto_control.utils.remote_desktop.registry import registry
1071-
client = registry.webrtc_usb_client()
1072-
if client is None:
1073-
raise RuntimeError("no live WebRTC viewer with a usb channel")
1074-
return client
1015+
from je_auto_control.utils.usb.passthrough import commands
1016+
return commands.loopback_open(vendor_id, product_id, serial=serial)
10751017

10761018

10771019
def _usb_remote_list() -> Dict[str, Any]:
1078-
"""List the remote host's devices over the live WebRTC usb channel."""
1079-
return {"devices": _usb_remote_client().list_devices()}
1020+
from je_auto_control.utils.usb.passthrough import commands
1021+
return commands.remote_list()
10801022

10811023

10821024
def _usb_remote_open(vendor_id: str, product_id: str,
10831025
serial: Optional[str] = None) -> Dict[str, Any]:
1084-
"""Claim a remote device over the live WebRTC usb channel + probe."""
1085-
return _usb_descriptor_probe(
1086-
_usb_remote_client(), vendor_id, product_id, serial,
1087-
)
1026+
from je_auto_control.utils.usb.passthrough import commands
1027+
return commands.remote_open(vendor_id, product_id, serial=serial)
10881028

10891029

10901030
def _ac_web_run(action: Optional[Dict[str, Any]] = None,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,113 @@ def gamepad_tools() -> List[MCPTool]:
17791779
]
17801780

17811781

1782+
_VID_PID = {
1783+
"vendor_id": {"type": "string"},
1784+
"product_id": {"type": "string"},
1785+
"serial": {"type": "string"},
1786+
}
1787+
1788+
1789+
def usb_passthrough_tools() -> List[MCPTool]:
1790+
"""First-class MCP tools for USB passthrough (default-off feature)."""
1791+
return [
1792+
MCPTool(
1793+
name="ac_usb_passthrough_enable",
1794+
description=("Toggle the USB passthrough feature flag. Default "
1795+
"off; must be enabled before any usb channel is "
1796+
"honoured."),
1797+
input_schema=schema({"enabled": {"type": "boolean"}}),
1798+
handler=h.usb_passthrough_enable,
1799+
annotations=NON_DESTRUCTIVE,
1800+
),
1801+
MCPTool(
1802+
name="ac_usb_passthrough_status",
1803+
description="Report whether USB passthrough is enabled.",
1804+
input_schema=schema({}),
1805+
handler=h.usb_passthrough_status,
1806+
annotations=READ_ONLY,
1807+
),
1808+
MCPTool(
1809+
name="ac_usb_acl_list",
1810+
description=("List USB ACL rules plus the default policy and the "
1811+
"HMAC integrity state."),
1812+
input_schema=schema({}),
1813+
handler=h.usb_acl_list,
1814+
annotations=READ_ONLY,
1815+
),
1816+
MCPTool(
1817+
name="ac_usb_acl_add",
1818+
description=("Add a per-device USB ACL rule (allow/deny, optional "
1819+
"prompt-on-open). vendor_id/product_id are 4 hex "
1820+
"digits, e.g. 1050/0407."),
1821+
input_schema=schema({
1822+
**_VID_PID,
1823+
"allow": {"type": "boolean"},
1824+
"prompt_on_open": {"type": "boolean"},
1825+
"label": {"type": "string"},
1826+
}, required=["vendor_id", "product_id"]),
1827+
handler=h.usb_acl_add,
1828+
annotations=NON_DESTRUCTIVE,
1829+
),
1830+
MCPTool(
1831+
name="ac_usb_acl_remove",
1832+
description="Remove a per-device USB ACL rule.",
1833+
input_schema=schema(
1834+
dict(_VID_PID), required=["vendor_id", "product_id"],
1835+
),
1836+
handler=h.usb_acl_remove,
1837+
annotations=NON_DESTRUCTIVE,
1838+
),
1839+
MCPTool(
1840+
name="ac_usb_acl_set_default",
1841+
description="Set the USB ACL default policy (allow | deny).",
1842+
input_schema=schema({
1843+
"policy": {"type": "string", "enum": ["allow", "deny"]},
1844+
}, required=["policy"]),
1845+
handler=h.usb_acl_set_default,
1846+
annotations=NON_DESTRUCTIVE,
1847+
),
1848+
MCPTool(
1849+
name="ac_usb_loopback_list",
1850+
description=("List ACL-visible USB devices on this machine over "
1851+
"the in-process loopback channel."),
1852+
input_schema=schema({}),
1853+
handler=h.usb_loopback_list,
1854+
annotations=READ_ONLY,
1855+
),
1856+
MCPTool(
1857+
name="ac_usb_loopback_open",
1858+
description=("Claim a local USB device over loopback and read its "
1859+
"device descriptor (a full protocol-stack probe). "
1860+
"Fails closed if the ACL denies it."),
1861+
input_schema=schema(
1862+
dict(_VID_PID), required=["vendor_id", "product_id"],
1863+
),
1864+
handler=h.usb_loopback_open,
1865+
annotations=NON_DESTRUCTIVE,
1866+
),
1867+
MCPTool(
1868+
name="ac_usb_remote_list",
1869+
description=("List the remote host's USB devices over the live "
1870+
"WebRTC usb channel. Requires a connected WebRTC "
1871+
"viewer."),
1872+
input_schema=schema({}),
1873+
handler=h.usb_remote_list,
1874+
annotations=READ_ONLY,
1875+
),
1876+
MCPTool(
1877+
name="ac_usb_remote_open",
1878+
description=("Claim a remote USB device over the live WebRTC usb "
1879+
"channel and read its descriptor."),
1880+
input_schema=schema(
1881+
dict(_VID_PID), required=["vendor_id", "product_id"],
1882+
),
1883+
handler=h.usb_remote_open,
1884+
annotations=NON_DESTRUCTIVE,
1885+
),
1886+
]
1887+
1888+
17821889
ALL_FACTORIES = (
17831890
mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools,
17841891
window_tools, system_tools, recording_tools, drag_and_send_tools,
@@ -1789,4 +1896,5 @@ def gamepad_tools() -> List[MCPTool]:
17891896
redaction_tools, android_widget_tools, ios_tools, webrunner_tools,
17901897
scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools,
17911898
process_and_shell_tools, remote_desktop_tools, gamepad_tools,
1899+
usb_passthrough_tools,
17921900
)

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,3 +1535,64 @@ def gamepad_reset() -> Dict[str, Any]:
15351535
from je_auto_control.utils.gamepad import default_gamepad
15361536
default_gamepad().reset()
15371537
return {"reset": True}
1538+
1539+
1540+
# --- USB passthrough — delegate to the shared command module -------------
1541+
1542+
1543+
def usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]:
1544+
from je_auto_control.utils.usb.passthrough import commands
1545+
return commands.passthrough_enable(enabled)
1546+
1547+
1548+
def usb_passthrough_status() -> Dict[str, Any]:
1549+
from je_auto_control.utils.usb.passthrough import commands
1550+
return commands.passthrough_status()
1551+
1552+
1553+
def usb_acl_list() -> Dict[str, Any]:
1554+
from je_auto_control.utils.usb.passthrough import commands
1555+
return commands.acl_list()
1556+
1557+
1558+
def usb_acl_add(vendor_id: str, product_id: str,
1559+
serial: Optional[str] = None, allow: bool = True,
1560+
prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]:
1561+
from je_auto_control.utils.usb.passthrough import commands
1562+
return commands.acl_add(
1563+
vendor_id, product_id, serial=serial, allow=allow,
1564+
prompt_on_open=prompt_on_open, label=label,
1565+
)
1566+
1567+
1568+
def usb_acl_remove(vendor_id: str, product_id: str,
1569+
serial: Optional[str] = None) -> Dict[str, Any]:
1570+
from je_auto_control.utils.usb.passthrough import commands
1571+
return commands.acl_remove(vendor_id, product_id, serial=serial)
1572+
1573+
1574+
def usb_acl_set_default(policy: str) -> Dict[str, Any]:
1575+
from je_auto_control.utils.usb.passthrough import commands
1576+
return commands.acl_set_default(policy)
1577+
1578+
1579+
def usb_loopback_list() -> Dict[str, Any]:
1580+
from je_auto_control.utils.usb.passthrough import commands
1581+
return commands.loopback_list()
1582+
1583+
1584+
def usb_loopback_open(vendor_id: str, product_id: str,
1585+
serial: Optional[str] = None) -> Dict[str, Any]:
1586+
from je_auto_control.utils.usb.passthrough import commands
1587+
return commands.loopback_open(vendor_id, product_id, serial=serial)
1588+
1589+
1590+
def usb_remote_list() -> Dict[str, Any]:
1591+
from je_auto_control.utils.usb.passthrough import commands
1592+
return commands.remote_list()
1593+
1594+
1595+
def usb_remote_open(vendor_id: str, product_id: str,
1596+
serial: Optional[str] = None) -> Dict[str, Any]:
1597+
from je_auto_control.utils.usb.passthrough import commands
1598+
return commands.remote_open(vendor_id, product_id, serial=serial)

0 commit comments

Comments
 (0)