diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index d27d91b342..4a6499f1f9 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -36,6 +36,11 @@ Gmail, Calendar, Drive, Contacts, Sheets, and Docs — through Hermes-managed OA The setup is fully non-interactive — you drive it step by step so it works on CLI, Telegram, Discord, or any platform. +Run the setup script with Python from the Hermes environment, not an unrelated +system Python. `--install-deps` syncs Hermes' declared Google extra through PM; +after syncing, restart Hermes and rerun the OAuth command. If Hermes is not +importable, use `hermes setup` first rather than installing packages with pip. + Define a shorthand first: ```bash diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index de2b938ba3..eaffdee8a8 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -26,12 +26,15 @@ from __future__ import annotations # allow PEP 604 `X | None` on Python 3.9+ import argparse import json import os -import shutil -import subprocess import sys -from importlib.metadata import version as _distribution_version from pathlib import Path +try: + import pm +except ImportError: + # A copied skill must not install into an unrelated Python environment. + pm = None + # Ensure sibling modules (_hermes_home) are importable when run standalone. _SCRIPTS_DIR = str(Path(__file__).resolve().parent) if _SCRIPTS_DIR not in sys.path: @@ -55,21 +58,6 @@ SCOPES = [ "https://www.googleapis.com/auth/documents", ] -# Exact pins: keep in sync with pyproject.toml [project.optional-dependencies].google -# and the pyproject 'google' extra. -# Pinning all protects against version drift and ensures the security floors -# (httplib2 GHSA-j5g9-f88f-gfj3, stale pyasn1/google-auth) are honoured -# regardless of install path. -REQUIRED_PACKAGES = [ - "google-api-python-client==2.194.0", - "google-auth==2.55.1", - "google-auth-oauthlib==1.3.1", - "google-auth-httplib2==0.3.1", - # GHSA-j5g9-f88f-gfj3 — Decompression Bomb DoS via unbounded gzip/deflate - "httplib2==0.32.0", - "pyasn1==0.6.4", -] - # OAuth redirect for "out of band" manual code copy flow. # Google deprecated OOB, so we use a localhost redirect and tell the user to # copy the code from the browser's URL bar (or the page body). @@ -107,85 +95,29 @@ def _format_missing_scopes(missing_scopes: list[str]) -> str: ) -def _missing_required_packages() -> list[str]: - """Return exact requirements absent or stale in this interpreter. - - All REQUIRED_PACKAGES entries are exact ``name==version`` pins, so a - direct version comparison is sufficient — no ``packaging`` dependency - needed in this standalone script. - """ - missing = [] - for spec in REQUIRED_PACKAGES: - name, _, wanted = spec.partition("==") - try: - if _distribution_version(name) != wanted: - missing.append(spec) - except Exception: - missing.append(spec) - return missing - - def install_deps(): - """Install missing or stale Google API packages. Returns True on success.""" - missing = _missing_required_packages() - if not missing: - print("Dependencies already installed.") - return True - - print("Installing Google API dependencies...") - - # First choice: pip in the current interpreter. Works for most installs. + """Sync Hermes' declared Google extra, ready for the next process.""" + if pm is None: + print("ERROR: Run this script in the Hermes environment; use hermes setup first.") + return False try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet"] + missing, - stdout=subprocess.DEVNULL, - ) - remaining = _missing_required_packages() - if remaining: - print(f"ERROR: Dependencies remain stale after pip install: {' '.join(remaining)}") - return False - print("Dependencies installed.") - return True - except subprocess.CalledProcessError as e: - pip_error = e - - # Fallback: the interpreter has no pip (the Hermes Docker image's venv is - # built with `uv sync`, which does not bootstrap pip). `uv pip install - # --python ` installs into that exact interpreter without - # needing pip present. Targeting sys.executable keeps us on the venv the - # script is actually running under, rather than guessing. - uv = shutil.which("uv") - if uv: - try: - subprocess.check_call( - [uv, "pip", "install", "--python", sys.executable, "--quiet"] - + missing, - stdout=subprocess.DEVNULL, - ) - remaining = _missing_required_packages() - if remaining: - print(f"ERROR: Dependencies remain stale after uv install: {' '.join(remaining)}") - return False - print("Dependencies installed.") - return True - except subprocess.CalledProcessError as e: - print(f"ERROR: Failed to install dependencies via uv: {e}") - print(f"Manually: {uv} pip install --python {sys.executable} {' '.join(REQUIRED_PACKAGES)}") - return False - - print(f"ERROR: Failed to install dependencies: {pip_error}") - print( - "On environments without pip (e.g. Nix, or the Hermes Docker image's " - "uv-managed venv), install the optional extra instead:" - ) - print(" hermes setup") - print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}") - return False + pm.sync_venv(["google"], explicit=True) + except Exception as exc: + print(f"ERROR: Failed to install Google dependencies: {exc}") + return False + print("Google dependencies synced. Restart Hermes, then rerun setup to continue OAuth.") + return True def _ensure_deps(): - """Check exact dependency versions, install if stale, exit on failure.""" - if _missing_required_packages() and not install_deps(): + """Let PM check imports and stop if activation needs a new process.""" + if pm is None: + print("ERROR: Run this script in the Hermes environment; use hermes setup first.") + sys.exit(1) + try: + pm.ensure_import("google") + except Exception as exc: + print(f"ERROR: Google dependencies unavailable: {exc}") sys.exit(1) diff --git a/tests/skills/test_google_workspace_setup.py b/tests/skills/test_google_workspace_setup.py index 385a68fb6c..43ebf746d2 100644 --- a/tests/skills/test_google_workspace_setup.py +++ b/tests/skills/test_google_workspace_setup.py @@ -1,11 +1,12 @@ -"""Security-floor tests for the Google Workspace runtime installer.""" +"""Google Workspace setup delegates dependency ownership to PM.""" from __future__ import annotations import importlib.util -from importlib.metadata import PackageNotFoundError from pathlib import Path +from unittest.mock import Mock +import pm import pytest @@ -16,75 +17,39 @@ SETUP_PATH = ( @pytest.fixture() -def setup_module(): - spec = importlib.util.spec_from_file_location( - "test_google_workspace_setup_module", - SETUP_PATH, - ) +def setup_module(monkeypatch): + # setup.py exposes sibling imports for direct script execution. + monkeypatch.syspath_prepend(str(SETUP_PATH.parent)) + spec = importlib.util.spec_from_file_location("google_workspace_setup", SETUP_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def test_stale_google_transitives_are_reported_missing(setup_module, monkeypatch): - installed = { - "google-api-python-client": "2.194.0", - "google-auth": "2.55.0", - "google-auth-oauthlib": "1.3.1", - "google-auth-httplib2": "0.3.1", - "httplib2": "0.31.2", - "pyasn1": "0.6.3", - } +@pytest.mark.parametrize("error", [None, pm.InstallError("venv", "sync refused")]) +def test_explicit_install_uses_pm_and_reports_restart(setup_module, monkeypatch, capsys, error): + sync = Mock(side_effect=error) + monkeypatch.setattr(pm, "sync_venv", sync) + # Even a successful old-interpreter probe must not bypass explicit sync. + monkeypatch.setattr(pm, "ensure_import", Mock(side_effect=AssertionError("not a sync"))) + monkeypatch.setattr("subprocess.check_call", Mock(side_effect=AssertionError("ambient install"))) - def fake_version(name): - try: - return installed[name] - except KeyError: - raise PackageNotFoundError(name) from None - - monkeypatch.setattr(setup_module, "_distribution_version", fake_version) - - assert setup_module._missing_required_packages() == [ - "google-auth==2.55.1", - "httplib2==0.32.0", - "pyasn1==0.6.4", - ] + assert setup_module.install_deps() is (error is None) + sync.assert_called_once_with(["google"], explicit=True) + output = capsys.readouterr().out + if error is None: + assert "restart" in output.lower() + else: + assert "sync refused" in output -def test_installer_repairs_stale_transitives(setup_module, monkeypatch): - states = iter( - [ - [ - "google-auth==2.55.1", - "httplib2==0.32.0", - "pyasn1==0.6.4", - ], - [], - ] - ) - monkeypatch.setattr( - setup_module, - "_missing_required_packages", - lambda: next(states), - ) - calls = [] - monkeypatch.setattr( - setup_module.subprocess, - "check_call", - lambda argv, **kwargs: calls.append(argv), - ) +def test_auth_uses_pm_import_check(setup_module, monkeypatch): + ensure = Mock() + monkeypatch.setattr(pm, "ensure_import", ensure) + monkeypatch.setattr(pm, "sync_venv", Mock(side_effect=AssertionError("explicit sync during auth"))) + monkeypatch.setattr("subprocess.check_call", Mock(side_effect=AssertionError("ambient install"))) - assert setup_module.install_deps() is True - assert calls == [ - [ - setup_module.sys.executable, - "-m", - "pip", - "install", - "--quiet", - "google-auth==2.55.1", - "httplib2==0.32.0", - "pyasn1==0.6.4", - ] - ] + setup_module._ensure_deps() + + ensure.assert_called_once_with("google") diff --git a/tests/skills/test_google_workspace_setup_deps.py b/tests/skills/test_google_workspace_setup_deps.py index 306e33a077..803e540ac1 100644 --- a/tests/skills/test_google_workspace_setup_deps.py +++ b/tests/skills/test_google_workspace_setup_deps.py @@ -1,141 +1,65 @@ -"""Regression test: google-workspace setup.py REQUIRED_PACKAGES must pin httplib2. - -GHSA-j5g9-f88f-gfj3 (HIGH) — Decompression Bomb DoS via unbounded gzip/deflate -response handling. Fixed in httplib2 0.32.0. - -There are two install paths for google-workspace dependencies: - 1. pyproject.toml [project.optional-dependencies].google - 2. skills/productivity/google-workspace/scripts/setup.py REQUIRED_PACKAGES - -This test ensures path 2 stays pinned and consistent with path 1. -""" +"""OAuth must not run against an unavailable or newly selected environment.""" from __future__ import annotations -import ast +import importlib.util +import json +import os +import subprocess +import sys from pathlib import Path +from unittest.mock import Mock -REPO_ROOT = Path(__file__).resolve().parents[2] - -SETUP_PY = REPO_ROOT / "skills/productivity/google-workspace/scripts/setup.py" -PYPROJECT_TOML = REPO_ROOT / "pyproject.toml" - -# --------------------------------------------------------------------------- -# Static parsers -# --------------------------------------------------------------------------- - -_GOOGLE_EXTRA_KEY = "google" +import pm +import pytest -def _parse_setup_py_required_packages() -> list[str]: - """Parse setup.py and return the REQUIRED_PACKAGES list.""" - tree = ast.parse(SETUP_PY.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id == "REQUIRED_PACKAGES": - if isinstance(node.value, ast.List): - return [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant)] - raise AssertionError("REQUIRED_PACKAGES not found in setup.py") +SETUP_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/setup.py" +) -def _parse_pyproject_google_extra() -> list[str]: - """Parse pyproject.toml and return the google extra dependency list.""" - try: - import tomllib - except ImportError: - import tomli as tomllib # type: ignore[no-redef] - data = tomllib.loads(PYPROJECT_TOML.read_text(encoding="utf-8")) - optional_deps = data["project"]["optional-dependencies"] - return list(optional_deps[_GOOGLE_EXTRA_KEY]) +@pytest.mark.parametrize("command", ["--check", "--check-live", "--auth-url", "--auth-code", "--revoke"]) +def test_oauth_stops_at_pm_restart_boundary(command, monkeypatch, tmp_path, capsys): + monkeypatch.syspath_prepend(str(SETUP_PATH.parent)) + spec = importlib.util.spec_from_file_location("google_workspace_setup", SETUP_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for name in ("TOKEN_PATH", "CLIENT_SECRET_PATH", "PENDING_AUTH_PATH"): + path = tmp_path / f"{name}.json" + path.write_text(json.dumps({"state": "pending-state", "code_verifier": "verifier"})) + monkeypatch.setattr(module, name, path) + before = {path: path.read_bytes() for path in tmp_path.glob("*.json")} + ensure = Mock(side_effect=pm.InstallError("venv", "google installed; restart Hermes to activate")) + monkeypatch.setattr(pm, "ensure_import", ensure) + monkeypatch.setattr("subprocess.check_call", Mock(side_effect=AssertionError("ambient install"))) + monkeypatch.setattr(sys, "argv", [str(SETUP_PATH), command] + (["code"] if command == "--auth-code" else [])) + + with pytest.raises(SystemExit) as failure: + module.main() + + assert failure.value.code == 1 + ensure.assert_called_once_with("google") + assert "restart Hermes" in capsys.readouterr().out + assert {path: path.read_bytes() for path in tmp_path.glob("*.json")} == before -def _extract_pins(packages: list[str]) -> dict[str, str]: - """Extract pinned versions: {package_name: version} for entries with == pin.""" - pins: dict[str, str] = {} - for pkg in packages: - if "==" in pkg: - name, version = pkg.split("==", 1) - pins[name.strip()] = version.strip() - return pins - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestGoogleWorkspaceSetupDepsPins: - """Security pin consistency across all three google-workspace install paths.""" - - def test_setup_py_pins_httplib2(self): - """setup.py REQUIRED_PACKAGES must pin httplib2 at or above the GHSA fix version.""" - packages = _parse_setup_py_required_packages() - pins = _extract_pins(packages) - assert "httplib2" in pins, ( - f"httplib2 not found in setup.py REQUIRED_PACKAGES.\n" - f" Current entries: {packages}" - ) - # GHSA-j5g9-f88f-gfj3 is fixed in 0.32.0 — floor invariant, not a snapshot, - # so future bumps don't break this test. - pinned = tuple(int(part) for part in pins["httplib2"].split(".")) - assert pinned >= (0, 32, 0), ( - f"httplib2 pin {pins['httplib2']} in setup.py is below 0.32.0, the " - f"GHSA-j5g9-f88f-gfj3 fix version.\n" - f" Full REQUIRED_PACKAGES: {packages}" - ) - - def test_setup_py_pins_match_pyproject_toml(self): - """httplib2 pin in setup.py must match pyproject.toml google extra.""" - required_packages = _parse_setup_py_required_packages() - pyproject_packages = _parse_pyproject_google_extra() - - required_pins = _extract_pins(required_packages) - pyproject_pins = _extract_pins(pyproject_packages) - - for pkg in ("httplib2", "google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"): - setup_ver = required_pins.get(pkg) - toml_ver = pyproject_pins.get(pkg) - if setup_ver is None and toml_ver is None: - continue # neither path pins it, skip - assert toml_ver is not None, ( - f"{pkg} is pinned in setup.py ({setup_ver}) but NOT in pyproject.toml google extra.\n" - f" setup.py: {required_pins}\n" - f" pyproject.toml google: {pyproject_pins}" - ) - assert setup_ver is not None, ( - f"{pkg} is pinned in pyproject.toml ({toml_ver}) but NOT in setup.py.\n" - f" pyproject.toml google: {pyproject_pins}\n" - f" setup.py: {required_pins}" - ) - assert setup_ver == toml_ver, ( - f"{pkg} pin mismatch: setup.py has {setup_ver}, pyproject.toml has {toml_ver}.\n" - f" setup.py: {required_pins}\n" - f" pyproject.toml google: {pyproject_pins}" - ) - - def test_all_google_packages_are_pinned_in_all_paths(self): - """Every google workspace package that is version-pinned in any path must appear in both.""" - pyproject_packages = _parse_pyproject_google_extra() - setup_packages = _parse_setup_py_required_packages() - - all_pins: dict[str, set[str]] = {} - for label, pkgs in [ - ("pyproject.toml", pyproject_packages), - ("setup.py", setup_packages), - ]: - for pkg in pkgs: - if "==" in pkg: - name, ver = pkg.split("==", 1) - all_pins.setdefault(name.strip(), set()).add(f"{label}={ver.strip()}") - - for pkg, entries in sorted(all_pins.items()): - versions = {e.split("=", 1)[1] for e in entries} - assert len(versions) == 1, ( - f"{pkg} has inconsistent pins across install paths:\n" - + "\n".join(f" {e}" for e in sorted(entries)) - ) - assert len(entries) == 2, ( - f"{pkg} is not pinned in all install paths. Found {len(entries)}/2:\n" - + "\n".join(f" {e}" for e in sorted(entries)) - ) +@pytest.mark.parametrize("command", ["--install-deps", "--auth-url"]) +def test_standalone_without_hermes_reports_setup_not_ambient_installs(command, tmp_path): + # -I -S excludes both the checkout and installed site packages, just as a + # copied skill run with an unrelated interpreter has no Hermes PM module. + (tmp_path / "google_client_secret.json").write_text("{}") + result = subprocess.run( + [sys.executable, "-I", "-S", str(SETUP_PATH), command], + env={**os.environ, "HERMES_HOME": str(tmp_path), "PATH": ""}, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 1 + assert "Hermes environment" in result.stdout + assert "hermes setup" in result.stdout + assert "pip" not in result.stdout + result.stderr + assert "Traceback" not in result.stderr