fix(computer-use): stop launching retired browser-grant runtimes

This commit is contained in:
Zane Chee
2026-08-30 01:46:02 +08:00
committed by Teknium
parent 5368598ba1
commit b2e24b986f
3 changed files with 53 additions and 137 deletions

View File

@@ -3750,10 +3750,7 @@ DEFAULT_CONFIG = {
# False = always enable the overlay
"no_overlay": None,
# cua-driver permission mode for each Hermes computer-use runtime.
# standard (default) — cua-driver's own approval boundary. Protected
# operations (e.g. attaching to an existing signed-in browser
# profile) fail closed unless grant_existing_profile is enabled
# below.
# standard (default) — cua-driver's own approval boundary.
# bounded — repeatable automation under a user-reviewed session
# capability manifest (set capability_manifest below). No runtime
# prompts; anything outside the manifest fails closed inside
@@ -3774,14 +3771,6 @@ DEFAULT_CONFIG = {
# cua-driver identity (com.trycua.driver / an official signing team).
# Enable only when developing the driver locally from source.
"allow_unsigned_driver": False,
# Pre-authorize existing-profile browser attachment in standard mode
# (cua-driver's trusted-launcher `--grant existing-profile`). When
# true, the agent can attach to your already-running, signed-in
# Chrome/Edge window — exposing that profile's live pages, cookies,
# and storage to the browser protocol — without a per-use prompt.
# Leave false to keep existing-profile attachment failing closed;
# isolated driver-owned profiles work either way.
"grant_existing_profile": False,
},
# =========================================================================

View File

@@ -2,8 +2,9 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -213,35 +214,62 @@ def test_standard_backend_does_not_spawn_an_embedded_daemon():
assert unrestricted._embedded_daemon is not None
def test_standard_existing_profile_grant_owns_private_macos_runtime():
from tools.computer_use.cua_backend import _standard_runtime_launch_args
def test_retired_browser_grant_cannot_change_standard_runtime(tmp_path, monkeypatch):
from tools.computer_use.cua_backend import _AsyncBridge, _CuaDriverSession
args, socket_path = _standard_runtime_launch_args(
["mcp"],
grant_existing_profile=True,
platform="darwin",
socket_path="/tmp/hermes-cua-test.sock",
(tmp_path / "config.yaml").write_text(
"computer_use:\n grant_existing_profile: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
session = _CuaDriverSession(_AsyncBridge())
captured = {}
assert args == [
"mcp",
"--grant",
"existing-profile",
"--socket",
"/tmp/hermes-cua-test.sock",
]
assert socket_path == "/tmp/hermes-cua-test.sock"
async def drive_lifecycle():
def capture_params(**kwargs):
captured.update(kwargs)
return MagicMock()
with patch(
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
return_value="/opt/cua-driver",
), patch(
"tools.computer_use.cua_backend._resolve_mcp_invocation",
return_value=("/opt/cua-driver", ["mcp"]),
), patch(
"mcp.StdioServerParameters", side_effect=capture_params
), patch(
"mcp.client.stdio.stdio_client"
) as stdio_client, patch(
"mcp.ClientSession"
) as client_session:
stdio_client.return_value.__aenter__ = AsyncMock(
return_value=(MagicMock(), MagicMock())
)
stdio_client.return_value.__aexit__ = AsyncMock(return_value=None)
live_session = MagicMock()
live_session.initialize = AsyncMock()
live_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
client_session.return_value.__aenter__ = AsyncMock(
return_value=live_session
)
client_session.return_value.__aexit__ = AsyncMock(return_value=None)
def test_standard_existing_profile_grant_stays_in_process_off_macos():
from tools.computer_use.cua_backend import _standard_runtime_launch_args
async def stop_when_ready():
while session._shutdown_event is None:
await asyncio.sleep(0)
session._shutdown_event.set()
args, socket_path = _standard_runtime_launch_args(
["mcp"], grant_existing_profile=True, platform="linux"
)
stop_task = asyncio.create_task(stop_when_ready())
try:
await session._lifecycle_coro()
finally:
await stop_task
assert args == ["mcp", "--grant", "existing-profile"]
assert socket_path is None
asyncio.run(drive_lifecycle())
assert captured["command"] == "/opt/cua-driver"
assert captured["args"] == ["mcp"]
def test_transport_reset_invalidates_native_capabilities():

View File

@@ -309,26 +309,6 @@ def _cua_capability_manifest() -> Optional[str]:
return raw.strip()
def _cua_grant_existing_profile() -> bool:
"""True when the user pre-authorized existing-profile browser attachment.
Reads ``computer_use.grant_existing_profile`` (default False). This is
cua-driver's trusted-launcher grant. Hermes passes
``--grant existing-profile`` when it launches the standard-mode runtime.
On macOS it also selects a private socket so the newly configured
CuaDriver.app runtime cannot collide with an already-running default
daemon. The setting never applies to bounded mode, where the reviewed
capability manifest owns authorization.
It DOES apply to unrestricted mode. An approval bypass (``--yolo``,
``-z``) is consent to skip prompts, not consent to read an existing
browser profile's live pages, cookies, and storage, so the host-side
grant floor enforces this key even when the private unrestricted daemon
would answer the launch.
"""
return bool(_computer_use_cfg().get("grant_existing_profile", False))
def _manifest_is_mode_independent(path: str) -> bool:
"""True when this capability manifest may accompany any permission mode.
@@ -360,36 +340,6 @@ def _manifest_is_mode_independent(path: str) -> bool:
return isinstance(version, int) and not isinstance(version, bool) and version >= 3
def _standard_runtime_launch_args(
args: List[str],
*,
grant_existing_profile: bool,
platform: str,
socket_path: Optional[str] = None,
) -> Tuple[List[str], Optional[str]]:
"""Return MCP args and any private runtime socket owned by this transport.
Windows and Linux run the standard runtime in the MCP process, so the
launch grant can be passed directly. macOS proxies through CuaDriver.app;
a grant must therefore launch a fresh app daemon on a private socket
instead of trying to reconfigure the default daemon.
``platform`` is explicit so this policy can be tested as a pure function
on every CI host.
"""
result = list(args)
if not grant_existing_profile:
return result, None
result.extend(["--grant", "existing-profile"])
if platform != "darwin":
return result, None
private_socket = socket_path or os.path.join(
tempfile.gettempdir(), f"hermes-cua-standard-{uuid.uuid4().hex[:12]}.sock"
)
result.extend(["--socket", private_socket])
return result, private_socket
def _computer_use_max_image_dimension() -> Optional[int]:
"""Longest-edge cap for cua-driver screenshots, or None to leave unset.
@@ -1717,10 +1667,6 @@ class _CuaDriverSession:
# Used to revive a logical ended-session rejection without
# recursive call_tool re-entry or backend-owned state (#71166).
self._declared_session_id: Optional[str] = None
# A macOS standard-mode launch grant belongs to the app daemon that
# receives it. Select and own a private endpoint so an existing
# default daemon cannot reject or silently miss the requested grant.
self._owned_standard_runtime_socket: Optional[str] = None
self._transport_generation = 0
self._transport_reset_callback: Optional[Any] = None
@@ -1764,13 +1710,6 @@ class _CuaDriverSession:
child_env = self._embedded_daemon.child_env()
else:
command, args = _resolve_mcp_invocation(driver_cmd)
args, owned_socket = _standard_runtime_launch_args(
args,
grant_existing_profile=_cua_grant_existing_profile(),
platform=sys.platform,
socket_path=self._owned_standard_runtime_socket,
)
self._owned_standard_runtime_socket = owned_socket
child_env = cua_driver_child_env()
_t_manifest = _time.monotonic()
params = StdioServerParameters(
@@ -1879,17 +1818,8 @@ class _CuaDriverSession:
with self._lock:
if self._started:
return
# A previous transport may have died without taking down its
# private app daemon. Stop that exact endpoint before relaunching
# with --grant; grants cannot modify an already-running runtime.
if self._owned_standard_runtime_socket is not None:
self._stop_owned_standard_runtime_locked()
self._bridge.start()
try:
self._start_lifecycle_locked()
except Exception:
self._stop_owned_standard_runtime_locked()
raise
self._start_lifecycle_locked()
self._started = True
def _start_lifecycle_locked(self) -> None:
@@ -1935,11 +1865,9 @@ class _CuaDriverSession:
def stop(self) -> None:
with self._lock:
if not self._started:
self._stop_owned_standard_runtime_locked()
return
self._started = False
self._stop_lifecycle_locked()
self._stop_owned_standard_runtime_locked()
def set_transport_reset_callback(self, callback: Any) -> None:
"""Register a synchronous cache invalidation hook for transport swaps."""
@@ -1954,34 +1882,6 @@ class _CuaDriverSession:
except Exception as exc:
logger.debug("cua-driver transport reset callback failed: %s", exc)
def _stop_owned_standard_runtime_locked(self) -> None:
"""Stop the exact private macOS app daemon launched for a grant."""
socket_path = getattr(self, "_owned_standard_runtime_socket", None)
if not socket_path:
return
self._owned_standard_runtime_socket = None
driver_command = resolve_cua_driver_cmd()
if driver_command:
from tools.environments.local import _sanitize_subprocess_env
try:
subprocess.run(
[driver_command, "stop", "--socket", socket_path],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3.0,
creationflags=windows_hide_flags(),
env=_sanitize_subprocess_env(cua_driver_child_env()),
)
except (OSError, subprocess.SubprocessError):
pass
if os.path.exists(socket_path):
try:
os.remove(socket_path)
except OSError:
pass
def _stop_lifecycle_locked(self) -> None:
"""Signal shutdown + wait for the lifecycle coroutine to unwind.
Caller must hold self._lock."""
@@ -2194,7 +2094,6 @@ class _CuaDriverSession:
except Exception as e:
logger.debug("cua-driver session cleanup before reconnect failed: %s", e)
self._started = False
self._stop_owned_standard_runtime_locked()
# Clear stale capability state; the next start populates from scratch.
self._capabilities = {}
self._tool_schemas = {}