mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
690 lines
26 KiB
Python
690 lines
26 KiB
Python
"""OpenWorker Cloud client: sign-in and managed one-click connectors.
|
|
|
|
Everything here is OPTIONAL. The app is fully functional signed out — manual
|
|
token paste stays available for every connector (and remains available after
|
|
sign-in too). Cloud sign-in only unlocks the one-click managed OAuth path and
|
|
the metadata conveniences that come with it.
|
|
|
|
Flows (ported from the proven `ocw_cli` reference in opencoworker-cloud):
|
|
|
|
- Sign-in: Auth0 Authorization Code + PKCE. The sidecar generates the PKCE
|
|
pair, the browser signs in, Auth0 redirects to the sidecar's loopback
|
|
`GET /auth/callback`, and the code is exchanged here. Cloud session tokens
|
|
live in the SecretStore under `cloud:auth`.
|
|
- Managed connect: authenticated `POST /v1/oauth/{provider}/start` returns the
|
|
provider authorize URL; the broker's callback page form-POSTs the token
|
|
payload to the sidecar's loopback `POST /oauth/callback`; the profile is
|
|
written locally. Connector tokens never touch cloud storage.
|
|
- Refresh: managed profiles (they have refresh_token + connection_id) renew
|
|
through the broker just before expiry; manual profiles are never touched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
import secrets as _secrets
|
|
import time
|
|
import urllib.parse
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from .config import Config
|
|
from .secrets import SecretStore
|
|
|
|
CLOUD_AUTH_PROFILE = "cloud:auth"
|
|
LOGIN_SCOPES = "openid profile email offline_access"
|
|
|
|
from . import __version__ as APP_VERSION # noqa: E402
|
|
|
|
# connector id (canonical, = descriptor name) -> broker provider key
|
|
PROVIDER_FOR_CONNECTOR = {
|
|
"gmail": "google",
|
|
"google_calendar": "google",
|
|
"google_drive": "google",
|
|
"slack": "slack",
|
|
"notion": "notion",
|
|
"attio": "attio",
|
|
"hubspot": "hubspot",
|
|
"github": "github",
|
|
"outlook": "microsoft",
|
|
}
|
|
|
|
# Pending PKCE verifiers keyed by OAuth state; in-process only. A login that
|
|
# outlives the sidecar process simply has to be restarted.
|
|
_pending_logins: dict[str, dict[str, float | str]] = {}
|
|
_PENDING_TTL = 600
|
|
_pending_managed_states: dict[str, float] = {}
|
|
_MANAGED_STATE_TTL = 600
|
|
|
|
|
|
def _b64url(raw: bytes) -> str:
|
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
|
|
|
|
def _now() -> float:
|
|
return time.time()
|
|
|
|
|
|
# --- sign-in -----------------------------------------------------------------
|
|
|
|
|
|
def begin_login(config: Config) -> dict[str, Any]:
|
|
"""Create a PKCE login and return the browser URL. The sidecar's
|
|
GET /auth/callback completes it.
|
|
|
|
The redirect goes through the BROKER's stable callback, which bounces the
|
|
browser to our actual loopback port (carried as state's `.port` suffix —
|
|
Auth0 echoes state untouched). Direct loopback redirects can't work in the
|
|
packaged app: Auth0's allow-list rejects unregistered ports, and the
|
|
desktop shell binds the sidecar to a RANDOM free port. This shipped once
|
|
as "Firefox can't connect to 127.0.0.1:8765" right after Auth0 finished.
|
|
"""
|
|
verifier = _b64url(_secrets.token_bytes(48))
|
|
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
|
|
port = os.environ.get("COWORKER_PORT") or config.port
|
|
state = f"{_secrets.token_urlsafe(16)}.{port}"
|
|
|
|
for key, pending in list(_pending_logins.items()): # expire stale attempts
|
|
if float(pending["created"]) < _now() - _PENDING_TTL:
|
|
_pending_logins.pop(key, None)
|
|
_pending_logins[state] = {"verifier": verifier, "created": _now()}
|
|
|
|
redirect_uri = config.cloud_base_url.rstrip("/") + "/v1/auth/callback"
|
|
authorize_url = (
|
|
f"https://{config.cloud_auth_domain}/authorize?"
|
|
+ urllib.parse.urlencode(
|
|
{
|
|
"response_type": "code",
|
|
"client_id": config.cloud_client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": LOGIN_SCOPES,
|
|
"audience": config.cloud_audience,
|
|
"state": state,
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
)
|
|
)
|
|
return {"authorize_url": authorize_url, "state": state}
|
|
|
|
|
|
def complete_login(
|
|
secrets: SecretStore, config: Config, code: str, state: str
|
|
) -> dict[str, Any]:
|
|
pending = _pending_logins.pop(state, None)
|
|
if pending is None or float(pending["created"]) < _now() - _PENDING_TTL:
|
|
return {"ok": False, "error": "unknown or expired sign-in attempt"}
|
|
|
|
resp = httpx.post(
|
|
f"https://{config.cloud_auth_domain}/oauth/token",
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"client_id": config.cloud_client_id,
|
|
"code": code,
|
|
"code_verifier": pending["verifier"],
|
|
# MUST byte-match begin_login's authorize redirect_uri (RFC 6749 §4.1.3) — the
|
|
# broker bounce, not the loopback. The bounce change (eda23c9) updated only the
|
|
# authorize leg; the stale loopback here made Auth0 reject every exchange
|
|
# ("token exchange failed" on all sign-ins from 07-09 to 07-11).
|
|
"redirect_uri": config.cloud_base_url.rstrip("/") + "/v1/auth/callback",
|
|
},
|
|
timeout=15,
|
|
)
|
|
if resp.status_code != 200:
|
|
return {"ok": False, "error": "token exchange failed"}
|
|
_store_cloud_tokens(secrets, resp.json())
|
|
|
|
# Best-effort profile fetch so the GUI can show who is signed in.
|
|
me = fetch_me(secrets, config)
|
|
if me:
|
|
profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
|
|
profile["account"] = me.get("user", {}).get("email") or ""
|
|
profile["user_id"] = me.get("user", {}).get("user_id") or ""
|
|
secrets.put(CLOUD_AUTH_PROFILE, profile)
|
|
# Connection restore (sync_connections) deliberately does NOT run here: it is
|
|
# best-effort metadata work, and doing it inline held the browser's "Signed in"
|
|
# page + the GUI's signed-in flip hostage to an extra broker round trip (slow
|
|
# sign-in complaint, 2026-07-16). The /auth/callback route kicks it off in the
|
|
# background after responding.
|
|
return {"ok": True, **status(secrets)}
|
|
|
|
|
|
def sync_connections(secrets: SecretStore, config: Config) -> dict[str, Any]:
|
|
"""Rebuild local managed-connection state from the broker's metadata rows
|
|
(GET /v1/connections) after a cloud sign-in.
|
|
|
|
Only GitHub restores fully on a fresh install: its rows are routing metadata
|
|
(installation ids + logins) and installation tokens mint on demand — nothing
|
|
secret ever needs to live here. Every other connector's tokens are local-only
|
|
by design, so those need a one-click re-consent instead."""
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return {"ok": False, "error": "not signed in"}
|
|
try:
|
|
resp = httpx.get(
|
|
config.cloud_base_url.rstrip("/") + "/v1/connections",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=15,
|
|
)
|
|
except httpx.HTTPError:
|
|
return {"ok": False, "error": "cloud unreachable"}
|
|
if resp.status_code != 200:
|
|
return {"ok": False, "error": f"connections fetch failed ({resp.status_code})"}
|
|
|
|
from .connectors.github_installs import managed_connect_install
|
|
|
|
restored: list[str] = []
|
|
for row in resp.json().get("connections", []):
|
|
if row.get("connector") != "github" or row.get("status") != "connected":
|
|
continue
|
|
meta = row.get("tenant_metadata") or {}
|
|
installs = meta.get("installations") or []
|
|
if not installs and meta.get("installation_id"):
|
|
installs = [meta] # pre-restore-era rows carry only the primary install
|
|
for inst in installs:
|
|
out = managed_connect_install(
|
|
secrets,
|
|
{
|
|
"installation_id": str(inst.get("installation_id") or ""),
|
|
"account_login": inst.get("account_login", ""),
|
|
"account_type": inst.get("account_type", ""),
|
|
"repo_selection": inst.get("repo_selection", ""),
|
|
"github_login": meta.get("github_login", ""),
|
|
"connection_id": row.get("connection_id", ""),
|
|
},
|
|
)
|
|
if out.get("ok"):
|
|
restored.append(out["installation_id"])
|
|
return {"ok": True, "restored": restored}
|
|
|
|
|
|
def _store_cloud_tokens(secrets: SecretStore, token: dict) -> None:
|
|
profile = secrets.get(CLOUD_AUTH_PROFILE) or {"type": "oauth", "enabled": True}
|
|
profile["access_token"] = token.get("access_token", "")
|
|
if token.get("refresh_token"): # rotating refresh tokens: keep the newest
|
|
profile["refresh_token"] = token["refresh_token"]
|
|
profile["expires"] = _now() + int(token.get("expires_in") or 3600) - 60
|
|
secrets.put(CLOUD_AUTH_PROFILE, profile)
|
|
|
|
|
|
def status(secrets: SecretStore) -> dict[str, Any]:
|
|
profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
|
|
return {
|
|
"signed_in": bool(profile.get("access_token")),
|
|
"account": profile.get("account") or "",
|
|
"user_id": profile.get("user_id") or "",
|
|
}
|
|
|
|
|
|
def logout(secrets: SecretStore) -> dict[str, Any]:
|
|
secrets.delete(CLOUD_AUTH_PROFILE)
|
|
return {"ok": True, "signed_in": False}
|
|
|
|
|
|
def fresh_access_token(secrets: SecretStore, config: Config) -> Optional[str]:
|
|
"""Valid cloud session token, silently refreshed near expiry; None when
|
|
signed out or the session can't be renewed (GUI shows "sign in again")."""
|
|
profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
|
|
if not profile.get("access_token"):
|
|
return None
|
|
if float(profile.get("expires") or 0) > _now():
|
|
return profile["access_token"]
|
|
if not profile.get("refresh_token"):
|
|
return None
|
|
resp = httpx.post(
|
|
f"https://{config.cloud_auth_domain}/oauth/token",
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"client_id": config.cloud_client_id,
|
|
"refresh_token": profile["refresh_token"],
|
|
},
|
|
timeout=15,
|
|
)
|
|
if resp.status_code != 200:
|
|
return None
|
|
_store_cloud_tokens(secrets, resp.json())
|
|
return (secrets.get(CLOUD_AUTH_PROFILE) or {}).get("access_token")
|
|
|
|
|
|
def fetch_me(secrets: SecretStore, config: Config) -> Optional[dict]:
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return None
|
|
try:
|
|
resp = httpx.get(
|
|
config.cloud_base_url.rstrip("/") + "/v1/me",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=15,
|
|
)
|
|
except httpx.HTTPError:
|
|
return None
|
|
return resp.json() if resp.status_code == 200 else None
|
|
|
|
|
|
# --- telemetry (Phase 5) ---------------------------------------------------------
|
|
# One sentence: which coworker type was started and when — nothing else. Signed-in
|
|
# users only, default-on with an opt-out; signed out (or opted out) sends NOTHING.
|
|
# Never sent: titles, prompts, outputs, tool args, file paths, connector content.
|
|
|
|
TELEMETRY_PROFILE = "cloud:telemetry"
|
|
|
|
|
|
def install_id(secrets: SecretStore) -> str:
|
|
"""Stable random per-install id, minted on first use (spec Phase 5)."""
|
|
profile = secrets.get(TELEMETRY_PROFILE) or {}
|
|
if not profile.get("install_id"):
|
|
profile["install_id"] = "ins_" + _secrets.token_hex(12)
|
|
secrets.put(TELEMETRY_PROFILE, profile)
|
|
return profile["install_id"]
|
|
|
|
|
|
def telemetry_enabled(secrets: SecretStore) -> bool:
|
|
profile = secrets.get(TELEMETRY_PROFILE) or {}
|
|
return bool(profile.get("enabled", True)) # default-on (only matters signed in)
|
|
|
|
|
|
def set_telemetry_enabled(secrets: SecretStore, enabled: bool) -> dict[str, Any]:
|
|
profile = secrets.get(TELEMETRY_PROFILE) or {}
|
|
profile["enabled"] = bool(enabled)
|
|
secrets.put(TELEMETRY_PROFILE, profile)
|
|
return {"ok": True, "telemetry_enabled": bool(enabled)}
|
|
|
|
|
|
def emit_session_created(
|
|
secrets: SecretStore,
|
|
config: Config,
|
|
*,
|
|
session_id: str,
|
|
persona_id: str,
|
|
persona_family: str,
|
|
workspace_kind: str,
|
|
) -> bool:
|
|
"""Best-effort, content-free session event. Hard no-op unless signed in AND
|
|
the toggle is on; failures are swallowed (telemetry must never break a session)."""
|
|
import platform as _platform
|
|
import sys
|
|
|
|
if not telemetry_enabled(secrets):
|
|
return False
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return False # signed out: local-only users send nothing, by design
|
|
body = {
|
|
"event": "coworker_session_created",
|
|
"install_id": install_id(secrets),
|
|
"app_version": APP_VERSION,
|
|
"platform": {"darwin": "macos", "win32": "windows"}.get(
|
|
sys.platform, _platform.system().lower() or "unknown"
|
|
),
|
|
"session": {
|
|
"session_id_hash": "sha256:"
|
|
+ hashlib.sha256(session_id.encode()).hexdigest(),
|
|
"persona_id": persona_id,
|
|
"persona_family": persona_family,
|
|
"workspace_kind": workspace_kind,
|
|
},
|
|
}
|
|
try:
|
|
resp = httpx.post(
|
|
config.cloud_base_url.rstrip("/") + "/v1/telemetry/events",
|
|
json=body,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
return resp.status_code == 200
|
|
except httpx.HTTPError:
|
|
return False
|
|
|
|
|
|
# --- managed connectors --------------------------------------------------------
|
|
|
|
|
|
def begin_managed_connect(
|
|
secrets: SecretStore,
|
|
config: Config,
|
|
connector: str,
|
|
*,
|
|
access: str = "",
|
|
flow: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Authenticated start: returns the provider consent URL for the browser.
|
|
Requires sign-in — the manual token path stays available regardless.
|
|
`access` names a broker-defined consent tier (hubspot read | write); the
|
|
desktop never sends scopes. `flow` is GitHub-only: "" = the App install
|
|
page; "authorize" links a teammate to an existing installation."""
|
|
provider = PROVIDER_FOR_CONNECTOR.get(connector)
|
|
if provider is None:
|
|
return {"ok": False, "error": f"{connector} has no managed OAuth path"}
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return {"ok": False, "error": "not signed in", "signed_in": False}
|
|
|
|
app_state = _secrets.token_urlsafe(16)
|
|
# The broker form-POSTs the tokens back to THIS process's loopback. Use the
|
|
# actually-bound port (published by run.py), falling back to config.port —
|
|
# the packaged app runs the sidecar on a random port, not 8765.
|
|
port = os.environ.get("COWORKER_PORT") or config.port
|
|
try:
|
|
resp = httpx.post(
|
|
config.cloud_base_url.rstrip("/") + f"/v1/oauth/{provider}/start",
|
|
json={
|
|
"connector": connector,
|
|
"redirect": f"http://127.0.0.1:{port}/oauth/callback",
|
|
"app_state": app_state,
|
|
**({"access": access} if access else {}),
|
|
**({"flow": flow} if flow else {}),
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=15,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
return {"ok": False, "error": f"cloud unreachable: {type(exc).__name__}"}
|
|
if resp.status_code != 200:
|
|
return {"ok": False, "error": f"start failed ({resp.status_code})"}
|
|
_pending_managed_states[app_state] = _now()
|
|
return {
|
|
"ok": True,
|
|
"authorize_url": resp.json()["authorize_url"],
|
|
"app_state": app_state,
|
|
}
|
|
|
|
|
|
def consume_managed_state(state: str) -> bool:
|
|
"""Consume one recent managed-OAuth callback state exactly once."""
|
|
if not state:
|
|
return False
|
|
created = _pending_managed_states.pop(state, None)
|
|
return created is not None and created >= _now() - _MANAGED_STATE_TTL
|
|
|
|
|
|
def managed_profile_from_callback(form: dict[str, str]) -> dict[str, Any]:
|
|
"""Local connector profile from the broker's form-POST payload.
|
|
|
|
Field-compatible with a manual paste (`access_token` etc.) so tools and
|
|
gating treat both paths identically; the managed extras (refresh_token,
|
|
connection_id) are what enable broker refresh and cloud disconnect.
|
|
"""
|
|
profile = {
|
|
"type": "oauth",
|
|
"enabled": True,
|
|
"managed": True,
|
|
"access_token": form.get("access_token", ""),
|
|
"refresh_token": form.get("refresh_token", ""),
|
|
"scope": form.get("scope", ""),
|
|
"connection_id": form.get("connection_id", ""),
|
|
"provider": form.get("provider", ""),
|
|
"account": form.get("account", ""),
|
|
}
|
|
if form.get("account_id"):
|
|
# The stable id behind the display name (workspace/portal id) — what
|
|
# the generic accounts layer keys multi-account profiles by.
|
|
profile["account_id"] = form["account_id"]
|
|
if form.get("expires_in"): # absent ⇒ non-expiring token (e.g. Slack bot tokens)
|
|
profile["expires"] = _now() + int(form["expires_in"]) - 60
|
|
return profile
|
|
|
|
|
|
def refresh_managed_token(
|
|
secrets: SecretStore,
|
|
config: Config,
|
|
connector: str,
|
|
*,
|
|
profile_key: Optional[str] = None,
|
|
) -> Optional[dict[str, Any]]:
|
|
"""Renew a managed connector token through the broker. Returns the updated
|
|
profile, or None if this profile can't be (or doesn't need to be) renewed
|
|
that way. Manual profiles are never touched. `profile_key` targets an
|
|
account-keyed profile (`gmail:account:<email>`); default = `<name>:default`."""
|
|
key = profile_key or f"{connector}:default"
|
|
profile = secrets.get(key) or {}
|
|
if not (profile.get("managed") and profile.get("refresh_token")):
|
|
return None
|
|
provider = profile.get("provider") or PROVIDER_FOR_CONNECTOR.get(connector)
|
|
token = fresh_access_token(secrets, config)
|
|
if not provider or not token:
|
|
return None
|
|
try:
|
|
resp = httpx.post(
|
|
config.cloud_base_url.rstrip("/") + f"/v1/oauth/{provider}/refresh",
|
|
json={
|
|
"refresh_token": profile["refresh_token"],
|
|
"connection_id": profile.get("connection_id", ""),
|
|
"connector": connector,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=20,
|
|
)
|
|
except httpx.HTTPError:
|
|
return None
|
|
if resp.status_code != 200:
|
|
return None
|
|
fresh = resp.json()
|
|
profile["access_token"] = fresh.get("access_token", "")
|
|
if fresh.get("refresh_token"):
|
|
profile["refresh_token"] = fresh["refresh_token"]
|
|
profile["expires"] = _now() + int(fresh.get("expires_in") or 3600) - 60
|
|
secrets.put(key, profile)
|
|
return profile
|
|
|
|
|
|
def ensure_fresh_connector_token(
|
|
secrets: SecretStore,
|
|
config: Config,
|
|
connector: str,
|
|
*,
|
|
profile_key: Optional[str] = None,
|
|
leeway: int = 120,
|
|
) -> None:
|
|
"""Refresh-on-expiry hook for connector tools: if this is a managed profile
|
|
about to expire, renew it in place. No-op for manual profiles."""
|
|
key = profile_key or f"{connector}:default"
|
|
profile = secrets.get(key) or {}
|
|
if not profile.get("managed"):
|
|
return
|
|
expires = float(profile.get("expires") or 0)
|
|
if expires and expires > _now() + leeway:
|
|
return
|
|
refresh_managed_token(secrets, config, connector, profile_key=profile_key)
|
|
|
|
|
|
def cloud_disconnect(
|
|
secrets: SecretStore,
|
|
config: Config,
|
|
connector: str,
|
|
*,
|
|
profile_key: Optional[str] = None,
|
|
) -> None:
|
|
"""Best-effort: tell the cloud a managed connection is gone so its metadata
|
|
flips to disconnected. Local deletion always proceeds regardless."""
|
|
profile = secrets.get(profile_key or f"{connector}:default") or {}
|
|
connection_id = profile.get("connection_id")
|
|
if not (profile.get("managed") and connection_id):
|
|
return
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return
|
|
try:
|
|
httpx.post(
|
|
config.cloud_base_url.rstrip("/")
|
|
+ f"/v1/connections/{connection_id}/disconnect",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
except httpx.HTTPError:
|
|
pass
|
|
|
|
|
|
# installation_id -> (token, expires_epoch). MEMORY ONLY by design: GitHub
|
|
# installation tokens live ~1 h and are re-minted from the broker; they must
|
|
# never touch the secret store (github-relay-spec §4).
|
|
_GITHUB_TOKEN_CACHE: dict[str, tuple[str, float]] = {}
|
|
_GITHUB_TOKEN_LEEWAY = 600 # re-mint when < 10 min of life remains
|
|
|
|
|
|
def github_installation_token(
|
|
secrets: SecretStore, config: Config, installation_id: str, *, force: bool = False
|
|
) -> str:
|
|
"""A live installation access token for GitHub API calls, minted via the
|
|
authenticated broker route and cached in memory (~50 min). `force` skips
|
|
the cache — the 401 retry path. Empty string when unavailable (signed
|
|
out / revoked installation / cloud unreachable)."""
|
|
installation_id = str(installation_id or "").strip()
|
|
if not installation_id:
|
|
return ""
|
|
if not force:
|
|
cached = _GITHUB_TOKEN_CACHE.get(installation_id)
|
|
if cached and cached[1] > _now() + _GITHUB_TOKEN_LEEWAY:
|
|
return cached[0]
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return ""
|
|
try:
|
|
resp = httpx.post(
|
|
config.cloud_base_url.rstrip("/") + "/v1/github/token",
|
|
json={"installation_id": installation_id},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=20,
|
|
)
|
|
except httpx.HTTPError:
|
|
return ""
|
|
if resp.status_code != 200:
|
|
return ""
|
|
body = resp.json()
|
|
minted = body.get("token", "")
|
|
# expires_at is ISO-8601 from GitHub; parse defensively, default 1 h.
|
|
expires = _now() + 3600
|
|
try:
|
|
from datetime import datetime
|
|
|
|
raw = str(body.get("expires_at", ""))
|
|
if raw:
|
|
expires = datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
except ValueError:
|
|
pass
|
|
if minted:
|
|
_GITHUB_TOKEN_CACHE[installation_id] = (minted, expires)
|
|
return minted
|
|
|
|
|
|
def clear_github_token(installation_id: str) -> None:
|
|
"""Drop a cached installation token (disconnect / revocation)."""
|
|
_GITHUB_TOKEN_CACHE.pop(str(installation_id or "").strip(), None)
|
|
|
|
|
|
def github_disconnect_installation(
|
|
secrets: SecretStore, config: Config, installation_id: str
|
|
) -> None:
|
|
"""Best-effort: delete this user's relay routing rows for one installation
|
|
so the cloud stops pushing its events. Local profile deletion always
|
|
proceeds regardless (the row only routes)."""
|
|
clear_github_token(installation_id)
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return
|
|
try:
|
|
httpx.post(
|
|
config.cloud_base_url.rstrip("/") + "/v1/relay/github/disconnect",
|
|
json={"installation_id": installation_id},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
except httpx.HTTPError:
|
|
pass
|
|
|
|
|
|
def slack_disconnect_workspace(
|
|
secrets: SecretStore, config: Config, team_id: str
|
|
) -> None:
|
|
"""Best-effort: delete this user's relay routing row for one workspace so the
|
|
cloud stops pushing its events. Local token deletion always proceeds regardless
|
|
(the row only routes; without the desktop token nothing can be sent anyway)."""
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return
|
|
try:
|
|
httpx.post(
|
|
config.cloud_base_url.rstrip("/") + "/v1/relay/slack/uninstall",
|
|
json={"team_id": team_id},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
except httpx.HTTPError:
|
|
pass
|
|
|
|
|
|
# --- persona gallery -----------------------------------------------------------
|
|
|
|
|
|
def _gallery_get(secrets: SecretStore, config: Config, path: str) -> Optional[dict]:
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return None
|
|
try:
|
|
resp = httpx.get(
|
|
config.cloud_base_url.rstrip("/") + path,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=15,
|
|
)
|
|
except httpx.HTTPError:
|
|
return None
|
|
return resp.json() if resp.status_code == 200 else None
|
|
|
|
|
|
def gallery_list(secrets: SecretStore, config: Config) -> Optional[dict]:
|
|
"""Curated persona cards visible to this user's tenant; None when signed
|
|
out or the cloud is unreachable (gallery requires sign-in by design)."""
|
|
return _gallery_get(secrets, config, "/v1/personas/gallery")
|
|
|
|
|
|
def gallery_manifest(secrets: SecretStore, config: Config, slug: str) -> Optional[dict]:
|
|
return _gallery_get(secrets, config, f"/v1/personas/gallery/{slug}/manifest")
|
|
|
|
|
|
def gallery_install_event(secrets: SecretStore, config: Config, slug: str) -> None:
|
|
"""Best-effort product telemetry (slug/version only, no content)."""
|
|
token = fresh_access_token(secrets, config)
|
|
if not token:
|
|
return
|
|
try:
|
|
httpx.post(
|
|
config.cloud_base_url.rstrip("/")
|
|
+ f"/v1/personas/gallery/{slug}/install-events",
|
|
json={"platform": __import__("sys").platform},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
except httpx.HTTPError:
|
|
pass
|
|
|
|
|
|
def gallery_detail(secrets: SecretStore, config: Config, slug: str) -> Optional[dict]:
|
|
"""Solo-page payload: the cloud card + publisher pitch, with capability
|
|
facts derived LOCALLY from the manifest via the desktop's own strict
|
|
parser — the pitch can never advertise what install-time consent wouldn't
|
|
show, because both views come from the same parsed manifest."""
|
|
card = _gallery_get(secrets, config, f"/v1/personas/gallery/{slug}")
|
|
manifest = gallery_manifest(secrets, config, slug)
|
|
if card is None or manifest is None:
|
|
return None
|
|
try:
|
|
from .personas.loading import consent_summary
|
|
from .personas.manifest import parse_manifest
|
|
|
|
m = parse_manifest(manifest.get("manifest_markdown", ""), fallback_id=slug)
|
|
capabilities = consent_summary(m)
|
|
recommends = [
|
|
{"kind": r.kind, "ref": r.ref, "reason": r.reason, "tier": r.tier}
|
|
for r in m.recommends
|
|
]
|
|
except Exception as exc: # malformed manifest: surface, don't crash
|
|
return {"ok": False, "error": f"manifest failed local validation: {exc}"}
|
|
return {
|
|
"ok": True,
|
|
"card": card,
|
|
"capabilities": capabilities,
|
|
"recommends": recommends,
|
|
}
|