fix(skills): route google workspace dependencies through pm

This commit is contained in:
ethernet
2026-09-11 18:20:23 -04:00
parent 6ee8610b67
commit 596f0216a5
4 changed files with 112 additions and 286 deletions

View File

@@ -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

View File

@@ -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 <interpreter>` 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)

View File

@@ -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")

View File

@@ -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