From f1e6d39a74faf4224f0d365009f31d0589c8b8eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:57:16 -0700 Subject: [PATCH] feat(computer_use): disable cua-driver telemetry by default, add opt-in (#50842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(computer_use): disable cua-driver telemetry by default, add opt-in cua-driver ships anonymous PostHog usage telemetry ENABLED by default upstream (fires cua_driver_install / cua_driver_doctor events to eu.i.posthog.com). Hermes now disables it for our users unless they explicitly opt in. - New config key `computer_use.cua_telemetry` (default false) in DEFAULT_CONFIG. - `cua_backend.cua_driver_child_env()` injects `CUA_DRIVER_RS_TELEMETRY_ENABLED=0` into the child env when telemetry is disabled (the default); leaves the var untouched on opt-in so the driver uses its own default. Reads config fail-safe — any error defaults to telemetry off. - Routed every cua-driver spawn site through the policy: MCP backend (StdioServerParameters env), `cua_driver_update_check`, doctor's health_report Popen, the install.sh/install.ps1 runner, and the `--version` / status probes. - Docs: new Telemetry subsection in computer-use.md (EN). - Tests: tests/computer_use/test_cua_telemetry.py — default disables, explicit-false disables, opt-in leaves var untouched, config-failure fails safe, inherited-enabled is overridden off. Verified live on Linux against the real cua-driver-rs 0.6.0 binary: with the var=0 the driver reports "telemetry: disabled via CUA_DRIVER_RS_TELEMETRY_ENABLED" and sends no event; with it unset it logs "sending event: cua_driver_doctor". 213 computer_use + install tests green. * fix(dashboard): fold computer_use config category into agent tab The new computer_use.cua_telemetry key created a single-field dashboard config category, tripping test_no_single_field_categories (web_server's invariant that categories with <2 fields must be merged to avoid tab sprawl). Add computer_use -> agent to _CATEGORY_MERGE, matching the existing onboarding/telegram single-field folds. --- hermes_cli/config.py | 11 +++ hermes_cli/main.py | 2 + hermes_cli/tools_config.py | 24 +++++- hermes_cli/web_server.py | 4 + tests/computer_use/test_cua_telemetry.py | 80 +++++++++++++++++++ tools/computer_use/cua_backend.py | 44 +++++++++- tools/computer_use/doctor.py | 16 ++++ .../docs/user-guide/features/computer-use.md | 19 +++++ 8 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/computer_use/test_cua_telemetry.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ee03744a45e..ce8ec7d6693 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2794,6 +2794,17 @@ DEFAULT_CONFIG = { "paste_collapse_threshold_fallback": 5, "paste_collapse_char_threshold": 2000, + # Computer Use (cua-driver) toolset settings. + "computer_use": { + # cua-driver ships with anonymous usage telemetry (PostHog) ENABLED + # by default upstream. Hermes disables it for our users unless they + # explicitly opt in here. When false (default), Hermes sets + # CUA_DRIVER_RS_TELEMETRY_ENABLED=0 in the cua-driver child env for + # every invocation (MCP backend, status, doctor, install). Set true + # to let cua-driver use its own default (telemetry on). + "cua_telemetry": False, + }, + # Config schema version - bump this when adding new required fields "_config_version": 30, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 15f9417305d..4b1a3f64db2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12526,9 +12526,11 @@ def main(): if path: version = "" try: + from hermes_cli.tools_config import _cua_driver_env version = subprocess.run( [path, "--version"], capture_output=True, text=True, timeout=5, + env=_cua_driver_env(), ).stdout.strip() except Exception: pass diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index d3afb61a035..741dbb267dd 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -582,6 +582,22 @@ def _cua_driver_cmd() -> str: return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" +def _cua_driver_env() -> dict: + """cua-driver child env with the Hermes telemetry policy applied. + + Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by + default; user opt-in via ``computer_use.cua_telemetry``). Falls back to the + current environment if the helper can't be imported, so install/status + never break on a telemetry-helper error. + """ + try: + from tools.computer_use.cua_backend import cua_driver_child_env + + return cua_driver_child_env() + except Exception: + return dict(os.environ) + + def _pip_install( args: List[str], *, @@ -804,7 +820,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: try: version = subprocess.run( [driver_cmd, "--version"], - capture_output=True, text=True, timeout=5, + capture_output=True, text=True, timeout=5, env=_cua_driver_env(), ).stdout.strip() _print_success(f" {driver_cmd} already installed: {version or 'unknown version'}") except Exception: @@ -850,7 +866,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: try: before = subprocess.run( [driver_cmd, "--version"], - capture_output=True, text=True, timeout=5, + capture_output=True, text=True, timeout=5, env=_cua_driver_env(), ).stdout.strip() except Exception: before = "" @@ -862,7 +878,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: try: after = subprocess.run( [driver_cmd, "--version"], - capture_output=True, text=True, timeout=5, + capture_output=True, text=True, timeout=5, env=_cua_driver_env(), ).stdout.strip() if after and after != before: _print_success(f" {driver_cmd} upgraded: {before} → {after}") @@ -921,7 +937,7 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) - _print_info(f" {label} cua-driver...") driver_cmd = _cua_driver_cmd() try: - result = subprocess.run(install_cmd, shell=use_shell, timeout=300) + result = subprocess.run(install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env()) if result.returncode == 0 and shutil.which(driver_cmd): if verbose: _print_success(f" {driver_cmd} installed.") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f869a2a43ae..61b0fd5dcab 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -623,6 +623,10 @@ _CATEGORY_MERGE: Dict[str, str] = { # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. "telegram": "discord", + # `computer_use.cua_telemetry` is the only schema-surfaced computer_use + # field — fold it into the agent tab rather than spawning a one-field + # orphan category. + "computer_use": "agent", } # Display order for tabs — unlisted categories sort alphabetically after these. diff --git a/tests/computer_use/test_cua_telemetry.py b/tests/computer_use/test_cua_telemetry.py new file mode 100644 index 00000000000..fd72a979f09 --- /dev/null +++ b/tests/computer_use/test_cua_telemetry.py @@ -0,0 +1,80 @@ +"""Tests for the cua-driver telemetry opt-in policy. + +cua-driver ships anonymous PostHog telemetry ENABLED by default upstream. +Hermes disables it unless the user opts in via +``computer_use.cua_telemetry: true``. The policy is applied by injecting +``CUA_DRIVER_RS_TELEMETRY_ENABLED=0`` into every cua-driver child env. + +These assert the behavior contract (default disables, opt-in leaves the var +untouched, config failure fails safe toward disabled), not specific config +snapshots. +""" + +from unittest.mock import patch + +from tools.computer_use import cua_backend + + +_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED" + + +class TestTelemetryDisabledFlag: + def test_default_config_disables(self): + # cua_telemetry absent / False => telemetry disabled. + with patch("hermes_cli.config.load_config", return_value={}): + assert cua_backend._cua_telemetry_disabled() is True + + def test_explicit_false_disables(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"cua_telemetry": False}}): + assert cua_backend._cua_telemetry_disabled() is True + + def test_opt_in_true_does_not_disable(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"cua_telemetry": True}}): + assert cua_backend._cua_telemetry_disabled() is False + + def test_config_load_failure_fails_safe(self): + # Unreadable config => default to disabling telemetry (privacy-safe). + with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): + assert cua_backend._cua_telemetry_disabled() is True + + def test_missing_section_disables(self): + with patch("hermes_cli.config.load_config", return_value={"other": {}}): + assert cua_backend._cua_telemetry_disabled() is True + + +class TestChildEnv: + def test_disabled_injects_var_zero(self): + with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=True): + env = cua_backend.cua_driver_child_env({"PATH": "/usr/bin"}) + assert env[_VAR] == "0" + # base env is preserved + assert env["PATH"] == "/usr/bin" + + def test_opt_in_leaves_var_untouched(self): + # When the user opts in, we must NOT set the var — the driver uses its + # own default. If the base env already has a value, it is preserved. + with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=False): + env = cua_backend.cua_driver_child_env({"PATH": "/usr/bin"}) + assert _VAR not in env + + def test_opt_in_preserves_user_set_var(self): + with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=False): + env = cua_backend.cua_driver_child_env({_VAR: "1", "PATH": "/usr/bin"}) + # user opted in and explicitly set it — don't clobber. + assert env[_VAR] == "1" + + def test_disabled_overrides_inherited_enabled(self): + # Even if the parent process had telemetry enabled, the default policy + # forces it off in the child. + with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=True): + env = cua_backend.cua_driver_child_env({_VAR: "1"}) + assert env[_VAR] == "0" + + def test_defaults_to_os_environ_when_no_base(self): + with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=True), \ + patch.dict("os.environ", {"SOME_MARKER": "yes"}, clear=False): + env = cua_backend.cua_driver_child_env() + assert env.get("SOME_MARKER") == "yes" + assert env[_VAR] == "0" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index bca732eb86e..b46785d2e95 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -78,6 +78,45 @@ _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport (fallback when the # driver doesn't expose `manifest` — see # `_resolve_mcp_invocation` below) +# Env var cua-driver reads to gate its anonymous usage telemetry (PostHog). +# Setting it to "0" disables telemetry; absence => the binary's own default +# (telemetry ON upstream). +_CUA_TELEMETRY_ENV_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED" + + +def _cua_telemetry_disabled() -> bool: + """True when Hermes should disable cua-driver telemetry for this user. + + Reads ``computer_use.cua_telemetry`` from config.yaml. Default is False + (telemetry off). Any failure to read config fails SAFE — toward the + privacy-preserving default of telemetry disabled. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + cu = cfg.get("computer_use") or {} + # opt-in flag: True => user wants telemetry => do NOT disable. + return not bool(cu.get("cua_telemetry", False)) + except Exception: + # Config unreadable — default to disabling telemetry (fail safe). + return True + + +def cua_driver_child_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """Return the environment dict for spawning cua-driver. + + Starts from ``base_env`` (defaults to ``os.environ``) and, when telemetry + is disabled (the default), injects ``CUA_DRIVER_RS_TELEMETRY_ENABLED=0``. + When the user has opted in, the var is left untouched so cua-driver uses + its own default. Used by every cua-driver spawn site (MCP backend, status, + doctor, install) so the policy is applied consistently. + """ + env = dict(base_env if base_env is not None else os.environ) + if _cua_telemetry_disabled(): + env[_CUA_TELEMETRY_ENV_VAR] = "0" + return env + def _resolve_mcp_invocation( driver_cmd: str, @@ -176,6 +215,7 @@ def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]] # stdin-reading mode rather than erroring — DEVNULL gives them EOF # so they exit fast instead of blocking until the timeout. stdin=subprocess.DEVNULL, + env=cua_driver_child_env(), ) except Exception: return None @@ -523,7 +563,9 @@ class _CuaDriverSession: params = StdioServerParameters( command=command, args=args, - env=_sanitize_subprocess_env(dict(os.environ)), + # Apply the telemetry policy first (default: disabled), then + # sanitize Hermes-managed secrets out of the child env. + env=_sanitize_subprocess_env(cua_driver_child_env()), ) async with stdio_client(params) as (read, write): diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index a7811c39b6d..1d557cd7d98 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -37,6 +37,21 @@ _OVERALL_GLYPH = { } +def _cua_child_env() -> Dict[str, str]: + """cua-driver child env with the Hermes telemetry policy applied. + + Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by + default unless the user opts in). Falls back to the current environment + if that import fails, so doctor never breaks on a telemetry-helper error. + """ + try: + from tools.computer_use.cua_backend import cua_driver_child_env + + return cua_driver_child_env() + except Exception: + return dict(os.environ) + + def _drive_health_report( binary: str, *, @@ -72,6 +87,7 @@ def _drive_health_report( encoding="utf-8", errors="replace", bufsize=1, + env=_cua_child_env(), ) try: # 1. initialize diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index 4996428732a..223004263d9 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -288,6 +288,25 @@ Swap the backend entirely (for testing): HERMES_COMPUTER_USE_BACKEND=noop # records calls, no side effects ``` +### Telemetry + +cua-driver ships with anonymous usage telemetry (PostHog) enabled by default +upstream. **Hermes disables it for you** — on every cua-driver invocation +(the MCP backend, `status`, `doctor`, and install) Hermes sets +`CUA_DRIVER_RS_TELEMETRY_ENABLED=0` in the driver's environment. + +To opt back in (let cua-driver use its own default and send telemetry), set +this in `config.yaml`: + +```yaml +computer_use: + cua_telemetry: true # default: false (telemetry off) +``` + +When it's on, `hermes computer-use doctor` reports `telemetry: enabled`; +when off (the default), it reports `telemetry: disabled via +CUA_DRIVER_RS_TELEMETRY_ENABLED`. + ## Testing against a local cua-driver build When you're developing cua-driver itself — or want to test an