fix(skills): route google workspace dependencies through pm
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user