mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
"""Safe read/modify/write of 9Router's on-disk provider credentials.
|
|
|
|
9Router owns ~/.9router/db.json and rewrites the whole file whenever it refreshes a token or a
|
|
user edits a provider, and its HTTP API has no route that can write an OAuth connection's tokens
|
|
(PUT /api/providers/[id] accepts name/priority/isActive/apiKey only, and apiKey only for apikey
|
|
connections). So the only way to move an OAuth credential is to edit the file, which means we have
|
|
to not race the router for it. Every mutation here happens with the router stopped.
|
|
|
|
The reason any of this exists: providers hand back a NEW refresh token on every refresh and treat
|
|
a replayed one as theft, revoking the whole grant family. So a credential may have exactly one
|
|
holder that can refresh it. Removing `refreshToken` from a connection is what makes a given
|
|
9Router instance structurally unable to rotate, because its refresh dispatcher bails on a falsy
|
|
refreshToken before it ever calls the provider.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.nine_router import process
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
P_SHUTDOWN_TIMEOUT_S = 5.0
|
|
P_DOWN_POLL_INTERVAL_S = 0.1
|
|
P_DOWN_WAIT_S = 10.0
|
|
|
|
|
|
class ProviderCredential(BaseModel):
|
|
"""The transferable half of a 9Router provider connection."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
connection_id: str
|
|
provider: str
|
|
access_token: str
|
|
refresh_token: Optional[str] = None
|
|
expires_at: Optional[str] = None
|
|
|
|
|
|
@typechecked
|
|
def db_path() -> str:
|
|
return os.path.join(process.nine_router_data_dir(), "db.json")
|
|
|
|
|
|
@typechecked
|
|
def p_load_db() -> Optional[Dict[str, Any]]:
|
|
try:
|
|
with open(db_path(), encoding="utf-8") as f:
|
|
db = json.load(f)
|
|
return db if isinstance(db, dict) else None
|
|
except (OSError, ValueError):
|
|
logger.warning("could not read 9router db.json", exc_info=True)
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
def p_write_db(db: Dict[str, Any]) -> bool:
|
|
"""Atomic replace at 0600. A half-written db.json costs the user every provider connection."""
|
|
path = db_path()
|
|
directory = os.path.dirname(path)
|
|
handle, temp_path = tempfile.mkstemp(dir=directory, prefix=".db.json.", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(handle, "w", encoding="utf-8") as f:
|
|
json.dump(db, f, indent=2)
|
|
os.chmod(temp_path, 0o600)
|
|
os.replace(temp_path, path)
|
|
return True
|
|
except OSError:
|
|
logger.warning("could not write 9router db.json", exc_info=True)
|
|
try:
|
|
os.unlink(temp_path)
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
@typechecked
|
|
def read_credential(connection_id: str) -> Optional[ProviderCredential]:
|
|
"""The tokens for one connection, readable whether or not the router is up."""
|
|
for c in process.read_persisted_connections():
|
|
if c.get("id") != connection_id:
|
|
continue
|
|
access = c.get("accessToken")
|
|
if not isinstance(access, str) or not access:
|
|
return None
|
|
refresh = c.get("refreshToken")
|
|
expires = c.get("expiresAt")
|
|
return ProviderCredential(
|
|
connection_id=connection_id,
|
|
provider=str(c.get("provider") or ""),
|
|
access_token=access,
|
|
refresh_token=refresh if isinstance(refresh, str) and refresh else None,
|
|
expires_at=expires if isinstance(expires, str) else None,
|
|
)
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
def list_oauth_connection_ids() -> List[str]:
|
|
"""Connections that carry a rotating credential; apikey rows have nothing to lease."""
|
|
return [
|
|
str(c.get("id"))
|
|
for c in process.read_persisted_connections()
|
|
if c.get("authType") == "oauth" and c.get("id")
|
|
]
|
|
|
|
|
|
@typechecked
|
|
async def request_shutdown() -> None:
|
|
"""Ask the router to exit over HTTP. Its own seam so a test can never reach a real router."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=P_SHUTDOWN_TIMEOUT_S, headers=process.cli_auth_headers()) as client:
|
|
await client.post(f"{process.NINE_ROUTER_API}/shutdown")
|
|
except (httpx.HTTPError, AttributeError):
|
|
pass
|
|
|
|
|
|
@typechecked
|
|
async def p_stop_router() -> bool:
|
|
"""Down the router however we can reach it. `stop()` alone only kills one we spawned; an
|
|
adopted port-holder has no handle, so ask it to shut itself down over HTTP first."""
|
|
await request_shutdown()
|
|
process.stop()
|
|
waited = 0.0
|
|
while waited < P_DOWN_WAIT_S:
|
|
if not process.is_running():
|
|
return True
|
|
await asyncio.sleep(P_DOWN_POLL_INTERVAL_S)
|
|
waited += P_DOWN_POLL_INTERVAL_S
|
|
return not process.is_running()
|
|
|
|
|
|
@typechecked
|
|
async def apply_to_connection(connection_id: str, changes: Dict[str, Any], drop: List[str]) -> bool:
|
|
"""Set `changes` and delete `drop` on one connection, with the router stopped throughout.
|
|
|
|
Refuses to run if the router will not go down, because a concurrent refresh would either lose
|
|
our edit or, far worse, resurrect a refresh token we are in the middle of handing away.
|
|
"""
|
|
if not await p_stop_router():
|
|
logger.error("refusing to edit 9router db.json: router would not stop")
|
|
return False
|
|
try:
|
|
db = p_load_db()
|
|
if db is None:
|
|
return False
|
|
connections = db.get("providerConnections")
|
|
if not isinstance(connections, list):
|
|
return False
|
|
target = next((c for c in connections if isinstance(c, dict) and c.get("id") == connection_id), None)
|
|
if target is None:
|
|
logger.error("9router connection %s not found", connection_id)
|
|
return False
|
|
target.update(changes)
|
|
for key in drop:
|
|
target.pop(key, None)
|
|
return p_write_db(db)
|
|
finally:
|
|
await process.ensure_running()
|