garc-gws-agent-runtime/scripts/garc_core.py
林 駿甫 (Shunsuke Hayashi) 7b5951a1d5 fix: resolve all 17 playbook findings (P0–P3)
P0 fixes:
- agent register: upsert by agent_id (no duplicate rows)
- daemon poll-once: extract _gmail_poll_cycle, run synchronously
- garc_core.py: suppress urllib3/googleapiclient DeprecationWarnings

P1 fixes:
- OAuth: detect RefreshError → delete stale token → re-auth flow
- OAuth: scope coverage check before returning valid creds
- ingress: add stale-reset subcommand (reset in_progress > N min)
- sheets: trim-sheet / clean-all — deleteDimension for empty rows
- approval gate: send Gmail notification to GARC_APPROVAL_EMAIL

P2 additions:
- Google Chat: garc-chat-helper.py + garc send chat subcommands
- Service Account: garc auth service-account verify + DWD support
- Audit log: Sheets audit tab + garc audit list + bin/garc async hook
- garc auth revoke: POST /revoke + delete token file
- kg: pagination fix, shell injection fix, garc-kg-query.py
- docs: _doc_insert_text / append_doc / garc drive append-doc

P3 additions:
- Multi-tenant: lib/profile.sh (list/use/add/show/remove/current)
  bin/garc: auto-load profile config.env and token.json
- Google Forms pipeline: garc-forms-helper.py + lib/forms.sh
  garc forms list/responses/watch
- systemd: _daemon_install_service OS-detect → launchd or systemd units
- Python version gate (>=3.10) in bin/garc + pyproject.toml
- garc doctor command for environment diagnostics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:55:33 +09:00

241 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
GARC Core — Shared utilities: auth, service builders, retry, output formatting
"""
import json
import os
import sys
import time
import warnings
import functools
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Any
# Suppress noisy deprecation warnings from urllib3 / requests bundled
# inside google-auth and google-api-python-client on Python 3.12+
warnings.filterwarnings("ignore", message=".*urllib3.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*ssl.wrap_socket.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*imp module.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning, module="googleapiclient")
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
SERVICE_ACCOUNT_FILE = Path(os.environ.get("GARC_SERVICE_ACCOUNT_FILE", GARC_CONFIG_DIR / "service_account.json"))
# All supported scopes for backoffice_agent profile
ALL_SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/directory.readonly",
"https://www.googleapis.com/auth/chat.messages",
]
PROFILE_SCOPES = {
"readonly": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/tasks.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/documents",
],
"writer": [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
],
"backoffice_agent": ALL_SCOPES,
"admin": ALL_SCOPES + [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
],
}
def get_credentials(scopes: Optional[list] = None, use_service_account: bool = False):
"""
Get valid Google credentials.
Tries: service account → existing token → OAuth flow
"""
if scopes is None:
scopes = ALL_SCOPES
# Service account path
if use_service_account and SERVICE_ACCOUNT_FILE.exists():
try:
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file(
str(SERVICE_ACCOUNT_FILE), scopes=scopes
)
return creds
except Exception as e:
print(f"⚠️ Service account error: {e}", file=sys.stderr)
# User OAuth token
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
creds = None
if TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
if creds and creds.valid:
# Verify the token covers the requested scopes
if _scopes_covered(creds, scopes):
return creds
# Scope mismatch — need re-auth
print("⚠️ Token scopes insufficient for requested operation.", file=sys.stderr)
print(" Re-authenticating to add required scopes...", file=sys.stderr)
creds = None
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_save_token(creds)
return creds
except RefreshError as e:
# Token revoked or expired beyond refresh — delete and re-auth
print(f"⚠️ Token refresh failed (revoked or expired): {e}", file=sys.stderr)
print(" Deleting stale token. You will be prompted to log in again.", file=sys.stderr)
TOKEN_FILE.unlink(missing_ok=True)
creds = None
except Exception as e:
print(f"⚠️ Token refresh error: {e}", file=sys.stderr)
TOKEN_FILE.unlink(missing_ok=True)
creds = None
# Need fresh OAuth flow
if not CREDENTIALS_FILE.exists():
print(f"❌ credentials.json not found: {CREDENTIALS_FILE}", file=sys.stderr)
print(" Download from Google Cloud Console → APIs & Services → Credentials", file=sys.stderr)
print(" Run: garc auth login --profile backoffice_agent", file=sys.stderr)
sys.exit(1)
from google_auth_oauthlib.flow import InstalledAppFlow
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0, open_browser=True)
_save_token(creds)
return creds
except ImportError as e:
print(f"❌ Missing dependency: {e}", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def _scopes_covered(creds, requested_scopes: list) -> bool:
"""Return True if the credential's granted scopes cover all requested scopes."""
if not requested_scopes:
return True
granted = set(getattr(creds, "scopes", None) or [])
if not granted:
# Token file may not carry scope info — assume OK to avoid spurious re-auth
return True
return all(s in granted for s in requested_scopes)
def _save_token(creds):
"""Save credentials to token file."""
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
TOKEN_FILE.chmod(0o600)
def build_service(service_name: str, version: str, scopes: Optional[list] = None):
"""Build a Google API service with proper credentials."""
try:
from googleapiclient.discovery import build
creds = get_credentials(scopes)
return build(service_name, version, credentials=creds, cache_discovery=False)
except ImportError:
print("❌ google-api-python-client not installed", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def with_retry(max_retries: int = 3, backoff: float = 1.5):
"""Decorator: retry on transient Google API errors with exponential backoff."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
error_str = str(e).lower()
# Retry on rate limit, server error
if any(code in error_str for code in ["429", "500", "503", "quota", "rate"]):
wait = backoff ** attempt
if attempt < max_retries - 1:
print(f" ⏳ Rate limit hit, waiting {wait:.1f}s...", file=sys.stderr)
time.sleep(wait)
last_error = e
continue
raise
raise last_error
return wrapper
return decorator
def utc_now() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def format_table(rows: list[dict], columns: list[str], max_width: int = 120) -> str:
"""Format a list of dicts as a simple table."""
if not rows:
return "(no results)"
# Calculate column widths
widths = {col: len(col) for col in columns}
for row in rows:
for col in columns:
val = str(row.get(col, ""))
widths[col] = min(max(widths[col], len(val)), 40)
header = " ".join(col.ljust(widths[col]) for col in columns)
sep = " ".join("" * widths[col] for col in columns)
lines = [header, sep]
for row in rows:
line = " ".join(str(row.get(col, "")).ljust(widths[col])[:widths[col]] for col in columns)
lines.append(line)
return "\n".join(lines)
def load_config() -> dict:
"""Load GARC config from environment / config.env file."""
config_file = GARC_CONFIG_DIR / "config.env"
config = {}
if config_file.exists():
with open(config_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
config[key.strip()] = val.strip()
# Override with env vars
for key in ["GARC_DRIVE_FOLDER_ID", "GARC_SHEETS_ID", "GARC_GMAIL_DEFAULT_TO",
"GARC_CALENDAR_ID", "GARC_CHAT_SPACE_ID", "GARC_DEFAULT_AGENT"]:
if os.environ.get(key):
config[key] = os.environ[key]
return config