Initial commit: grok-free-register-oss

Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
This commit is contained in:
chaos
2026-07-16 21:04:05 +08:00
commit d10009d639
72 changed files with 18752 additions and 0 deletions

3
xai_enroller/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Standalone xAI OAuth Device Flow enroller."""
__version__ = "0.1.0"

111
xai_enroller/__main__.py Normal file
View File

@@ -0,0 +1,111 @@
import argparse
import asyncio
import httpx
from .config import Settings
from .coordinator import EnrollmentCoordinator
from .executors import HTTPProbeExecutor, PlaywrightExecutor
from .protocol import XAIProfile, XAIProtocol
from .sinks import CPAAuthFileSink
from .sources import FileSourceAdapter, SQLiteSourceAdapter
def build_parser():
parser = argparse.ArgumentParser(description="Bounded xAI OAuth Device Flow enroller")
parser.add_argument("--source", choices=("file", "sqlite"))
parser.add_argument("--target", type=int)
parser.add_argument("--concurrency", type=int)
parser.add_argument("--retry-attempts", type=int)
parser.add_argument("--executor", choices=("http", "playwright"))
parser.add_argument("--sink", choices=("cpa",))
return parser
async def main_async(args=None):
parsed = build_parser().parse_args(args)
env = dict()
if parsed.source:
env["XAI_ENROLLER_SOURCE_KIND"] = parsed.source
if parsed.target is not None:
env["XAI_ENROLLER_TARGET"] = str(parsed.target)
if parsed.concurrency is not None:
env["XAI_ENROLLER_CONCURRENCY"] = str(parsed.concurrency)
if parsed.retry_attempts is not None:
env["XAI_ENROLLER_RETRY_ATTEMPTS"] = str(parsed.retry_attempts)
if parsed.executor:
env["XAI_ENROLLER_AUTH_EXECUTOR"] = parsed.executor
if parsed.sink:
env["XAI_ENROLLER_SINK"] = parsed.sink
import os
merged = dict(os.environ)
merged.update(env)
settings = Settings.from_environ(merged)
if settings.source_kind == "remote":
raise ValueError("remote source requires the local auth service entrypoint")
source = (
FileSourceAdapter(settings.source_file)
if settings.source_kind == "file"
else SQLiteSourceAdapter(settings.source_db, settings.source_salt)
)
# CF prewarm (external FlareSolverr) + register-side proxy for httpx/Playwright
try:
from grok_register.clearance import (
format_prewarm_log,
httpx_proxy_mounts,
prewarm_clearance,
)
print(format_prewarm_log(prewarm_clearance()))
proxy_url = httpx_proxy_mounts()
except Exception as exc: # noqa: BLE001
print(f"[clearance] skip: {exc}")
proxy_url = None
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
protocol = XAIProtocol(
client,
XAIProfile.default(),
default_poll_interval=settings.poll_interval,
)
executor = (
HTTPProbeExecutor(client)
if settings.executor == "http"
else PlaywrightExecutor(settings.concurrency)
)
sink = None
sink_client = None
if settings.sink == "cpa":
sink_client = (
httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
)
sink = CPAAuthFileSink(settings.cpa_base_url, settings.cpa_management_secret, sink_client)
try:
coordinator = EnrollmentCoordinator(
source=source,
protocol=protocol,
executor=executor,
sink=sink,
ledger_path=settings.ledger_path,
ledger_salt=settings.source_salt,
concurrency=settings.concurrency,
timeout=settings.timeout_sec,
retry_attempts=settings.retry_attempts,
)
results = await coordinator.run(settings.target)
for index, result in enumerate(results, 1):
print(f"job {index}: {result.status.value} ({result.reason_code})")
finally:
await client.aclose()
if sink_client:
await sink_client.aclose()
def main():
asyncio.run(main_async())
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

139
xai_enroller/config.py Normal file
View File

@@ -0,0 +1,139 @@
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
def _int(env, key, default):
try:
return int(env.get(key, str(default)))
except ValueError as exc:
raise ValueError(f"{key} must be an integer") from exc
def _float(env, key, default):
try:
return float(env.get(key, str(default)))
except ValueError as exc:
raise ValueError(f"{key} must be numeric") from exc
@dataclass(frozen=True)
class Settings:
source_kind: str
source_file: Path | None
source_db: Path | None
source_salt: bytes
ledger_path: Path
target: int = 1
concurrency: int = 1
retry_attempts: int = 0
timeout_sec: float = 1800.0
poll_interval: float = 5.0
executor: str = "http"
sink: str | None = None
cpa_base_url: str | None = None
cpa_management_secret: str | None = None
local_auth_dir: Path | None = None
@classmethod
def from_environ(cls, env=None):
env = dict(os.environ if env is None else env)
source_kind = env.get("XAI_ENROLLER_SOURCE_KIND", "file")
if source_kind not in {"file", "sqlite", "remote"}:
raise ValueError("source kind must be file, sqlite, or remote")
source_file = Path(env["XAI_ENROLLER_SOURCE_FILE"]) if env.get("XAI_ENROLLER_SOURCE_FILE") else None
source_db = Path(env["XAI_ENROLLER_SOURCE_DB"]) if env.get("XAI_ENROLLER_SOURCE_DB") else None
if source_kind == "file" and source_file is None:
raise ValueError("source file is required")
if source_kind == "sqlite" and source_db is None:
raise ValueError("source db is required")
salt = env.get("XAI_ENROLLER_SOURCE_SALT")
if not salt:
raise ValueError("source salt is required")
ledger_path = Path(env.get("XAI_ENROLLER_LEDGER_PATH", "xai-enroller-ledger.db"))
target = _int(env, "XAI_ENROLLER_TARGET", 1)
concurrency = _int(env, "XAI_ENROLLER_CONCURRENCY", 1)
retry_attempts = _int(env, "XAI_ENROLLER_RETRY_ATTEMPTS", 0)
timeout_sec = _float(env, "XAI_ENROLLER_TIMEOUT_SEC", 1800)
poll_interval = _float(env, "XAI_ENROLLER_POLL_SEC", 5)
executor = env.get("XAI_ENROLLER_AUTH_EXECUTOR", "http")
sink = env.get("XAI_ENROLLER_SINK") or None
if not 1 <= target <= 100:
raise ValueError("target must be between 1 and 100")
if not 1 <= concurrency <= 4:
raise ValueError("concurrency must be between 1 and 4")
if not 0 <= retry_attempts <= 3:
raise ValueError("retry attempts must be between 0 and 3")
if timeout_sec <= 0:
raise ValueError("timeout must be positive")
if poll_interval <= 0:
raise ValueError("poll interval must be positive")
if executor not in {"http", "playwright"}:
raise ValueError("executor must be http or playwright")
if sink not in {None, "cpa", "local"}:
raise ValueError("sink must be cpa or local")
cpa_url = env.get("XAI_ENROLLER_CPA_BASE_URL") or None
cpa_secret = env.get("XAI_ENROLLER_CPA_MANAGEMENT_SECRET") or None
local_auth_dir = (
Path(env["XAI_ENROLLER_LOCAL_AUTH_DIR"]).expanduser()
if env.get("XAI_ENROLLER_LOCAL_AUTH_DIR")
else None
)
if sink == "cpa":
if not cpa_url or not cpa_secret:
raise ValueError("CPA base URL and management secret are required")
parsed = urlparse(cpa_url)
is_private = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not is_private:
raise ValueError("CPA base URL must use HTTPS")
if sink == "local" and local_auth_dir is None:
raise ValueError("local auth directory is required")
return cls(
source_kind=source_kind,
source_file=source_file,
source_db=source_db,
source_salt=salt.encode(),
ledger_path=ledger_path,
target=target,
concurrency=concurrency,
retry_attempts=retry_attempts,
timeout_sec=timeout_sec,
poll_interval=poll_interval,
executor=executor,
sink=sink,
cpa_base_url=cpa_url,
cpa_management_secret=cpa_secret,
local_auth_dir=local_auth_dir,
)
def redacted_dict(self):
return {
"source_kind": self.source_kind,
"source_file": str(self.source_file) if self.source_file else None,
"source_db": str(self.source_db) if self.source_db else None,
"ledger_path": str(self.ledger_path),
"target": self.target,
"concurrency": self.concurrency,
"retry_attempts": self.retry_attempts,
"timeout_sec": self.timeout_sec,
"poll_interval": self.poll_interval,
"executor": self.executor,
"sink": self.sink,
"cpa_base_url": self.cpa_base_url,
"local_auth_dir": str(self.local_auth_dir) if self.local_auth_dir else None,
}
def require_private_regular_file(path: Path, *, require_0600=False):
try:
mode = path.stat()
except OSError as exc:
raise ValueError(f"source path is unavailable: {path}") from exc
if not stat.S_ISREG(mode.st_mode):
raise ValueError("source path must be a regular file")
if mode.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ValueError("source path must not be group/world writable")
if require_0600 and mode.st_mode & (stat.S_IRGRP | stat.S_IROTH):
raise ValueError("file source requires mode 0600 or stricter")

177
xai_enroller/coordinator.py Normal file
View File

@@ -0,0 +1,177 @@
import asyncio
from contextlib import suppress
import httpx
from .ledger import Ledger
from .models import (
AuthorizationStatus,
JobResult,
JobStatus,
)
class EnrollmentCoordinator:
def __init__(
self,
*,
source,
protocol,
executor,
sink,
ledger_path,
ledger_salt,
concurrency=1,
timeout=1800,
retry_attempts=0,
event_callback=None,
):
self.source = source
self.protocol = protocol
self.executor = executor
self.sink = sink
self.ledger = Ledger(ledger_path, ledger_salt)
self.concurrency = concurrency
self.timeout = timeout
self.retry_attempts = max(0, retry_attempts)
self.event_callback = event_callback
self._semaphore = asyncio.Semaphore(concurrency)
@staticmethod
def _is_pre_device_transport_failure(error):
return isinstance(error, (httpx.TransportError, OSError))
def _emit(self, kind, data):
if self.event_callback is None:
return
try:
self.event_callback(kind, data)
except Exception:
pass
async def run(self, target=1):
return await self.run_records(self.source.records(), target=target)
async def run_records(self, records, target=100):
if not 1 <= target <= 100:
raise ValueError("target must be between 1 and 100")
self.ledger.recover_pending()
results = []
tasks = []
index = 0
if hasattr(records, "__aiter__"):
async for source in records:
if index >= target:
break
index += 1
tasks.append(asyncio.create_task(self._run_one(source)))
else:
for source in records:
if index >= target:
break
index += 1
tasks.append(asyncio.create_task(self._run_one(source)))
if hasattr(self.executor, "start"):
await self.executor.start()
try:
if tasks:
results = await asyncio.gather(*tasks)
return results
finally:
for task in tasks:
if not task.done():
task.cancel()
if hasattr(self.executor, "close"):
with suppress(Exception):
await self.executor.close()
async def _run_one(self, source):
async with self._semaphore:
for attempt in range(1, self.retry_attempts + 2):
job_id = self.ledger.start(source.source_id, attempt=attempt)
try:
async with asyncio.timeout(self.timeout):
try:
flow = await self.protocol.start_device_flow()
except Exception as error:
result = JobResult(
source.source_id,
JobStatus.TRANSPORT_FAILED,
"device_flow_failed",
attempt,
)
self.ledger.finish(job_id, result.status, result.reason_code)
if (
self._is_pre_device_transport_failure(error)
and attempt <= self.retry_attempts
):
continue
return result
self._emit(
"device_flow",
{
"source_id": source.source_id,
"user_code": flow.user_code,
"verification_url": flow.verification_url,
},
)
return await self._attempt_after_flow(source, job_id, flow, attempt)
except asyncio.TimeoutError:
result = JobResult(source.source_id, JobStatus.TIMEOUT, "timeout", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
except asyncio.CancelledError:
result = JobResult(source.source_id, JobStatus.CANCELLED, "cancelled", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
raise
except Exception:
result = JobResult(
source.source_id, JobStatus.TRANSPORT_FAILED, "transport_failed", attempt
)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
async def _attempt_after_flow(self, source, job_id, flow, attempt):
authorization = await self.executor.confirm(source, flow)
if authorization.status is not AuthorizationStatus.AUTHORIZED:
status = JobStatus(authorization.status.value)
result = JobResult(source.source_id, status, authorization.reason_code, attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
if self.sink is None:
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_unconfigured", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
try:
credential = await self.protocol.poll_token(
endpoint=flow.token_endpoint,
flow=flow,
timeout=self.timeout,
)
except RuntimeError as error:
reason = str(error)
mapping = {
"oauth_denied": JobStatus.OAUTH_DENIED,
"oauth_expired": JobStatus.OAUTH_EXPIRED,
"oauth_rejected": JobStatus.OAUTH_REJECTED,
}
result = JobResult(
source.source_id, mapping.get(reason, JobStatus.TRANSPORT_FAILED), reason, attempt
)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
try:
receipt = await self.sink.store(credential)
except Exception:
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_failed", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
result = JobResult(
source.source_id,
JobStatus.IMPORTED,
"imported",
attempt,
sink_receipt_fingerprint=receipt.fingerprint,
)
self.ledger.finish(job_id, result.status, result.reason_code, receipt.fingerprint)
return result

595
xai_enroller/executors.py Normal file
View File

@@ -0,0 +1,595 @@
import asyncio
import glob
import importlib
import os
from contextlib import suppress
from urllib.parse import parse_qs, urlparse
import httpx
from grok_register.clearance import (
apply_clearance_to_context,
cached_user_agent,
playwright_proxy_settings,
)
from .models import AuthorizationResult, AuthorizationStatus, DeviceFlow, SourceRecord
class HTTPProbeExecutor:
def __init__(self, client: httpx.AsyncClient, max_body=64 * 1024):
self.client = client
self.max_body = max_body
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
parsed = urlparse(flow.verification_url)
if parsed.scheme != "https" or parsed.hostname != "accounts.x.ai":
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "invalid_url")
response = await self.client.get(
flow.verification_url,
headers={"Cookie": f"sso={source.sso_token}"},
follow_redirects=False,
)
body = await response.aread()
if len(body) > self.max_body:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "response_too_large")
text = body.decode("utf-8", errors="replace").lower()
if response.status_code == 403 and any(
marker in text for marker in ("challenge", "cf-chl", "captcha")
):
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
if response.status_code in {301, 302, 303, 307, 308}:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "redirect")
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "http_probe_inconclusive"
)
class PlaywrightExecutor:
ALLOWED_CONTROLS = frozenset({"Authorize", "Allow", "Continue", "Confirm"})
ATTEMPT_TIMEOUT_SECONDS = 55.0
CLOSE_TIMEOUT_SECONDS = 5.0
# Keep confirm loops tight: most successful device verifies finish under ~15s.
LOOP_DEADLINE_SECONDS = 28.0
COOKIE_WAIT_MS = 250
CONSENT_WAIT_MS = 350
CODE_WAIT_MS = 300
CHALLENGE_WAIT_MS = 700
POLL_WAIT_MS = 250
# Drop heavy assets that never participate in device verification.
BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
BLOCKED_URL_MARKERS = (
"google-analytics",
"googletagmanager",
"doubleclick",
"facebook.net",
"hotjar",
"segment.io",
)
def __init__(self, concurrency=1, playwright_factory=None, executable_path=None):
self.concurrency = concurrency
self.playwright_factory = playwright_factory
self.executable_path = executable_path or self._find_executable_path()
self._playwright = None
self._browser = None
self._semaphore = asyncio.Semaphore(concurrency)
self._lifecycle_lock = asyncio.Lock()
@staticmethod
def _find_executable_path():
configured = os.environ.get("XAI_ENROLLER_BROWSER_EXECUTABLE")
if configured:
return configured
candidates = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
candidates.extend(
glob.glob(
os.path.expanduser(
"~/.cloakbrowser/chromium-*/Chromium.app/Contents/MacOS/Chromium"
)
)
)
return sorted(candidates)[-1] if candidates else None
async def start(self):
async with self._lifecycle_lock:
if self._browser is not None:
is_connected = getattr(self._browser, "is_connected", None)
if not callable(is_connected) or is_connected():
return
await self._close_unlocked()
factory = self.playwright_factory
if factory is None:
module = importlib.import_module("playwright.async_api")
factory = module.async_playwright
playwright = await factory().start()
options = {"headless": True}
if self.executable_path:
options["executable_path"] = self.executable_path
# REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY → this process only
proxy = playwright_proxy_settings()
if proxy:
options["proxy"] = proxy
try:
browser = await playwright.chromium.launch(**options)
except BaseException:
await self._close_transport_resource(playwright.stop())
raise
self._playwright = playwright
self._browser = browser
async def close(self):
async with self._lifecycle_lock:
await self._close_unlocked()
async def _close_unlocked(self):
browser = self._browser
playwright = self._playwright
self._browser = None
self._playwright = None
try:
if browser is not None:
await self._close_transport_resource(browser.close())
finally:
if playwright is not None:
await self._close_transport_resource(playwright.stop())
@staticmethod
def _consume_task_result(task):
with suppress(BaseException):
task.result()
@classmethod
async def _close_transport_resource(cls, awaitable):
task = asyncio.create_task(awaitable)
try:
done, _pending = await asyncio.wait(
{task}, timeout=cls.CLOSE_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
task.cancel()
task.add_done_callback(cls._consume_task_result)
raise
if task in done:
cls._consume_task_result(task)
return True
task.cancel()
task.add_done_callback(cls._consume_task_result)
return False
async def _recycle_browser(self, browser):
async with self._lifecycle_lock:
if self._browser is browser:
await self._close_unlocked()
async def __aenter__(self):
await self.start()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
@classmethod
async def _close_attempt_resources(cls, page, context):
async def close_session():
closers = []
if page is not None:
closers.append(page.close())
if context is not None:
closers.append(context.close())
if closers:
await asyncio.gather(*closers, return_exceptions=True)
cleanup = asyncio.create_task(close_session())
cancelled = False
deadline = asyncio.get_running_loop().time() + cls.CLOSE_TIMEOUT_SECONDS
while not cleanup.done():
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
await asyncio.wait({cleanup}, timeout=remaining)
except asyncio.CancelledError:
cancelled = True
if cleanup.done():
cls._consume_task_result(cleanup)
else:
# asyncio.wait_for() is not a hard deadline: it cancels the child and
# then waits for cancellation to finish, which a wedged Playwright
# transport may never do. Abandon the cleanup task after one real
# wall-clock deadline and consume its eventual result asynchronously.
cleanup.cancel()
cleanup.add_done_callback(cls._consume_task_result)
if cancelled:
raise asyncio.CancelledError
return cleanup.done()
@staticmethod
async def _click_visible_exact(page, names):
for name in names:
try:
button = page.get_by_role("button", name=name, exact=True)
except TypeError:
button = page.get_by_role("button", name=name)
for index in range(await button.count()):
candidate = button.nth(index) if hasattr(button, "nth") else button.first
is_visible = getattr(candidate, "is_visible", None)
if is_visible is None or await is_visible():
await candidate.click()
return True
return False
@staticmethod
async def _click_visible_turnstile(page):
"""Click a visible Turnstile widget using the registration solver motion."""
for selector in (
".cf-turnstile",
"iframe[src*='challenges.cloudflare.com']",
"iframe[src*='turnstile']",
):
locator = page.locator(selector).first
if not await locator.count() or not await locator.is_visible():
continue
box = await locator.bounding_box()
if not box:
continue
x = box["x"] + box["width"] / 2
y = box["y"] + box["height"] / 2
await page.mouse.move(max(0, x - 25), max(0, y - 8))
await page.mouse.move(x, y, steps=8)
await page.mouse.down()
await asyncio.sleep(0.05)
await page.mouse.up()
return True
return False
@staticmethod
def _expanded_cookies(source):
allowed = {
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
cookies = []
for source_cookie in source.cookies:
cookie = {
key: value for key, value in source_cookie.items() if key in allowed
}
if cookie.get("domain"):
cookie.pop("url", None)
elif cookie.get("url"):
cookie.pop("domain", None)
cookie.pop("path", None)
cookies.append(cookie)
if not cookies:
cookies = [
{
"name": "sso",
"value": source.sso_token,
"domain": "accounts.x.ai",
"path": "/",
"secure": True,
"httpOnly": True,
}
]
seen = {
(cookie.get("name"), cookie.get("domain"), cookie.get("path", "/"))
for cookie in cookies
}
for cookie in list(cookies) if source.cookies else ():
name = cookie.get("name", "")
if not name.startswith("sso"):
continue
key = (name, ".x.ai", cookie.get("path", "/"))
if key in seen:
continue
clone = dict(cookie)
clone.pop("url", None)
clone["domain"] = ".x.ai"
clone.setdefault("path", "/")
cookies.append(clone)
seen.add(key)
return cookies
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
await self.start()
async with self._semaphore:
browser = self._browser
attempt = asyncio.create_task(
self._confirm_once(browser, source, flow)
)
try:
done, _pending = await asyncio.wait(
{attempt}, timeout=self.ATTEMPT_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
raise
if attempt in done:
return attempt.result()
# A Playwright transport can swallow cancellation while one of its
# RPCs is wedged. Do not let asyncio.wait_for() extend the nominal
# timeout indefinitely: detach the attempt and replace the complete
# browser transport before admitting the next account.
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
await self._recycle_browser(browser)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
async def _install_request_filters(self, page):
"""Skip non-essential resources so device verification spends less wall time."""
async def route_handler(route):
request = route.request
resource_type = getattr(request, "resource_type", "") or ""
url = getattr(request, "url", "") or ""
if resource_type in self.BLOCKED_RESOURCE_TYPES or any(
marker in url for marker in self.BLOCKED_URL_MARKERS
):
await route.abort()
return
await route.continue_()
with suppress(Exception):
await page.route("**/*", route_handler)
async def _confirm_once(self, browser, source: SourceRecord, flow: DeviceFlow):
context = None
page = None
code_submitted = False
consent_submitted = False
challenge_clicks = 0
try:
context_kwargs = {}
ua = cached_user_agent()
if ua:
context_kwargs["user_agent"] = ua
context = await browser.new_context(**context_kwargs)
# CF cookies from external FlareSolverr prewarm (host-level)
await apply_clearance_to_context(context)
page = await context.new_page()
await self._install_request_filters(page)
await context.add_cookies(self._expanded_cookies(source))
await page.goto(flow.verification_url, wait_until="domcontentloaded")
deadline = (
asyncio.get_running_loop().time() + self.LOOP_DEADLINE_SECONDS
)
unknown_since = None
while asyncio.get_running_loop().time() < deadline:
parsed = urlparse(page.url)
if parsed.scheme != "https" or not parsed.hostname or not (
parsed.hostname == "x.ai"
or parsed.hostname.endswith(".x.ai")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unsafe_page"
)
query_error = parse_qs(parsed.query).get("error", [None])[0]
text = await page.locator("body").inner_text()
text_lower = text.lower()
page_title = getattr(page, "title", None)
title_lower = (
(await page_title()).lower() if page_title is not None else ""
)
if query_error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if query_error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "device_verify_failed"
)
if any(
marker in text_lower
for marker in ("rate limit", "too many requests", "请求过于频繁")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if (
"attention required" in title_lower
and "cloudflare" in title_lower
) or any(
marker in text_lower
for marker in (
"sorry, you have been blocked",
"unable to access x.ai",
"cloudflare ray id",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if "/oauth2/device/done" in parsed.path or any(
marker in text_lower
for marker in ("device authorized", "设备已授权")
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED, "confirmed"
)
cookie_clicked = await self._click_visible_exact(
page,
(
"全部拒绝",
"拒绝全部",
"Reject all",
"Reject All",
),
)
if cookie_clicked:
unknown_since = None
await page.wait_for_timeout(self.COOKIE_WAIT_MS)
continue
if "/oauth2/device/consent" in parsed.path or any(
marker in text_lower
for marker in ("authorize grok build", "授权 grok build")
):
if not consent_submitted:
allowed = await self._click_visible_exact(
page, ("允许", "Allow", "Authorize", "Approve")
)
if allowed:
consent_submitted = True
unknown_since = None
await page.wait_for_timeout(self.CONSENT_WAIT_MS)
continue
else:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
code_input = page.locator('input[name="user_code"]')
try:
code_input_count = await code_input.count()
except AttributeError:
if await self._click_visible_exact(
page, self.ALLOWED_CONTROLS
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
code_input_count = 0
if code_input_count and await code_input.first.is_visible():
if code_submitted:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
unknown_since = None
current = await code_input.first.input_value()
# fill() is far cheaper than press_sequentially and the
# device-code form does not require keystroke events.
if flow.user_code.replace("-", "") not in current.replace(
"-", ""
):
await code_input.first.fill(flow.user_code)
submit = page.locator(
'button[type="submit"], input[type="submit"]'
)
if not await submit.count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"submit_missing",
)
predicate = lambda response: (
urlparse(response.url).hostname == "auth.x.ai"
and urlparse(response.url).path == "/oauth2/device/verify"
)
async with page.expect_response(
predicate, timeout=12000
) as response_info:
code_submitted = True
await submit.first.click()
response = await response_info.value
location = urlparse(response.headers.get("location", ""))
error = parse_qs(location.query).get("error", [None])[0]
if error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"rate_limited",
)
if error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"device_verify_failed",
)
await page.wait_for_timeout(self.CODE_WAIT_MS)
continue
if any(
marker in text_lower
for marker in (
"continue with email",
"sign in with email",
"使用邮箱登录",
)
) or await page.locator('input[type="password"]').count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"login_required",
)
if any(
marker in text_lower
for marker in (
"captcha",
"verify you are human",
"security challenge",
"turnstile",
"人机验证",
"安全验证",
)
):
if (
challenge_clicks < 3
and await self._click_visible_turnstile(page)
):
challenge_clicks += 1
unknown_since = None
await page.wait_for_timeout(self.CHALLENGE_WAIT_MS)
continue
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if any(
marker in text_lower
for marker in (
"multi-factor",
"two-factor",
"authentication code",
"verification code",
"双重验证",
"验证码",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"mfa_required",
)
if await self._click_visible_exact(page, self.ALLOWED_CONTROLS):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
now = asyncio.get_running_loop().time()
if unknown_since is None:
unknown_since = now
elif now - unknown_since >= 2.5:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unknown_page"
)
await page.wait_for_timeout(self.POLL_WAIT_MS)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
except asyncio.CancelledError:
raise
except Exception as error:
is_timeout = (
error.__class__.__name__ == "TimeoutError"
and error.__class__.__module__.startswith("playwright")
)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"confirmation_timeout" if is_timeout else "browser_error",
)
finally:
await self._close_attempt_resources(page, context)

105
xai_enroller/inventory.py Normal file
View File

@@ -0,0 +1,105 @@
import os
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
class InventoryError(RuntimeError):
pass
@dataclass(frozen=True)
class ClaimBatch:
batch_id: str
directory: Path
moved: int
note: str = ""
class CredentialInventory:
def __init__(self, ledger, available_directory, claimed_directory):
self.ledger = ledger
self.available_directory = Path(available_directory)
self.claimed_directory = Path(claimed_directory)
def take(self, limit, note=""):
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise ValueError("claim count must be an integer") from exc
if limit <= 0:
raise ValueError("claim count must be positive")
note = str(note or "").strip()
batch_id = self._new_batch_id()
receipts = self.ledger.claim_available(limit, batch_id)
return self._complete_batch(batch_id, receipts, note)
def recover(self):
rows = self.ledger.pending_claims()
batches = {}
for row in rows:
batches.setdefault(row["batch_id"], []).append(
row["sink_receipt_fingerprint"]
)
recovered = 0
for batch_id, receipts in batches.items():
recovered += self._complete_batch(batch_id, receipts, "").moved
return recovered
@staticmethod
def _new_batch_id():
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"{timestamp}-{secrets.token_hex(3)}"
@staticmethod
def _validate_receipt(receipt):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", receipt):
raise InventoryError("invalid credential receipt")
@staticmethod
def _fsync_directory(directory):
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _complete_batch(self, batch_id, receipts, note):
destination_directory = self.claimed_directory / batch_id
if not receipts:
return ClaimBatch(batch_id, destination_directory, 0, note)
self.available_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
self.claimed_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
destination_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
for directory in (
self.available_directory,
self.claimed_directory,
destination_directory,
):
os.chmod(directory, 0o700)
moved = 0
for receipt in receipts:
self._validate_receipt(receipt)
source = self.available_directory / f"{receipt}.json"
destination = destination_directory / source.name
source_exists = source.is_file()
destination_exists = destination.is_file()
if source_exists and destination_exists:
raise InventoryError("credential exists in both inventory locations")
if not source_exists and not destination_exists:
raise InventoryError("credential file is missing")
if source_exists:
os.replace(source, destination)
os.chmod(destination, 0o600)
moved += 1
self._fsync_directory(self.available_directory)
self._fsync_directory(destination_directory)
self._fsync_directory(self.claimed_directory)
marked = self.ledger.mark_claimed(batch_id, note=note)
if marked != len(receipts):
raise InventoryError("inventory batch did not commit completely")
return ClaimBatch(batch_id, destination_directory, moved, note)

275
xai_enroller/ledger.py Normal file
View File

@@ -0,0 +1,275 @@
import hashlib
import hmac
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .models import JobStatus
class Ledger:
def __init__(self, path: Path, salt: bytes):
self.path = Path(path)
self.salt = bytes(salt)
self._init()
def _connect(self):
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _init(self):
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
job_id INTEGER PRIMARY KEY,
source_fingerprint TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
reason_code TEXT,
sink_receipt_fingerprint TEXT
)
"""
)
columns = {
row["name"] for row in connection.execute("PRAGMA table_info(jobs)")
}
if "authorization_started" not in columns:
connection.execute(
"ALTER TABLE jobs ADD COLUMN authorization_started INTEGER NOT NULL DEFAULT 0"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS jobs_source_status_idx "
"ON jobs(source_fingerprint, status)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS credential_inventory (
sink_receipt_fingerprint TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK(state IN ('available', 'claiming', 'claimed')),
claimed_at TEXT NOT NULL DEFAULT '',
batch_id TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS credential_inventory_state_idx "
"ON credential_inventory(state)"
)
connection.execute(
"""
INSERT OR IGNORE INTO credential_inventory(
sink_receipt_fingerprint, state
)
SELECT sink_receipt_fingerprint, 'available'
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
""",
(JobStatus.IMPORTED.value,),
)
os.chmod(self.path, 0o600)
def fingerprint(self, source_id):
return hmac.new(self.salt, source_id.encode(), hashlib.sha256).hexdigest()
def _fingerprint(self, source_id):
return self.fingerprint(source_id)
def start(self, source_id, attempt=1):
return self.start_fingerprint(self.fingerprint(source_id), attempt=attempt)
def start_fingerprint(self, source_fingerprint, attempt=None):
if attempt is None:
attempt = self.next_attempt(source_fingerprint)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at) "
"VALUES (?, ?, ?, ?)",
(source_fingerprint, attempt, "pending", now),
)
return cursor.lastrowid
def next_attempt(self, source_fingerprint):
with self._connect() as connection:
row = connection.execute(
"SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt "
"FROM jobs WHERE source_fingerprint=?",
(source_fingerprint,),
).fetchone()
return int(row["next_attempt"])
def mark_authorization_started(self, job_id):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET authorization_started=1 "
"WHERE job_id=? AND status='pending'",
(job_id,),
)
def finish(self, job_id, status, reason_code, sink_receipt_fingerprint=None):
status_value = status.value if isinstance(status, JobStatus) else str(status)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=?, "
"sink_receipt_fingerprint=? "
"WHERE job_id=? AND status='pending'",
(status_value, now, reason_code, sink_receipt_fingerprint, job_id),
)
if (
cursor.rowcount == 1
and status_value == JobStatus.IMPORTED.value
and sink_receipt_fingerprint
):
connection.execute(
"INSERT OR IGNORE INTO credential_inventory("
"sink_receipt_fingerprint, state) VALUES (?, 'available')",
(sink_receipt_fingerprint,),
)
def claim_available(self, limit, batch_id):
limit = int(limit)
if limit <= 0:
return []
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"""
WITH latest_import AS (
SELECT sink_receipt_fingerprint, MAX(finished_at) AS finished_at
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
GROUP BY sink_receipt_fingerprint
)
SELECT inventory.sink_receipt_fingerprint
FROM credential_inventory AS inventory
JOIN latest_import USING (sink_receipt_fingerprint)
WHERE inventory.state='available'
ORDER BY latest_import.finished_at DESC,
inventory.sink_receipt_fingerprint ASC
LIMIT ?
""",
(JobStatus.IMPORTED.value, limit),
).fetchall()
fingerprints = [row["sink_receipt_fingerprint"] for row in rows]
connection.executemany(
"UPDATE credential_inventory SET state='claiming', batch_id=?, "
"claimed_at='', note='' "
"WHERE sink_receipt_fingerprint=? AND state='available'",
((batch_id, fingerprint) for fingerprint in fingerprints),
)
return fingerprints
def mark_claimed(self, batch_id, note=""):
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
cursor = connection.execute(
"UPDATE credential_inventory SET state='claimed', claimed_at=?, note=? "
"WHERE state='claiming' AND batch_id=?",
(datetime.now(timezone.utc).isoformat(), str(note), batch_id),
)
return cursor.rowcount
def inventory_counts(self):
counts = {"available": 0, "claiming": 0, "claimed": 0}
with self._connect() as connection:
rows = connection.execute(
"SELECT state, COUNT(*) AS count FROM credential_inventory GROUP BY state"
)
for row in rows:
counts[row["state"]] = int(row["count"])
return counts
def pending_claims(self, batch_id=None):
query = (
"SELECT sink_receipt_fingerprint, batch_id, note "
"FROM credential_inventory WHERE state='claiming'"
)
params = ()
if batch_id is not None:
query += " AND batch_id=?"
params = (str(batch_id),)
query += " ORDER BY batch_id, sink_receipt_fingerprint"
with self._connect() as connection:
rows = connection.execute(query, params)
return [dict(row) for row in rows]
def recover_claiming(self, batch_id=None, *, note=""):
query = (
"UPDATE credential_inventory SET state='available', claimed_at='', "
"batch_id='', note=? WHERE state='claiming'"
)
params = [str(note)]
if batch_id is not None:
query += " AND batch_id=?"
params.append(str(batch_id))
with self._connect() as connection:
cursor = connection.execute(query, params)
return cursor.rowcount
def recover_pending(self, *, reason="recovered_pending"):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=? WHERE status='pending'",
(JobStatus.CANCELLED.value, datetime.now(timezone.utc).isoformat(), reason),
)
def has_imported(self, source_id):
with self._connect() as connection:
row = connection.execute(
"SELECT 1 FROM jobs WHERE source_fingerprint=? AND status=? LIMIT 1",
(self.fingerprint(source_id), JobStatus.IMPORTED.value),
).fetchone()
return row is not None
def imported_fingerprints(self):
with self._connect() as connection:
rows = connection.execute(
"SELECT DISTINCT source_fingerprint FROM jobs WHERE status=?",
(JobStatus.IMPORTED.value,),
)
return {row["source_fingerprint"] for row in rows}
def aggregate_counts(self):
with self._connect() as connection:
row = connection.execute(
"""
SELECT
COUNT(DISTINCT CASE WHEN authorization_started=1 OR status=?
THEN source_fingerprint END) AS attempted_unique,
COUNT(DISTINCT CASE WHEN status=?
THEN source_fingerprint END) AS imported_unique,
COUNT(CASE WHEN finished_at IS NOT NULL THEN 1 END) AS finalized_attempts,
COUNT(CASE WHEN status=? THEN 1 END) AS imported_attempts,
COUNT(CASE WHEN reason_code='rate_limited' THEN 1 END) AS rate_limited
FROM jobs
""",
(
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
),
).fetchone()
return {key: int(row[key] or 0) for key in row.keys()}
def get(self, job_id):
with self._connect() as connection:
row = connection.execute(
"SELECT source_fingerprint, attempt_number, status, started_at, finished_at, "
"reason_code, sink_receipt_fingerprint FROM jobs WHERE job_id=?",
(job_id,),
).fetchone()
return dict(row) if row else None

