scripts/setup.py
scripts/setup.pyBrowse 7 files
4,612 tokens
20,231 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Google Workspace OAuth2 setup for Hermes Agent.3 4Fully non-interactive — designed to be driven by the agent via terminal commands.5The agent mediates between this script and the user (works on CLI, Telegram, Discord, etc.)6 7Commands:8 setup.py --check # Is auth valid? Exit 0 = yes, 1 = no9 setup.py --client-secret /path/to.json # Store OAuth client credentials10 setup.py --auth-url # Print the OAuth URL for user to visit11 setup.py --auth-code CODE # Exchange auth code for token12 setup.py --revoke # Revoke and delete stored token13 setup.py --install-deps # Install Python dependencies only14 15Agent workflow:16 1. Run --check. If exit 0, auth is good — skip setup.17 2. Ask user for client_secret.json path. Run --client-secret PATH.18 3. Run --auth-url. Send the printed URL to the user.19 4. User opens URL, authorizes, gets redirected to a page with a code.20 5. User pastes the code. Agent runs --auth-code CODE.21 6. Run --check to verify. Done.22"""23 24from __future__ import annotations # allow PEP 604 `X | None` on Python 3.9+25 26import argparse27import json28import os29import shutil30import subprocess31import sys32from importlib.metadata import version as _distribution_version33from pathlib import Path34 35# Ensure sibling modules (_hermes_home) are importable when run standalone.36_SCRIPTS_DIR = str(Path(__file__).resolve().parent)37if _SCRIPTS_DIR not in sys.path:38 sys.path.insert(0, _SCRIPTS_DIR)39 40from _hermes_home import display_hermes_home, get_hermes_home41 42HERMES_HOME = get_hermes_home()43TOKEN_PATH = HERMES_HOME / "google_token.json"44CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"45PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json"46 47SCOPES = [48 "https://www.googleapis.com/auth/gmail.readonly",49 "https://www.googleapis.com/auth/gmail.send",50 "https://www.googleapis.com/auth/gmail.modify",51 "https://www.googleapis.com/auth/calendar",52 "https://www.googleapis.com/auth/drive",53 "https://www.googleapis.com/auth/contacts.readonly",54 "https://www.googleapis.com/auth/spreadsheets",55 "https://www.googleapis.com/auth/documents",56]57 58# Exact pins: keep in sync with pyproject.toml [project.optional-dependencies].google59# and tools/lazy_deps.py LAZY_DEPS['skill.google_workspace'].60# Pinning all protects against version drift and ensures the security floors61# (httplib2 GHSA-j5g9-f88f-gfj3, stale pyasn1/google-auth) are honoured62# regardless of install path.63REQUIRED_PACKAGES = [64 "google-api-python-client==2.194.0",65 "google-auth==2.55.1",66 "google-auth-oauthlib==1.3.1",67 "google-auth-httplib2==0.3.1",68 # GHSA-j5g9-f88f-gfj3 — Decompression Bomb DoS via unbounded gzip/deflate69 "httplib2==0.32.0",70 "pyasn1==0.6.4",71]72 73# OAuth redirect for "out of band" manual code copy flow.74# Google deprecated OOB, so we use a localhost redirect and tell the user to75# copy the code from the browser's URL bar (or the page body).76REDIRECT_URI = "http://localhost:1"77 78 79def _normalize_authorized_user_payload(payload: dict) -> dict:80 normalized = dict(payload)81 if not normalized.get("type"):82 normalized["type"] = "authorized_user"83 return normalized84 85 86def _load_token_payload(path: Path = TOKEN_PATH) -> dict:87 try:88 return json.loads(path.read_text(encoding="utf-8"))89 except Exception:90 return {}91 92 93def _missing_scopes_from_payload(payload: dict) -> list[str]:94 raw = payload.get("scopes") or payload.get("scope")95 if not raw:96 return []97 granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}98 return sorted(scope for scope in SCOPES if scope not in granted)99 100 101def _format_missing_scopes(missing_scopes: list[str]) -> str:102 bullets = "\n".join(f" - {scope}" for scope in missing_scopes)103 return (104 "Token is valid but missing required Google Workspace scopes:\n"105 f"{bullets}\n"106 "Run the Google Workspace setup again from this same Hermes profile to refresh consent."107 )108 109 110def _missing_required_packages() -> list[str]:111 """Return exact requirements absent or stale in this interpreter.112 113 All REQUIRED_PACKAGES entries are exact ``name==version`` pins, so a114 direct version comparison is sufficient — no ``packaging`` dependency115 needed in this standalone script.116 """117 missing = []118 for spec in REQUIRED_PACKAGES:119 name, _, wanted = spec.partition("==")120 try:121 if _distribution_version(name) != wanted:122 missing.append(spec)123 except Exception:124 missing.append(spec)125 return missing126 127 128def install_deps():129 """Install missing or stale Google API packages. Returns True on success."""130 missing = _missing_required_packages()131 if not missing:132 print("Dependencies already installed.")133 return True134 135 print("Installing Google API dependencies...")136 137 # First choice: pip in the current interpreter. Works for most installs.138 try:139 subprocess.check_call(140 [sys.executable, "-m", "pip", "install", "--quiet"] + missing,141 stdout=subprocess.DEVNULL,142 )143 remaining = _missing_required_packages()144 if remaining:145 print(f"ERROR: Dependencies remain stale after pip install: {' '.join(remaining)}")146 return False147 print("Dependencies installed.")148 return True149 except subprocess.CalledProcessError as e:150 pip_error = e151 152 # Fallback: the interpreter has no pip (the Hermes Docker image's venv is153 # built with `uv sync`, which does not bootstrap pip). `uv pip install154 # --python <interpreter>` installs into that exact interpreter without155 # needing pip present. Targeting sys.executable keeps us on the venv the156 # script is actually running under, rather than guessing.157 uv = shutil.which("uv")158 if uv:159 try:160 subprocess.check_call(161 [uv, "pip", "install", "--python", sys.executable, "--quiet"]162 + missing,163 stdout=subprocess.DEVNULL,164 )165 remaining = _missing_required_packages()166 if remaining:167 print(f"ERROR: Dependencies remain stale after uv install: {' '.join(remaining)}")168 return False169 print("Dependencies installed.")170 return True171 except subprocess.CalledProcessError as e:172 print(f"ERROR: Failed to install dependencies via uv: {e}")173 print(f"Manually: {uv} pip install --python {sys.executable} {' '.join(REQUIRED_PACKAGES)}")174 return False175 176 print(f"ERROR: Failed to install dependencies: {pip_error}")177 print(178 "On environments without pip (e.g. Nix, or the Hermes Docker image's "179 "uv-managed venv), install the optional extra instead:"180 )181 print(" hermes setup")182 print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")183 return False184 185 186def _ensure_deps():187 """Check exact dependency versions, install if stale, exit on failure."""188 if _missing_required_packages() and not install_deps():189 sys.exit(1)190 191 192def check_auth_live():193 """Check auth with a real API call to detect disabled_client/account issues."""194 # quiet=True suppresses the "AUTHENTICATED" print from check_auth so the195 # final status line reflects the live-call outcome (OK or FAILED).196 if not check_auth(quiet=True):197 return False198 try:199 from googleapiclient.discovery import build200 from google.oauth2.credentials import Credentials201 creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))202 service = build("calendar", "v3", credentials=creds)203 service.calendarList().list(maxResults=1).execute()204 print("LIVE_CHECK_OK: Real API call succeeded.")205 return True206 except Exception as e:207 err_str = str(e).lower()208 if "disabled_client" in err_str or "invalid_client" in err_str:209 print(f"LIVE_CHECK_FAILED: OAuth client or account disabled: {e}")210 print(" 1. Check Google Cloud Console for disabled OAuth client")211 print(" 2. Check myaccount.google.com for account status")212 print(" 3. Do NOT retry with a disabled account")213 else:214 print(f"LIVE_CHECK_FAILED: {e}")215 return False216 217 218def check_auth(quiet: bool = False):219 """Check if stored credentials are valid. Prints status, exits 0 or 1."""220 if not TOKEN_PATH.exists():221 print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}")222 return False223 224 _ensure_deps()225 from google.oauth2.credentials import Credentials226 from google.auth.transport.requests import Request227 228 try:229 # Don't pass scopes — user may have authorized only a subset.230 # Passing scopes forces google-auth to validate them on refresh,231 # which fails with invalid_scope if the token has fewer scopes232 # than requested.233 creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))234 except Exception as e:235 print(f"TOKEN_CORRUPT: {e}")236 return False237 238 payload = _load_token_payload(TOKEN_PATH)239 if creds.valid:240 missing_scopes = _missing_scopes_from_payload(payload)241 if missing_scopes:242 print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:")243 for s in missing_scopes:244 print(f" - {s}")245 if not quiet:246 print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")247 return True248 249 if creds.expired and creds.refresh_token:250 try:251 creds.refresh(Request())252 TOKEN_PATH.write_text(253 json.dumps(254 _normalize_authorized_user_payload(json.loads(creds.to_json())),255 indent=2,256 ), encoding="utf-8"257 )258 missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))259 if missing_scopes:260 print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:")261 for s in missing_scopes:262 print(f" - {s}")263 if not quiet:264 print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")265 return True266 except Exception as e:267 err_str = str(e).lower()268 if "disabled_client" in err_str or "invalid_client" in err_str:269 print(f"OAUTH_CLIENT_DISABLED: {e}")270 print(" The OAuth client or Google account has been disabled.")271 print(" Steps to resolve:")272 print(" 1. Check your Google Cloud Console — verify the OAuth client is not disabled")273 print(" 2. Check if your Google account itself has been disabled at myaccount.google.com")274 print(" 3. If the account is disabled, you can appeal at accounts.google.com/signin/recovery")275 print(" 4. Do NOT retry API calls with a disabled account — this may worsen the situation")276 print(" 5. If the OAuth client is disabled, create a new one in Google Cloud Console")277 elif "token_revoked" in err_str or "invalid_grant" in err_str:278 print(f"TOKEN_REVOKED: {e}")279 print(" Re-run setup to re-authenticate.")280 else:281 print(f"REFRESH_FAILED: {e}")282 return False283 284 print("TOKEN_INVALID: Re-run setup.")285 return False286 287 288def store_client_secret(path: str):289 """Copy and validate client_secret.json to Hermes home."""290 src = Path(path).expanduser().resolve()291 if not src.exists():292 print(f"ERROR: File not found: {src}")293 sys.exit(1)294 295 try:296 data = json.loads(src.read_text(encoding="utf-8"))297 except json.JSONDecodeError:298 print("ERROR: File is not valid JSON.")299 sys.exit(1)300 301 if "installed" not in data and "web" not in data:302 print("ERROR: Not a Google OAuth client secret file (missing 'installed' key).")303 print("Download the correct file from: https://console.cloud.google.com/apis/credentials")304 sys.exit(1)305 306 CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")307 print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}")308 309 310def _save_pending_auth(*, state: str, code_verifier: str):311 """Persist the OAuth session bits needed for a later token exchange."""312 PENDING_AUTH_PATH.write_text(313 json.dumps(314 {315 "state": state,316 "code_verifier": code_verifier,317 "redirect_uri": REDIRECT_URI,318 },319 indent=2,320 ), encoding="utf-8"321 )322 323 324def _load_pending_auth() -> dict:325 """Load the pending OAuth session created by get_auth_url()."""326 if not PENDING_AUTH_PATH.exists():327 print("ERROR: No pending OAuth session found. Run --auth-url first.")328 sys.exit(1)329 330 try:331 data = json.loads(PENDING_AUTH_PATH.read_text(encoding="utf-8"))332 except Exception as e:333 print(f"ERROR: Could not read pending OAuth session: {e}")334 print("Run --auth-url again to start a fresh OAuth session.")335 sys.exit(1)336 337 if not data.get("state") or not data.get("code_verifier"):338 print("ERROR: Pending OAuth session is missing PKCE data.")339 print("Run --auth-url again to start a fresh OAuth session.")340 sys.exit(1)341 342 return data343 344 345def _extract_code_and_state(code_or_url: str) -> tuple[str, str | None]:346 """Accept either a raw auth code or the full redirect URL pasted by the user."""347 if not code_or_url.startswith("http"):348 return code_or_url, None349 350 from urllib.parse import parse_qs, urlparse351 352 parsed = urlparse(code_or_url)353 params = parse_qs(parsed.query)354 if "code" not in params:355 print("ERROR: No 'code' parameter found in URL.")356 sys.exit(1)357 358 state = params.get("state", [None])[0]359 return params["code"][0], state360 361 362def get_auth_url():363 """Print the OAuth authorization URL. User visits this in a browser."""364 if not CLIENT_SECRET_PATH.exists():365 print("ERROR: No client secret stored. Run --client-secret first.")366 sys.exit(1)367 368 _ensure_deps()369 from google_auth_oauthlib.flow import Flow370 371 flow = Flow.from_client_secrets_file(372 str(CLIENT_SECRET_PATH),373 scopes=SCOPES,374 redirect_uri=REDIRECT_URI,375 autogenerate_code_verifier=True,376 )377 auth_url, state = flow.authorization_url(378 access_type="offline",379 prompt="consent",380 )381 _save_pending_auth(state=state, code_verifier=flow.code_verifier)382 # Print just the URL so the agent can extract it cleanly383 print(auth_url)384 385 386def exchange_auth_code(code: str):387 """Exchange the authorization code for a token and save it."""388 if not CLIENT_SECRET_PATH.exists():389 print("ERROR: No client secret stored. Run --client-secret first.")390 sys.exit(1)391 392 pending_auth = _load_pending_auth()393 raw_callback = code394 code, returned_state = _extract_code_and_state(code)395 if returned_state and returned_state != pending_auth["state"]:396 print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.")397 sys.exit(1)398 399 _ensure_deps()400 from google_auth_oauthlib.flow import Flow401 from urllib.parse import parse_qs, urlparse402 403 # Extract granted scopes from the callback URL if the user pasted the full redirect URL.404 granted_scopes = list(SCOPES)405 if isinstance(raw_callback, str) and raw_callback.startswith("http"):406 params = parse_qs(urlparse(raw_callback).query)407 scope_val = (params.get("scope") or [""])[0].strip()408 if scope_val:409 granted_scopes = scope_val.split()410 411 flow = Flow.from_client_secrets_file(412 str(CLIENT_SECRET_PATH),413 scopes=granted_scopes,414 redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI),415 state=pending_auth["state"],416 code_verifier=pending_auth["code_verifier"],417 )418 419 try:420 # Accept partial scopes — user may deselect some permissions in the consent screen421 os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"422 flow.fetch_token(code=code)423 except Exception as e:424 print(f"ERROR: Token exchange failed: {e}")425 print("The code may have expired. Run --auth-url to get a fresh URL.")426 sys.exit(1)427 428 creds = flow.credentials429 token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))430 431 # Store only the scopes actually granted by the user, not what was requested.432 # creds.to_json() writes the requested scopes, which causes refresh to fail433 # with invalid_scope if the user only authorized a subset.434 actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else []435 if actually_granted:436 token_payload["scopes"] = actually_granted437 elif granted_scopes != SCOPES:438 # granted_scopes was extracted from the callback URL439 token_payload["scopes"] = granted_scopes440 441 missing_scopes = _missing_scopes_from_payload(token_payload)442 if missing_scopes:443 print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")444 print("Some services may not be available.")445 446 TOKEN_PATH.write_text(json.dumps(token_payload, indent=2), encoding="utf-8")447 PENDING_AUTH_PATH.unlink(missing_ok=True)448 print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")449 print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")450 451 452def revoke():453 """Revoke stored token and delete it."""454 if not TOKEN_PATH.exists():455 print("No token to revoke.")456 return457 458 _ensure_deps()459 from google.oauth2.credentials import Credentials460 from google.auth.transport.requests import Request461 462 try:463 creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)464 if creds.expired and creds.refresh_token:465 creds.refresh(Request())466 467 import urllib.request468 urllib.request.urlopen(469 urllib.request.Request(470 f"https://oauth2.googleapis.com/revoke?token={creds.token}",471 method="POST",472 headers={"Content-Type": "application/x-www-form-urlencoded"},473 ),474 timeout=15,475 )476 print("Token revoked with Google.")477 except Exception as e:478 print(f"Remote revocation failed (token may already be invalid): {e}")479 480 TOKEN_PATH.unlink(missing_ok=True)481 PENDING_AUTH_PATH.unlink(missing_ok=True)482 print(f"Deleted {TOKEN_PATH}")483 484 485def main():486 parser = argparse.ArgumentParser(description="Google Workspace OAuth setup for Hermes")487 group = parser.add_mutually_exclusive_group(required=True)488 group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)")489 group.add_argument("--check-live", action="store_true", help="Check auth with a real API call (detects disabled_client)")490 group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json")491 group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit")492 group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token")493 group.add_argument("--revoke", action="store_true", help="Revoke and delete stored token")494 group.add_argument("--install-deps", action="store_true", help="Install Python dependencies")495 args = parser.parse_args()496 497 if args.check:498 sys.exit(0 if check_auth() else 1)499 if getattr(args, "check_live", False):500 sys.exit(0 if check_auth_live() else 1)501 elif args.client_secret:502 store_client_secret(args.client_secret)503 elif args.auth_url:504 get_auth_url()505 elif args.auth_code:506 exchange_auth_code(args.auth_code)507 elif args.revoke:508 revoke()509 elif args.install_deps:510 sys.exit(0 if install_deps() else 1)511 512 513if __name__ == "__main__":514 main()515