fix(computer-use): stop launching retired browser-grant runtimes
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user