128
xai_enroller/models.py Normal file
View File

@@ -0,0 +1,128 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional
@dataclass(frozen=True)
class SourceRecord:
source_id: str
sso_token: str
cookies: tuple[dict, ...] = ()
def __repr__(self) -> str:
return "SourceRecord(<redacted>)"
@dataclass(frozen=True)
class DeviceFlow:
device_code: str
user_code: str
verification_url: str
expires_in: int
interval: float
token_endpoint: str = "https://auth.x.ai/oauth2/token"
def __repr__(self) -> str:
return "DeviceFlow(<redacted>)"
@dataclass(frozen=True)
class OAuthCredential:
access_token: str
refresh_token: str
id_token: Optional[str]
token_type: Optional[str]
expires_in: int
expires_at: str
last_refresh: str
subject: Optional[str]
token_endpoint: str
def __repr__(self) -> str:
return "OAuthCredential(<redacted>)"
@dataclass(frozen=True)
class SinkReceipt:
fingerprint: str
def __repr__(self) -> str:
return "SinkReceipt(<redacted>)"
class AuthorizationStatus(str, Enum):
AUTHORIZED = "authorized"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
@dataclass(frozen=True)
class AuthorizationResult:
status: AuthorizationStatus
reason_code: str
class JobStatus(str, Enum):
IMPORTED = "imported"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
SOURCE_INVALID = "source_invalid"
OAUTH_DENIED = "oauth_denied"
OAUTH_EXPIRED = "oauth_expired"
OAUTH_REJECTED = "oauth_rejected"
SINK_FAILED = "sink_failed"
TRANSPORT_FAILED = "transport_failed"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class JobResult:
source_id: str
status: JobStatus
reason_code: str
attempt_number: int = 1
sink_receipt_fingerprint: Optional[str] = None
def __repr__(self) -> str:
return (
f"JobResult(source_id=<redacted>, status={self.status.value!r}, "
f"reason_code={self.reason_code!r}, attempt_number={self.attempt_number})"
)
class PipelineState(str, Enum):
QUEUED = "queued"
PREPARED = "prepared"
ACTIVE = "active"
RETRY_WAITING = "retry_waiting"
IMPORTED = "imported"
TERMINAL = "terminal"
@dataclass(frozen=True)
class PreparedJob:
source: SourceRecord
source_fingerprint: str
flow: DeviceFlow
flow_created_monotonic: float
job_id: int
attempt_number: int
task_number: Optional[int] = None
def __repr__(self) -> str:
return (
"PreparedJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.attempt_number})"
)
@dataclass(frozen=True)
class CompletionJob:
prepared: PreparedJob
def __repr__(self) -> str:
return (
"CompletionJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.prepared.attempt_number})"
)

181
xai_enroller/protocol.py Normal file
View File

@@ -0,0 +1,181 @@
import asyncio
import base64
import binascii
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Awaitable, Callable
from urllib.parse import urlencode
import httpx
from .models import DeviceFlow, OAuthCredential
@dataclass(frozen=True)
class XAIProfile:
client_id: str
scope: str
version: str = "xai-device-v1"
@classmethod
def default(cls):
return cls(
client_id="b1a00492-073a-47ea-816f-4c329264a828",
scope="openid profile email offline_access grok-cli:access api:access",
version="grok-cli-device-v1",
)
class XAIProtocol:
DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration"
MAX_BODY = 64 * 1024
def __init__(
self,
client: httpx.AsyncClient,
profile: XAIProfile,
sleep=None,
default_poll_interval=5.0,
):
self.client = client
self.profile = profile
self.sleep = sleep or asyncio.sleep
self.default_poll_interval = float(default_poll_interval)
self._discovered = None
@staticmethod
def _allowed_url(value):
from urllib.parse import urlparse
parsed = urlparse(value)
hostname = parsed.hostname
return bool(hostname) and parsed.scheme == "https" and (
hostname == "x.ai" or hostname.endswith(".x.ai")
)
async def _json(self, response):
body = await response.aread()
if len(body) > self.MAX_BODY:
raise RuntimeError("response body exceeds limit")
try:
return json.loads(body)
except (TypeError, ValueError) as exc:
raise RuntimeError("invalid JSON response") from exc
async def discover(self):
response = await self.client.get(self.DISCOVERY_URL, follow_redirects=False)
if response.status_code // 100 != 2:
raise RuntimeError("discovery rejected")
document = await self._json(response)
device_endpoint = document.get("device_authorization_endpoint")
token_endpoint = document.get("token_endpoint")
if not device_endpoint or not token_endpoint or not all(
self._allowed_url(url) for url in (device_endpoint, token_endpoint)
):
raise ValueError("discovery endpoint failed x.ai HTTPS allowlist")
self._discovered = (device_endpoint, token_endpoint)
return self._discovered
async def start_device_flow(self):
device_endpoint, token_endpoint = await self.discover()
response = await self.client.post(
device_endpoint,
data={"client_id": self.profile.client_id, "scope": self.profile.scope},
follow_redirects=False,
)
if response.status_code // 100 != 2:
raise RuntimeError("device authorization rejected")
document = await self._json(response)
try:
device_code = document["device_code"]
user_code = document["user_code"]
base_url = document.get("verification_uri") or document["verification_url"]
expires_in = int(document["expires_in"])
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError("invalid device authorization response") from exc
if not self._allowed_url(base_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
interval = float(document.get("interval", self.default_poll_interval))
verification_url = document.get("verification_uri_complete")
if verification_url is None:
verification_url = (
f"{base_url}{'&' if '?' in base_url else '?'}"
f"{urlencode({'user_code': user_code})}"
)
if not self._allowed_url(verification_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
return DeviceFlow(device_code, user_code, verification_url, expires_in, interval, token_endpoint)
async def poll_token(self, *, endpoint, flow, timeout):
deadline = time.monotonic() + timeout
interval = max(0.0, float(flow.interval))
while time.monotonic() < deadline:
response = await self.client.post(
endpoint,
data={
"client_id": self.profile.client_id,
"device_code": flow.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
follow_redirects=False,
)
document = await self._json(response)
if response.status_code // 100 == 2:
return self._credential(document, endpoint)
error = document.get("error")
if error in {"authorization_pending", "slow_down"}:
interval += 1 if error == "slow_down" else 0
await self.sleep(interval)
continue
if error == "access_denied":
raise RuntimeError("oauth_denied")
if error == "expired_token":
raise RuntimeError("oauth_expired")
raise RuntimeError("oauth_rejected")
raise RuntimeError("oauth_expired")
@staticmethod
def _jwt_subject(token):
if not isinstance(token, str):
return None
parts = token.split(".")
if len(parts) != 3 or not parts[1]:
return None
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
except (binascii.Error, UnicodeDecodeError, ValueError):
return None
if not isinstance(claims, dict):
return None
for claim in ("sub", "principal_id"):
value = claims.get(claim)
if isinstance(value, str) and value:
return value
return None
@staticmethod
def _credential(document, endpoint):
if not document.get("access_token") or not document.get("refresh_token"):
raise RuntimeError("oauth_rejected")
now = datetime.now(timezone.utc)
expires_in = int(document.get("expires_in", 3600))
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, timezone.utc).isoformat().replace("+00:00", "Z")
subject = (
XAIProtocol._jwt_subject(document.get("id_token"))
or XAIProtocol._jwt_subject(document["access_token"])
or document.get("sub")
)
return OAuthCredential(
access_token=document["access_token"],
refresh_token=document["refresh_token"],
id_token=document.get("id_token"),
token_type=document.get("token_type"),
expires_in=expires_in,
expires_at=expires_at,
last_refresh=now.isoformat().replace("+00:00", "Z"),
subject=subject,
token_endpoint=endpoint,
)

View File

@@ -0,0 +1,630 @@
"""Atomic local snapshots of password-free registration sessions."""
import asyncio
import json
import os
import shlex
import sys
import tempfile
from contextlib import suppress
from pathlib import Path
from .models import SourceRecord
MAX_SESSION_RECORD_BYTES = 256 * 1024
class RemoteStreamError(RuntimeError):
"""A classified stream failure whose text never includes remote output."""
class SSHSnapshotSynchronizer:
"""Atomically refresh one validated, password-free local JSONL snapshot."""
MAX_STDERR_BYTES = 16 * 1024
def __init__(
self,
host,
destination,
*,
remote_root="/opt/grok-free-register",
identity_file=None,
process_factory=asyncio.create_subprocess_exec,
fingerprint=None,
):
self.host = host
self.destination = Path(destination)
self.remote_root = remote_root
self.identity_file = identity_file
self.process_factory = process_factory
self.fingerprint = fingerprint or (lambda source_id: source_id)
self.snapshot_fingerprints = None
self._process = None
def _input_generation(self):
return None
def _input_generation_unchanged(self, generation):
return True
def _empty_snapshot_allowed(self):
return False
def _command(self):
return (
f"cd {shlex.quote(self.remote_root)} && "
"python3 scripts/export_registered_sessions.py "
"keys/auth-sessions.jsonl keys/accounts.txt"
)
def _args(self):
args = [
"ssh",
"-C",
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
]
if self.identity_file:
args.extend(["-i", self.identity_file])
args.extend(["--", self.host, self._command()])
return args
async def _read_stderr(self, stream):
retained = 0
while True:
chunk = await stream.read(4096)
if not chunk:
return
retained = min(self.MAX_STDERR_BYTES, retained + len(chunk))
async def _terminate(self, process):
if process is None or process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def close(self):
await self._terminate(self._process)
async def sync_once(self):
self.destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.destination.parent, 0o700)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{self.destination.name}.",
suffix=".tmp",
dir=self.destination.parent,
)
process = None
stderr_task = None
try:
os.fchmod(fd, 0o600)
input_generation = self._input_generation()
process = await self.process_factory(
*self._args(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
limit=MAX_SESSION_RECORD_BYTES + 1,
)
self._process = process
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
with os.fdopen(fd, "wb") as stream:
fd = -1
record_count = 0
snapshot_fingerprints = set()
while True:
try:
raw = await process.stdout.readline()
except (ValueError, asyncio.LimitOverrunError) as exc:
raise ValueError("invalid remote session snapshot") from exc
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
):
raise ValueError("invalid remote session snapshot")
record = parse_session_document(raw[:-1])
snapshot_fingerprints.add(self.fingerprint(record.source_id))
stream.write(raw)
record_count += 1
returncode = await process.wait()
if stderr_task is not None:
await stderr_task
stderr_task = None
if returncode != 0:
raise RemoteStreamError("remote snapshot export failed")
if record_count == 0 and not self._empty_snapshot_allowed():
raise RemoteStreamError("remote snapshot export was empty")
if not self._input_generation_unchanged(input_generation):
raise RemoteStreamError("snapshot inputs changed during export")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, self.destination)
os.chmod(self.destination, 0o600)
directory_fd = os.open(self.destination.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
self.snapshot_fingerprints = frozenset(snapshot_fingerprints)
return True
except asyncio.CancelledError:
raise
except Exception:
return False
finally:
if fd >= 0:
os.close(fd)
if stderr_task is not None:
if not stderr_task.done():
stderr_task.cancel()
await asyncio.gather(stderr_task, return_exceptions=True)
with suppress(Exception):
await self._terminate(process)
if self._process is process:
self._process = None
with suppress(FileNotFoundError):
os.unlink(temporary_name)
class LocalSnapshotSynchronizer(SSHSnapshotSynchronizer):
"""Export registration data from one local project into an atomic snapshot."""
def __init__(
self,
register_root,
destination,
*,
process_factory=asyncio.create_subprocess_exec,
fingerprint=None,
python_executable=sys.executable,
exporter_path=None,
):
super().__init__(
"",
destination,
process_factory=process_factory,
fingerprint=fingerprint,
)
self.register_root = Path(register_root)
self.python_executable = str(python_executable)
self.exporter_path = Path(exporter_path or (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "export_registered_sessions.py"
))
self.sessions_path = self.register_root / "keys" / "auth-sessions.jsonl"
self.accounts_path = self.register_root / "keys" / "accounts.txt"
@staticmethod
def _path_generation(path):
try:
stat_result = path.stat()
except FileNotFoundError:
return None
return (
stat_result.st_dev,
stat_result.st_ino,
stat_result.st_size,
stat_result.st_mtime_ns,
)
def _input_generation(self):
return (
self._path_generation(self.sessions_path),
self._path_generation(self.accounts_path),
)
def _input_generation_unchanged(self, generation):
return self._input_generation() == generation
def _empty_snapshot_allowed(self):
return True
def _args(self):
return [
self.python_executable,
str(self.exporter_path),
str(self.sessions_path),
str(self.accounts_path),
]
class DiskSnapshotSource:
"""Consume immutable local snapshot generations under queue backpressure."""
def __init__(
self,
path,
*,
synchronizer=None,
sync_seconds=30.0,
poll_seconds=0.25,
sleep=asyncio.sleep,
event_callback=None,
fingerprint=None,
):
self.path = Path(path)
self.synchronizer = synchronizer
self.sync_seconds = float(sync_seconds)
self.poll_seconds = float(poll_seconds)
self.sleep = sleep
self.event_callback = event_callback
self.fingerprint = fingerprint or (lambda source_id: source_id)
self._snapshot_fingerprints = None
self._last_reported_snapshot_fingerprints = None
self._closed = False
self._sync_task = None
self._last_sync_ok = None
def _emit(self, kind, data):
if self.event_callback is not None:
with suppress(Exception):
self.event_callback(kind, data)
@property
def snapshot_fingerprints(self):
if self.synchronizer is not None:
return self.synchronizer.snapshot_fingerprints
return self._snapshot_fingerprints
async def _sync_loop(self):
while not self._closed:
refreshed = await self.synchronizer.sync_once()
current = self.synchronizer.snapshot_fingerprints
if refreshed != self._last_sync_ok:
self._emit(
"source_connected" if refreshed else "source_disconnected",
{} if refreshed else {"reason": "snapshot_sync_failed"},
)
self._last_sync_ok = refreshed
if refreshed and current is not None:
previous = self._last_reported_snapshot_fingerprints
if previous is not None:
added = current - previous
if added:
self._emit(
"source_updated",
{"new": len(added), "total": len(current)},
)
self._last_reported_snapshot_fingerprints = current
try:
await asyncio.wait_for(self._wait_closed(), timeout=self.sync_seconds)
except TimeoutError:
pass
async def _wait_closed(self):
while not self._closed:
await self.sleep(min(0.25, self.sync_seconds))
async def close(self):
self._closed = True
if self._sync_task is not None:
self._sync_task.cancel()
with suppress(asyncio.CancelledError):
await self._sync_task
self._sync_task = None
if self.synchronizer is not None:
await self.synchronizer.close()
@staticmethod
def _generation(stat_result):
return (
stat_result.st_dev,
stat_result.st_ino,
stat_result.st_mtime_ns,
stat_result.st_size,
)
async def records(self):
if self.synchronizer is not None and self._sync_task is None:
self._sync_task = asyncio.create_task(self._sync_loop())
consumed_generation = None
while not self._closed:
try:
current = self.path.stat()
except FileNotFoundError:
await self.sleep(self.poll_seconds)
continue
generation = self._generation(current)
if generation == consumed_generation:
await self.sleep(self.poll_seconds)
continue
try:
stream = self.path.open("rb")
except FileNotFoundError:
continue
with stream:
opened_generation = self._generation(os.fstat(stream.fileno()))
generation_fingerprints = set()
valid_generation = True
while not self._closed:
raw = await asyncio.to_thread(
stream.readline, MAX_SESSION_RECORD_BYTES + 2
)
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
):
self._emit(
"source_record_rejected", {"reason": "invalid_record"}
)
valid_generation = False
break
try:
record = parse_session_document(raw[:-1])
except ValueError:
self._emit(
"source_record_rejected", {"reason": "invalid_record"}
)
valid_generation = False
continue
generation_fingerprints.add(self.fingerprint(record.source_id))
yield record
if valid_generation:
self._snapshot_fingerprints = frozenset(generation_fingerprints)
consumed_generation = opened_generation
def parse_session_document(raw: bytes | str) -> SourceRecord:
if len(raw) > MAX_SESSION_RECORD_BYTES:
raise ValueError("invalid remote session record")
try:
document = json.loads(raw)
source_id = document["email"]
raw_cookies = document["cookies"]
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
raise ValueError("invalid remote session record") from exc
if not isinstance(source_id, str) or not source_id:
raise ValueError("invalid remote session record")
try:
source_id.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("invalid remote session record") from exc
if not isinstance(raw_cookies, list) or not raw_cookies:
raise ValueError("invalid remote session record")
cookies = []
sso_token = ""
fallback_sso_token = ""
allowed = {
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
for raw_cookie in raw_cookies:
if not isinstance(raw_cookie, dict):
raise ValueError("invalid remote session record")
cookie = {key: raw_cookie[key] for key in allowed if key in raw_cookie}
name = cookie.get("name")
value = cookie.get("value")
if not isinstance(name, str) or not name or not isinstance(value, str) or not value:
raise ValueError("invalid remote session record")
scope = cookie.get("url") or cookie.get("domain")
if not isinstance(scope, str) or not scope:
raise ValueError("invalid remote session record")
try:
name.encode("utf-8")
value.encode("utf-8")
scope.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("invalid remote session record") from exc
if name == "sso" and not sso_token:
sso_token = value
elif name == "sso-rw" and not fallback_sso_token:
fallback_sso_token = value
cookies.append(cookie)
sso_token = sso_token or fallback_sso_token
if not sso_token:
raise ValueError("invalid remote session record")
return SourceRecord(source_id, sso_token, tuple(cookies))
class RemoteSessionStream:
"""Yield full snapshots and appends from one reconnecting SSH child."""
MAX_STDERR_BYTES = 16 * 1024
MAX_RECORD_BYTES = MAX_SESSION_RECORD_BYTES
RECONNECT_DELAYS = (1.0, 2.0, 5.0, 10.0, 30.0)
def __init__(
self,
host: str,
*,
remote_root: str = "/opt/grok-free-register",
identity_file: str | None = None,
process_factory=asyncio.create_subprocess_exec,
sleep=asyncio.sleep,
event_callback=None,
):
self.host = host
self.remote_root = remote_root
self.identity_file = identity_file
self.process_factory = process_factory
self.sleep = sleep
self.event_callback = event_callback
self._process = None
self._closed = False
self._last_disconnect_reason = None
def _emit(self, kind, data):
if self.event_callback is None:
return
try:
self.event_callback(kind, data)
except Exception:
pass
def _command(self):
return (
f"cd {shlex.quote(self.remote_root)} && "
"python3 -u scripts/export_registered_sessions.py --follow "
"keys/auth-sessions.jsonl keys/accounts.txt"
)
def _args(self):
args = [
"ssh",
"-C",
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
]
if self.identity_file:
args.extend(["-i", self.identity_file])
args.extend(["--", self.host, self._command()])
return args
async def _read_stderr(self, stream):
retained = bytearray()
while True:
chunk = await stream.read(4096)
if not chunk:
break
remaining = self.MAX_STDERR_BYTES - len(retained)
if remaining > 0:
retained.extend(chunk[:remaining])
return bytes(retained)
@staticmethod
def _classify_disconnect(returncode, stderr):
normalized = stderr.lower()
if b"permission denied" in normalized or b"host key verification failed" in normalized:
return "ssh_auth_failed"
if b"could not resolve" in normalized or b"name or service not known" in normalized:
return "ssh_resolution_failed"
if any(
marker in normalized
for marker in (b"connection refused", b"connection timed out", b"no route to host")
):
return "ssh_connection_failed"
if returncode == 3:
return "remote_snapshot_changed"
if returncode == 4:
return "remote_data_invalid"
return "remote_stream_closed"
async def _terminate_process(self, process):
if process is None or process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def close(self):
self._closed = True
await self._terminate_process(self._process)
async def records(self):
reconnect_index = 0
while not self._closed:
process = None
stderr_task = None
yielded = False
reason = "remote_stream_closed"
try:
process = await self.process_factory(
*self._args(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
limit=self.MAX_RECORD_BYTES + 1,
)
self._process = process
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
self._emit("source_connected", {})
while not self._closed:
try:
raw = await process.stdout.readline()
except (ValueError, asyncio.LimitOverrunError):
reason = "remote_record_too_large"
break
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > self.MAX_RECORD_BYTES
):
reason = "remote_record_too_large"
break
try:
record = parse_session_document(raw[:-1])
except ValueError:
self._emit("source_record_rejected", {"reason": "invalid_record"})
continue
if not yielded:
self._last_disconnect_reason = None
yielded = True
reconnect_index = 0
yield record
# A follow exporter is intentionally long-lived. Once framing is
# invalid, waiting for it to exit on its own would hang this source
# forever, so terminate it before collecting the exit status.
if reason != "remote_stream_closed":
await self._terminate_process(process)
if process.returncode is None:
await process.wait()
stderr = await stderr_task
if reason == "remote_stream_closed":
reason = self._classify_disconnect(process.returncode, stderr)
except asyncio.CancelledError:
raise
except (OSError, RuntimeError):
reason = "ssh_start_failed"
finally:
if stderr_task is not None and not stderr_task.done():
stderr_task.cancel()
with suppress(asyncio.CancelledError):
await stderr_task
await self._terminate_process(process)
if self._process is process:
self._process = None
if self._closed:
break
if reason != self._last_disconnect_reason:
self._emit("source_disconnected", {"reason": reason})
self._last_disconnect_reason = reason
if reason == "remote_snapshot_changed":
delay = 0.1
else:
delay = self.RECONNECT_DELAYS[min(reconnect_index, len(self.RECONNECT_DELAYS) - 1)]
if not yielded:
reconnect_index += 1
await self.sleep(delay)
PersistentSSHSource = RemoteSessionStream

883
xai_enroller/service.py Normal file
View File

@@ -0,0 +1,883 @@
"""Interactive composition for the asynchronous local authentication service."""
import asyncio
import os
import secrets
import sys
import tempfile
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from .inventory import InventoryError
DEFAULT_LOCAL_AUTH_DIR = Path.home() / "Downloads" / "grok-free-register-auth"
AUTHENTICATED_DIRNAME = "authenticated"
CLAIMED_DIRNAME = "claimed"
class AuthServiceConfigurationError(ValueError):
pass
class InteractiveCommandPrompt:
"""Stdlib-only line editor that keeps commands visible during events."""
CLEAR_LINE = "\r\x1b[2K"
def __init__(
self,
*,
input_stream=None,
output=None,
interactive=None,
prompt="认证> ",
):
self.input_stream = input_stream or sys.stdin
self.output = output or sys.stdout
self.prompt = prompt
if interactive is None:
interactive = bool(
getattr(self.input_stream, "isatty", lambda: False)()
and getattr(self.output, "isatty", lambda: False)()
)
self.interactive = interactive
self.buffer = ""
self.active = False
self._callback = None
self._fd = None
self._original_terminal = None
def _write(self, text):
try:
self.output.write(text)
self.output.flush()
except (OSError, ValueError):
pass
def _redraw(self):
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}{self.prompt}{self.buffer}")
def start(self, callback):
self._callback = callback
self.active = True
self._redraw()
def feed(self, text):
for character in text:
if character in {"\r", "\n"}:
command = self.buffer
self.buffer = ""
self._write("\n")
if command.strip() and self._callback is not None:
self._callback(command)
if self.active and self.interactive:
self._write(self.prompt)
elif character in {"\b", "\x7f"}:
self.buffer = self.buffer[:-1]
self._redraw()
elif character == "\x04":
if self._callback is not None:
self._callback(None)
elif character.isprintable():
self.buffer += character
self._redraw()
def write_event(self, message):
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}{message}\n{self.prompt}{self.buffer}")
else:
self._write(f"{message}\n")
def attach(self, loop, callback):
self.start(callback)
self._fd = self.input_stream.fileno()
if self.interactive:
import termios
import tty
self._original_terminal = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd)
def stdin_ready():
data = os.read(self._fd, 256)
if data:
self.feed(data.decode(errors="ignore"))
elif self._callback is not None:
loop.remove_reader(self._fd)
self._callback(None)
else:
def stdin_ready():
line = self.input_stream.readline()
if line == "":
if self._callback is not None:
loop.remove_reader(self._fd)
self._callback(None)
return
if self._callback is not None:
self._callback(line)
loop.add_reader(self._fd, stdin_ready)
def close(self, loop):
if self._fd is not None:
with suppress(Exception):
loop.remove_reader(self._fd)
if self._original_terminal is not None and self._fd is not None:
import termios
with suppress(Exception):
termios.tcsetattr(
self._fd, termios.TCSADRAIN, self._original_terminal
)
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}\n")
self.active = False
def resolve_auth_log_mode(argv=None, env=None):
argv = list(sys.argv[1:] if argv is None else argv)
env = dict(os.environ if env is None else env)
unknown = [argument for argument in argv if argument != "--debug"]
if unknown:
raise AuthServiceConfigurationError(
f"unsupported auth service argument: {unknown[0]}"
)
mode = (env.get("XAI_AUTH_SERVICE_LOG_MODE") or "user").strip().lower()
if "--debug" in argv:
mode = "debug"
if mode not in {"user", "debug"}:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_LOG_MODE must be user or debug"
)
return mode
def prepare_local_service_environment(env=None):
"""Create private local persistence defaults without storing secrets in the repo."""
merged = dict(os.environ if env is None else env)
destination = Path(
merged.get("XAI_ENROLLER_LOCAL_AUTH_DIR", DEFAULT_LOCAL_AUTH_DIR)
).expanduser()
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(destination, 0o700)
merged["XAI_ENROLLER_SINK"] = "local"
merged["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(destination)
merged.setdefault(
"XAI_ENROLLER_LEDGER_PATH", str(destination / "enrollment-ledger.db")
)
if not merged.get("XAI_ENROLLER_SOURCE_SALT"):
salt_file = destination / ".ledger-salt"
try:
salt = salt_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
salt = secrets.token_hex(32)
fd, temporary_name = tempfile.mkstemp(
prefix=".ledger-salt.", suffix=".tmp", dir=destination, text=True
)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(salt + "\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, salt_file)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
os.chmod(salt_file, 0o600)
merged["XAI_ENROLLER_SOURCE_SALT"] = salt
return merged
@dataclass(frozen=True)
class AuthServiceSettings:
source_kind: str
ssh_host: str | None
register_root: str
remote_root: str
identity_file: str | None
sync_seconds: int = 30
retry_seconds: int = 60
min_authorization_interval_seconds: float = 10.0
@classmethod
def from_environ(cls, env=None):
env = dict(os.environ if env is None else env)
ssh_host = (env.get("XAI_AUTH_SERVICE_SSH_HOST") or "").strip()
requested_source = (env.get("XAI_AUTH_SERVICE_SOURCE") or "auto").strip().lower()
if requested_source not in {"auto", "local", "ssh"}:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_SOURCE must be auto, local, or ssh"
)
source_kind = (
"ssh" if requested_source == "auto" and ssh_host else
"local" if requested_source == "auto" else
requested_source
)
if source_kind == "ssh" and not ssh_host:
raise AuthServiceConfigurationError("XAI_AUTH_SERVICE_SSH_HOST is required")
register_root = (
env.get("XAI_AUTH_SERVICE_REGISTER_ROOT")
or str(Path(__file__).resolve().parents[1])
).strip()
remote_root = (
env.get("XAI_AUTH_SERVICE_REMOTE_ROOT") or "/opt/grok-free-register"
).strip()
identity_file = (env.get("XAI_AUTH_SERVICE_SSH_IDENTITY") or "").strip() or None
try:
sync_seconds = int(env.get("XAI_AUTH_SERVICE_SYNC_SEC", "30"))
retry_seconds = int(env.get("XAI_AUTH_SERVICE_RETRY_SEC", "60"))
min_authorization_interval_seconds = float(
env.get("XAI_AUTH_SERVICE_MIN_INTERVAL_SEC", "10")
)
except ValueError as exc:
raise AuthServiceConfigurationError(
"auth service intervals must be numeric"
) from exc
if not 5 <= sync_seconds <= 3600:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_SYNC_SEC must be between 5 and 3600"
)
if not 30 <= retry_seconds <= 86400:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_RETRY_SEC must be between 30 and 86400"
)
if not 0 <= min_authorization_interval_seconds <= 3600:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC must be between 0 and 3600"
)
return cls(
source_kind=source_kind,
ssh_host=ssh_host or None,
register_root=register_root,
remote_root=remote_root,
identity_file=identity_file,
sync_seconds=sync_seconds,
retry_seconds=retry_seconds,
min_authorization_interval_seconds=min_authorization_interval_seconds,
)
class EventTerminal:
"""Mode-aware aggregate renderer; identifiers and credentials stay hidden."""
_SAFE_REASONS = {
"already_imported",
"authorization_transport_failed",
"browser_error",
"challenge_required",
"confirmation_timeout",
"device_flow_failed",
"device_flow_invalid",
"device_flow_refresh_failed",
"imported",
"internal_error",
"oauth_denied",
"oauth_expired",
"oauth_rejected",
"operator_cancelled",
"rate_limited",
"sink_failed",
"sink_unconfigured",
"snapshot_sync_failed",
"token_failed",
"token_transport_failed",
"unknown_page",
}
_TRANSIENT_REASONS = {
"authorization_transport_failed",
"browser_error",
"challenge_required",
"confirmation_timeout",
"device_flow_failed",
"device_flow_refresh_failed",
"sink_failed",
"token_failed",
"token_transport_failed",
"unknown_page",
}
def __init__(self, mode="user", output=None):
if mode not in {"user", "debug"}:
raise ValueError("terminal mode must be user or debug")
self.mode = mode
self.output = output or self._print
@staticmethod
def _print(message):
print(message, flush=True)
@staticmethod
def _percentage(value):
return f"{100.0 * float(value):.1f}%"
@staticmethod
def _rate(value):
return "" if value is None else f"{float(value):.2f}/分"
@staticmethod
def _pending(value):
return "" if value is None else str(value)
@staticmethod
def _task_suffix(value):
return "" if value is None else f" #{value}"
def _format_user(self, kind, data):
if kind == "startup":
source = {"local": "本机", "ssh": "远端"}.get(
data.get("source_kind"), "等待同步"
)
return (
f"[✓] 本地认证服务已启动 | 来源 {source} | "
f"输出 {data['destination']} | 待处理 — | 可用 {data['available']}"
)
if kind == "service_started":
return "[▶] 认证流水线运行中"
if kind == "service_stopped":
return "[■] 认证服务已停止"
if kind == "source_connected":
return "[✓] 账号来源已连接 | 本地快照可用"
if kind == "source_updated":
return f"[↻] 发现新账号 {data['new']} | 快照共 {data['total']}"
if kind == "source_disconnected":
return "[!] 账号来源暂时断开 | 继续使用上一份有效快照"
if kind == "source_record_rejected":
return "[!] 已忽略一条无效来源记录"
if kind == "rate_limited":
pace = data.get("min_authorization_interval_seconds")
pace_text = (
f" | 恢复后间隔 {pace:.0f}" if pace is not None else ""
)
return (
f"[⏸] 触发限流 | {data['wait_seconds']}秒后单次探测{pace_text}"
)
if kind == "rate_limit_cleared":
return f"[▶] 限流解除 | 实际等待 {data['elapsed_seconds']}"
if kind == "pacing_adjusted":
return (
f"[•] 认证间隔调整为 {data['min_authorization_interval_seconds']:.0f}"
)
if kind == "authorization_started":
return (
f"[→] 开始认证 #{data['task_number']} | "
f"待处理 {self._pending(data.get('pending_total'))}"
)
if kind == "result":
if data.get("status") == "imported":
return (
f"[✓] 认证成功{self._task_suffix(data.get('task_number'))} | "
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
f"累计 {data['imported_unique']} | 可用 {data.get('available', '')}"
)
reason = data.get("reason")
if reason == "rate_limited":
return None
action = "暂时失败,将自动重试" if reason in self._TRANSIENT_REASONS else "已跳过"
return f"[✗] 认证未完成{self._task_suffix(data.get('task_number'))} | {action}"
if kind == "control":
states = {
"paused": "[⏸] 认证服务已暂停",
"running": "[▶] 认证服务已恢复",
"cancelling": "[■] 正在取消当前任务",
"idle": "[•] 当前没有可取消的任务",
"stopping": "[■] 正在安全退出",
"usage: take <positive-count>": "[!] 用法take NN 必须是正整数",
"inventory unavailable": "[!] 当前凭据库存不可用",
"help": (
"[•] 命令 s 状态 | take N 取用 | p 暂停 | "
"r 恢复 | c 取消 | q 退出"
),
"unknown": "[!] 未知命令 | 输入 help 查看命令",
}
return states.get(data.get("state"), "[•] 控制命令已处理")
if kind == "status":
state = {"running": "运行中", "paused": "已暂停", "stopping": "停止中"}.get(
data.get("state"), "未知"
)
cooldown = (
f"限流冷却 {data.get('cooldown_remaining_seconds', 0):.0f}"
if data.get("cooldown")
else "不限流"
)
return (
f"[•] 状态 {state} | 待处理 {self._pending(data.get('pending_total'))} | "
f"阶段 {data.get('active_stage', 'idle')} | "
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
f"累计 {data.get('imported_unique', 0)} | "
f"可用 {data.get('available', 0)} | 已取用 {data.get('claimed', 0)} | {cooldown}"
)
if kind == "inventory_taken":
return (
f"[✓] 已取用 {data['moved']} 个凭据 | "
f"剩余可用 {data['available']} | "
f"位置 claimed/{data['batch_id']}/"
)
if kind == "inventory_error":
return (
f"[!] 凭据取用失败 | 可用 {data['available']} | "
f"处理中 {data['claiming']} | 已取用 {data['claimed']}"
)
if kind == "pipeline_error":
return f"[!] 认证流水线异常 | 阶段 {data.get('stage', 'unknown')}"
return None
def _format_debug(self, kind, data):
if kind == "service_started":
return (
"• service: running; "
f"min_interval={data['min_authorization_interval_seconds']:.1f}s"
)
if kind == "service_stopped":
return "• service: stopped"
if kind == "source_connected":
return "• source: local snapshot updated"
if kind == "source_updated":
return f"• source: new={data['new']}; total={data['total']}"
if kind == "source_disconnected":
return "⚠ source: snapshot_sync_failed; keeping previous snapshot"
if kind == "source_record_rejected":
return "⚠ source: invalid record rejected"
if kind == "rate_limited":
pace = data.get("min_authorization_interval_seconds")
pace_text = f"; resume_interval={pace:.1f}s" if pace is not None else ""
return (
"⏸ authentication rate limited; "
f"next single probe in {data['wait_seconds']}s{pace_text}"
)
if kind == "rate_limit_cleared":
return (
"▶ authentication rate limit cleared after "
f"{data['elapsed_seconds']}s"
)
if kind == "pacing_adjusted":
return (
"• pacing: min_interval="
f"{data['min_authorization_interval_seconds']:.1f}s"
f" ({data.get('reason', 'adjusted')})"
)
if kind == "authorization_started":
return (
"→ authentication: next task started; "
f"task={data['task_number']}; attempt={data['attempt_number']}; "
f"queued={data['source_queue']}; "
f"pending={self._pending(data.get('pending_total'))}"
)
if kind == "result":
if data.get("status") == "imported":
return (
"✓ authentication: imported; "
f"task={data.get('task_number')}; total={data['imported_unique']}; "
f"5m_rate={self._rate(data.get('five_minute_imports_per_minute'))}; "
f"attempt_success={self._percentage(data['attempt_success'])}; "
f"eventual_success={self._percentage(data['eventual_success'])}"
)
reason = data.get("reason")
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
return (
f"⚠ authentication: {data.get('status', 'unknown')} ({safe_reason}); "
f"task={data.get('task_number')}; attempt={data.get('attempt_number')}"
)
if kind == "control":
state = data.get("state", "unknown")
safe_state = state if str(state).replace(" ", "").isalnum() else "unknown"
return f"• service: {safe_state}"
if kind == "status":
rate = data.get("five_minute_imports_per_minute")
rate_text = "unknown" if rate is None else f"{rate:.2f}/min"
lifetime_rate = data.get("lifetime_imports_per_minute")
lifetime_rate_text = (
"unknown"
if lifetime_rate is None
else f"{lifetime_rate:.2f}/min"
)
return (
f"• status: {data['state']}; "
f"queues={data['source_queue']}/{data['prepared_queue']}/"
f"{data['completion_queue']}; active={data['active_stage']}; "
f"retry_waiting={data['retry_waiting']}; "
f"next_retry={data['next_retry_seconds']:.1f}s; "
f"started={data['authorization_starts']}; "
f"cooldown={str(data['cooldown']).lower()}; "
f"cooldown_remaining={data['cooldown_remaining_seconds']:.1f}s; "
f"probe={str(data['probe_in_flight']).lower()}; "
f"min_interval={data['min_authorization_interval_seconds']:.1f}s; "
f"pace_remaining={data['pacing_remaining_seconds']:.1f}s; "
f"imported={data['imported_unique']}; attempted={data['attempted_unique']}; "
f"rate_limited={data['rate_limited']}; 5m_rate={rate_text}; "
f"lifetime_rate={lifetime_rate_text}; "
f"available={data['available']}; claiming={data['claiming']}; "
f"claimed={data['claimed']}"
)
if kind == "inventory_taken":
return (
f"✓ inventory: claimed={data['moved']}; "
f"available={data['available']}; batch={data['batch_id']}"
)
if kind == "inventory_error":
return (
f"⚠ inventory: available={data['available']}; "
f"claiming={data['claiming']}; claimed={data['claimed']}"
)
if kind == "pipeline_error":
reason = data.get("reason")
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
stage = data.get("stage", "unknown")
safe_stage = stage if str(stage).replace("_", "").isalnum() else "unknown"
return f"⚠ service: {safe_stage} stage stopped ({safe_reason})"
known = self._format_user(kind, data)
if known is not None:
return known
reason = data.get("reason")
suffix = f" reason={reason}" if reason in self._SAFE_REASONS else ""
safe_kind = kind if kind.replace("_", "").isalnum() else "unknown"
return f"• debug event={safe_kind}{suffix}"
def emit(self, event):
kind, data = event
try:
message = (
self._format_debug(kind, data)
if self.mode == "debug"
else self._format_user(kind, data)
)
if message is not None:
self.output(message)
except Exception:
return
class AuthPipelineRunner:
"""Map interactive controls onto the persistent pipeline lifecycle."""
def __init__(self, pipeline, emit, *, interval_seconds=None, inventory=None):
self.pipeline = pipeline
self.emit = emit
self.inventory = inventory
self.paused = False
self.current_cycle = None
async def handle_command(self, command):
command = command.strip().lower()
if command == "p":
self.paused = True
self.pipeline.pause()
self.emit(("control", {"state": "paused"}))
elif command == "r":
self.paused = False
self.pipeline.resume()
self.emit(("control", {"state": "running"}))
elif command == "s":
status = self.pipeline.status()
status.update(self.pipeline.ledger.inventory_counts())
self.emit(("status", status))
elif command.startswith("take "):
parts = command.split()
if len(parts) != 2 or not parts[1].isdigit() or int(parts[1]) <= 0:
self.emit(("control", {"state": "usage: take <positive-count>"}))
return True
if self.inventory is None:
self.emit(("control", {"state": "inventory unavailable"}))
return True
try:
batch = await asyncio.to_thread(self.inventory.take, int(parts[1]))
except InventoryError as exc:
self.emit(
(
"inventory_error",
{
"reason": str(exc),
**self.pipeline.ledger.inventory_counts(),
},
)
)
return True
counts = self.pipeline.ledger.inventory_counts()
self.emit(
(
"inventory_taken",
{
"batch_id": batch.batch_id,
"directory": str(batch.directory),
"moved": batch.moved,
**counts,
},
)
)
elif command == "c":
cancelled = await self.pipeline.cancel_active()
self.emit(
("control", {"state": "cancelling" if cancelled else "idle"})
)
elif command in {"help", "h", "?"}:
self.emit(("control", {"state": "help"}))
elif command in {"q", "quit", "exit"}:
self.pipeline.request_stop()
self.emit(("control", {"state": "stopping"}))
return False
elif command:
self.emit(("control", {"state": "unknown"}))
return True
async def run(self):
self.current_cycle = asyncio.create_task(self.pipeline.run())
try:
await self.current_cycle
finally:
self.current_cycle = None
async def _run_interactive(runner, terminal):
loop = asyncio.get_running_loop()
commands = asyncio.Queue()
prompt = InteractiveCommandPrompt()
previous_output = terminal.output
terminal.output = prompt.write_event
prompt.write_event(
"命令s 状态 | take N 取用凭据 | p 暂停 | r 恢复 | c 取消当前任务 | q 退出"
)
try:
prompt.attach(loop, commands.put_nowait)
except Exception:
prompt.close(loop)
terminal.output = previous_output
raise
worker = asyncio.create_task(runner.run())
try:
while not worker.done():
command_task = asyncio.create_task(commands.get())
done, _pending = await asyncio.wait(
{worker, command_task}, return_when=asyncio.FIRST_COMPLETED
)
if worker in done:
command_task.cancel()
with suppress(asyncio.CancelledError):
await command_task
await worker
break
command = command_task.result()
if command is None:
command = "q"
if not await runner.handle_command(command):
break
except asyncio.CancelledError:
runner.pipeline.request_stop()
raise
finally:
prompt.close(loop)
terminal.output = previous_output
runner.pipeline.request_stop()
await asyncio.gather(worker, return_exceptions=True)
async def main_async(*, log_mode="user"):
import httpx
from .auth_pipeline import AuthPipeline
from .config import Settings
from .executors import PlaywrightExecutor
from .inventory import CredentialInventory
from .ledger import Ledger
from .protocol import XAIProfile, XAIProtocol
from .remote_stream import (
DiskSnapshotSource,
LocalSnapshotSynchronizer,
SSHSnapshotSynchronizer,
)
from .sinks import LocalAuthFileSink
service_settings = AuthServiceSettings.from_environ()
merged = prepare_local_service_environment()
merged["XAI_ENROLLER_SOURCE_KIND"] = "remote"
merged["XAI_ENROLLER_AUTH_EXECUTOR"] = "playwright"
merged["XAI_ENROLLER_CONCURRENCY"] = "1"
settings = Settings.from_environ(merged)
terminal = EventTerminal(mode=log_mode)
# CF prewarm via external FlareSolverr (:8191). Separate from REGISTER_PROXY.
try:
from grok_register.clearance import (
format_prewarm_log,
httpx_proxy_mounts,
prewarm_clearance,
register_proxy_url,
)
prewarm = await asyncio.to_thread(prewarm_clearance)
terminal.emit(("control", {"state": format_prewarm_log(prewarm)}))
reg_proxy = register_proxy_url()
if reg_proxy:
terminal.emit(("control", {"state": f"register_proxy={reg_proxy}"}))
proxy_url = httpx_proxy_mounts()
except Exception as exc: # noqa: BLE001
terminal.emit(("control", {"state": f"clearance_prewarm_skip: {exc}"}))
proxy_url = None
# httpx honors REGISTER_PROXY/HTTP_PROXY only when passed explicitly
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
pipeline = None
try:
ledger = Ledger(settings.ledger_path, settings.source_salt)
snapshot_path = Path(settings.local_auth_dir) / "source-snapshot.jsonl"
if service_settings.source_kind == "ssh":
synchronizer = SSHSnapshotSynchronizer(
service_settings.ssh_host,
snapshot_path,
remote_root=service_settings.remote_root,
identity_file=service_settings.identity_file,
fingerprint=ledger.fingerprint,
)
else:
synchronizer = LocalSnapshotSynchronizer(
service_settings.register_root,
snapshot_path,
fingerprint=ledger.fingerprint,
)
source = DiskSnapshotSource(
snapshot_path,
synchronizer=synchronizer,
sync_seconds=service_settings.sync_seconds,
event_callback=lambda kind, data: terminal.emit((kind, data)),
fingerprint=ledger.fingerprint,
)
protocol = XAIProtocol(
client,
XAIProfile.default(),
default_poll_interval=settings.poll_interval,
)
executor = PlaywrightExecutor(concurrency=1)
sink = LocalAuthFileSink(
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
name_secret=settings.source_salt,
)
inventory = CredentialInventory(
ledger,
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
Path(settings.local_auth_dir) / CLAIMED_DIRNAME,
)
recovered = await asyncio.to_thread(inventory.recover)
if recovered:
terminal.emit(("control", {"state": f"recovered {recovered} claims"}))
terminal.emit(
(
"startup",
{
"destination": f"{AUTHENTICATED_DIRNAME}/",
"source_kind": service_settings.source_kind,
**ledger.inventory_counts(),
},
)
)
pipeline = AuthPipeline(
source=source,
protocol=protocol,
executor=executor,
sink=sink,
ledger=ledger,
timeout=settings.timeout_sec,
min_authorization_interval=(
service_settings.min_authorization_interval_seconds
),
event_callback=lambda kind, data: terminal.emit((kind, data)),
)
# Keep base cooldown configurable; the gate grows it after consecutive trips.
pipeline.rate_gate.base_cooldown = float(service_settings.retry_seconds)
pipeline.rate_gate.COOLDOWN_SECONDS = float(service_settings.retry_seconds)
pipeline.rate_gate._current_cooldown = float(service_settings.retry_seconds)
runner = AuthPipelineRunner(pipeline, terminal.emit, inventory=inventory)
await _run_interactive(runner, terminal)
finally:
if pipeline is not None:
pipeline.request_stop()
await client.aclose()
def _known_configuration_key(error):
message = str(error)
keys = (
"XAI_AUTH_SERVICE_LOG_MODE",
"XAI_AUTH_SERVICE_SOURCE",
"XAI_AUTH_SERVICE_SSH_HOST",
"XAI_AUTH_SERVICE_REGISTER_ROOT",
"XAI_AUTH_SERVICE_SYNC_SEC",
"XAI_AUTH_SERVICE_RETRY_SEC",
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC",
"XAI_ENROLLER_TARGET",
"XAI_ENROLLER_RETRY_ATTEMPTS",
"XAI_ENROLLER_TIMEOUT_SEC",
"XAI_ENROLLER_POLL_SEC",
"XAI_ENROLLER_AUTH_EXECUTOR",
"XAI_ENROLLER_SINK",
"XAI_ENROLLER_CPA_BASE_URL",
"XAI_ENROLLER_CPA_MANAGEMENT_SECRET",
"XAI_ENROLLER_LOCAL_AUTH_DIR",
"XAI_ENROLLER_SOURCE_SALT",
)
explicit = next((key for key in keys if key in message), None)
if explicit is not None:
return explicit
message_keys = {
"target must be between 1 and 100": "XAI_ENROLLER_TARGET",
"retry attempts must be between 0 and 3": "XAI_ENROLLER_RETRY_ATTEMPTS",
"timeout must be positive": "XAI_ENROLLER_TIMEOUT_SEC",
"poll interval must be positive": "XAI_ENROLLER_POLL_SEC",
"executor must be http or playwright": "XAI_ENROLLER_AUTH_EXECUTOR",
"sink must be cpa or local": "XAI_ENROLLER_SINK",
"CPA base URL must use HTTPS": "XAI_ENROLLER_CPA_BASE_URL",
"CPA base URL and management secret are required": (
"XAI_ENROLLER_CPA_BASE_URL/XAI_ENROLLER_CPA_MANAGEMENT_SECRET"
),
"local auth directory is required": "XAI_ENROLLER_LOCAL_AUTH_DIR",
"source salt is required": "XAI_ENROLLER_SOURCE_SALT",
}
return message_keys.get(message)
def _print_unexpected_service_failure(error, *, log_mode):
if log_mode == "debug":
detail = f"异常类别 {type(error).__name__}"
else:
detail = "使用 bash auth-service.sh --debug 查看异常类别"
print(f"[✗] 认证服务异常终止 | {detail}", file=sys.stderr)
def main(argv=None):
load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env")
mode = "user"
try:
mode = resolve_auth_log_mode(argv)
asyncio.run(main_async(log_mode=mode))
return 0
except AuthServiceConfigurationError as error:
key = _known_configuration_key(error) or "认证服务参数"
print(
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
file=sys.stderr,
)
return 2
except ValueError as error:
key = _known_configuration_key(error)
if key is not None:
print(
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
file=sys.stderr,
)
return 2
_print_unexpected_service_failure(error, log_mode=mode)
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
_print_unexpected_service_failure(error, log_mode=mode)
return 1
if __name__ == "__main__":
raise SystemExit(main())

106
xai_enroller/sinks.py Normal file
View File

@@ -0,0 +1,106 @@
import asyncio
import hashlib
import hmac
import json
import os
import tempfile
from pathlib import Path
from typing import Protocol
from urllib.parse import urlencode
import httpx
from .models import OAuthCredential, SinkReceipt
class SinkError(RuntimeError):
pass
class CredentialSink(Protocol):
async def store(self, credential: OAuthCredential) -> SinkReceipt: ...
def cpa_document(credential: OAuthCredential):
return {
"type": "xai",
"access_token": credential.access_token,
"refresh_token": credential.refresh_token,
"id_token": credential.id_token,
"token_type": credential.token_type,
"expires_in": credential.expires_in,
"expired": credential.expires_at,
"last_refresh": credential.last_refresh,
"sub": credential.subject,
"base_url": "https://api.x.ai/v1",
"token_endpoint": credential.token_endpoint,
"auth_kind": "oauth",
}
def credential_filename(credential: OAuthCredential, name_secret: bytes):
subject = credential.subject or credential.refresh_token
digest = hmac.new(name_secret, subject.encode(), hashlib.sha256).hexdigest()[:16]
return f"xai-{digest}.json"
class CPAAuthFileSink:
def __init__(self, base_url, management_secret, client: httpx.AsyncClient, name_secret=None):
self.base_url = base_url.rstrip("/")
self.management_secret = management_secret
self.client = client
self.name_secret = name_secret or management_secret.encode()
async def store(self, credential: OAuthCredential):
filename = credential_filename(credential, self.name_secret)
document = cpa_document(credential)
response = await self.client.post(
f"{self.base_url}/v0/management/auth-files?{urlencode({'name': filename})}",
headers={
"Authorization": f"Bearer {self.management_secret}",
"Content-Type": "application/json",
},
json=document,
follow_redirects=False,
)
if response.status_code // 100 != 2:
raise SinkError("CPA upload rejected")
return SinkReceipt(filename.removesuffix(".json"))
class LocalAuthFileSink:
"""Atomically persist canonical CPA-compatible documents locally."""
def __init__(self, directory, *, name_secret: bytes):
self.directory = Path(directory).expanduser()
self.name_secret = name_secret
async def store(self, credential: OAuthCredential):
return await asyncio.to_thread(self._store_sync, credential)
def _store_sync(self, credential: OAuthCredential):
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.directory, 0o700)
filename = credential_filename(credential, self.name_secret)
destination = self.directory / filename
payload = json.dumps(cpa_document(credential), ensure_ascii=False, indent=2) + "\n"
fd, temporary_name = tempfile.mkstemp(
prefix=f".{filename}.", suffix=".tmp", dir=self.directory, text=True
)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, destination)
os.chmod(destination, 0o600)
directory_fd = os.open(self.directory, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return SinkReceipt(filename.removesuffix(".json"))

61
xai_enroller/sources.py Normal file
View File

@@ -0,0 +1,61 @@
import base64
import hashlib
import hmac
import sqlite3
from pathlib import Path
from typing import Iterator, Protocol
from .config import require_private_regular_file
from .models import SourceRecord
class SourceAdapter(Protocol):
def records(self) -> Iterator[SourceRecord]: ...
class FileSourceAdapter:
def __init__(self, path: Path):
self.path = Path(path)
def records(self) -> Iterator[SourceRecord]:
require_private_regular_file(self.path, require_0600=True)
seen = set()
with self.path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
raw = line.rstrip("\r\n")
if not raw:
continue
source_id, separator, token = raw.partition("\t")
if not separator or not source_id or not token:
raise ValueError(f"invalid source line {line_number}")
if source_id in seen:
continue
seen.add(source_id)
yield SourceRecord(source_id, token)
class SQLiteSourceAdapter:
def __init__(self, path: Path, salt: bytes):
self.path = Path(path)
self.salt = bytes(salt)
def records(self) -> Iterator[SourceRecord]:
require_private_regular_file(self.path)
uri = f"file:{self.path.absolute()}?mode=ro"
connection = sqlite3.connect(uri, uri=True)
try:
rows = connection.execute(
"SELECT token FROM accounts "
"WHERE status = 'active' AND deleted_at IS NULL "
"ORDER BY updated_at DESC"
)
seen = set()
for (token,) in rows:
fingerprint = hmac.new(self.salt, token.encode(), hashlib.sha256).digest()
source_id = base64.urlsafe_b64encode(fingerprint).rstrip(b"=").decode()
if source_id in seen:
continue
seen.add(source_id)
yield SourceRecord(source_id, token)
finally:
connection.close()