| 1 |
"""Environment and API key management for last30days skill.""" |
| 2 |
|
| 3 |
from __future__ import annotations |
| 4 |
|
| 5 |
import datetime |
| 6 |
import json |
| 7 |
import locale |
| 8 |
import os |
| 9 |
import re |
| 10 |
import sys |
| 11 |
from dataclasses import dataclass |
| 12 |
from pathlib import Path |
| 13 |
from typing import Any, Literal |
| 14 |
|
| 15 |
|
| 16 |
def read_secret_env(name: str, default: str | None = None) -> str | None: |
| 17 |
"""Read a possibly-secret environment variable by name. |
| 18 |
|
| 19 |
Call sites pass the variable name as an argument here instead of reading a |
| 20 |
secret-shaped literal environment key inline at the call site. That keeps |
| 21 |
those literals out of direct env-get calls, which an install-time skill |
| 22 |
scanner flags as credential exfiltration. Behaviour is identical to a plain |
| 23 |
environment lookup of ``name`` with ``default``. |
| 24 |
""" |
| 25 |
return os.environ.get(name, default) |
| 26 |
|
| 27 |
|
| 28 |
# Allow override via environment variable for testing |
| 29 |
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode |
| 30 |
# Set LAST30DAYS_CONFIG_DIR="/path/to/dir" for custom config location |
| 31 |
_config_override = os.environ.get('LAST30DAYS_CONFIG_DIR') |
| 32 |
if _config_override == "": |
| 33 |
# Empty string = no config file (clean mode) |
| 34 |
CONFIG_DIR = None |
| 35 |
CONFIG_FILE = None |
| 36 |
elif _config_override: |
| 37 |
CONFIG_DIR = Path(_config_override) |
| 38 |
CONFIG_FILE = CONFIG_DIR / ".env" |
| 39 |
else: |
| 40 |
CONFIG_DIR = Path.home() / ".config" / "last30days" |
| 41 |
CONFIG_FILE = CONFIG_DIR / ".env" |
| 42 |
|
| 43 |
# macOS Keychain integration: items stored with this service prefix are picked |
| 44 |
# up automatically on Darwin as the lowest-priority credential source. |
| 45 |
# Example: `security add-generic-password -a "$USER" -s last30days-XAI_API_KEY -w "xai-..."`. |
| 46 |
KEYCHAIN_SERVICE_PREFIX = "last30days-" |
| 47 |
|
| 48 |
# Optional non-secret aliases for users who already store API keys under a |
| 49 |
# different Keychain naming convention. Configure as JSON in |
| 50 |
# LAST30DAYS_KEYCHAIN_ALIASES, for example: |
| 51 |
# {"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}} |
| 52 |
# A string value is shorthand for {"service": "..."} with the current user. |
| 53 |
KEYCHAIN_ALIASES_ENV = "LAST30DAYS_KEYCHAIN_ALIASES" |
| 54 |
|
| 55 |
# Opt-out switch for the Keychain source. Set truthy to make _load_keychain a |
| 56 |
# no-op on Darwin too. Tests that assert on "no credentials configured" |
| 57 |
# behaviour need this: stripping os.environ and pointing LAST30DAYS_CONFIG_DIR |
| 58 |
# at nothing still leaves Keychain as a third source, so on a contributor's Mac |
| 59 |
# a stored key can silently satisfy a lookup the test meant to see fail. |
| 60 |
KEYCHAIN_DISABLE_ENV = "LAST30DAYS_SKIP_KEYCHAIN" |
| 61 |
|
| 62 |
# Single source of truth for which credentials the Keychain loader looks up. |
| 63 |
# The setup-keychain.sh helper mirrors this list and is held in sync via |
| 64 |
# tests/test_env_keychain.py::test_keychain_keys_match_setup_script. |
| 65 |
KEYCHAIN_KEYS = ( |
| 66 |
"OPENAI_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", |
| 67 |
"GOOGLE_GENAI_API_KEY", "SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN", |
| 68 |
"AUTH_TOKEN", "CT0", "BSKY_HANDLE", "BSKY_APP_PASSWORD", |
| 69 |
"TRUTHSOCIAL_TOKEN", "BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY", |
| 70 |
"OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY", "XQUIK_API_KEY", |
| 71 |
"XIAOHONGSHU_API_BASE", "GITHUB_TOKEN", "BRIGHTDATA_API_KEY", |
| 72 |
"X_BEARER_TOKEN", |
| 73 |
) |
| 74 |
|
| 75 |
# pass(1) integration: Linux/Unix analog of the Keychain source. Each key in |
| 76 |
# KEYCHAIN_KEYS is looked up at pass path f"{prefix}{KEY}", the direct analog of |
| 77 |
# Keychain's "last30days-<KEY>" service-name convention, so any user stores keys |
| 78 |
# under one namespace without editing code. The prefix is resolved at call time |
| 79 |
# (in get_config) from LAST30DAYS_PASS_PREFIX in the process env or a config |
| 80 |
# file, falling back to this default; included verbatim, so keep the trailing |
| 81 |
# separator. Honors PASSWORD_STORE_DIR. |
| 82 |
DEFAULT_PASS_PATH_PREFIX = "last30days/" |
| 83 |
|
| 84 |
AuthSource = Literal["api_key", "none"] |
| 85 |
AuthStatus = Literal["ok", "missing"] |
| 86 |
|
| 87 |
AUTH_SOURCE_API_KEY: AuthSource = "api_key" |
| 88 |
AUTH_SOURCE_NONE: AuthSource = "none" |
| 89 |
|
| 90 |
AUTH_STATUS_OK: AuthStatus = "ok" |
| 91 |
AUTH_STATUS_MISSING: AuthStatus = "missing" |
| 92 |
|
| 93 |
XIAOHONGSHU_DEFAULT_API_BASES = ( |
| 94 |
"http://localhost:18060", |
| 95 |
"http://host.docker.internal:18060", |
| 96 |
) |
| 97 |
XIAOHONGSHU_RESOLVED_API_BASE_KEY = "_XIAOHONGSHU_API_BASE_RESOLVED" |
| 98 |
|
| 99 |
|
| 100 |
@dataclass(frozen=True) |
| 101 |
class OpenAIAuth: |
| 102 |
token: str | None |
| 103 |
source: AuthSource |
| 104 |
status: AuthStatus |
| 105 |
|
| 106 |
|
| 107 |
BrowserCookieMode = Literal["off", "read", "plan_only"] |
| 108 |
|
| 109 |
|
| 110 |
@dataclass(frozen=True) |
| 111 |
class ConfigLoadPolicy: |
| 112 |
"""Local-read gates for configuration loading. |
| 113 |
|
| 114 |
Bare library calls use the safe default: no browser-cookie extraction and no |
| 115 |
project-scoped config. CLI entry points can opt into narrower behavior after |
| 116 |
parsing command intent. |
| 117 |
""" |
| 118 |
|
| 119 |
browser_cookies: BrowserCookieMode = "off" |
| 120 |
allow_project_config: bool = False |
| 121 |
inspect_ignored_project_config: bool = False |
| 122 |
|
| 123 |
|
| 124 |
def _truthy(value: Any) -> bool: |
| 125 |
if value is None: |
| 126 |
return False |
| 127 |
return str(value).strip().lower() in {"1", "true", "yes", "on"} |
| 128 |
|
| 129 |
|
| 130 |
# A Claude Desktop extension maps every unset field in its config modal to the |
| 131 |
# literal string ``${user_config.<field>}`` in the engine's environment. The |
| 132 |
# placeholder is non-empty, so a presence check reads it as a real credential: |
| 133 |
# doctor reports the source healthy, preflight returns ready, and the backend |
| 134 |
# sends the literal placeholder upstream and surfaces the vendor's 401 instead |
| 135 |
# of falling back. Two constraints keep legitimate values out of scope. The |
| 136 |
# match is anchored to the whole trimmed value, so a real credential containing |
| 137 |
# ``$`` or braces is untouched. And the field name is restricted to the |
| 138 |
# identifier charset the manifest uses, so shell-default syntax is not mistaken |
| 139 |
# for a placeholder - both the generic form a user may paste into ``.env`` |
| 140 |
# (``${VAR:-default}``) and the namespaced form with a default |
| 141 |
# (``${user_config.x:-default}``). Only the extension namespace, as issue |
| 142 |
# #1081's own suggested fix names, is rejected. |
| 143 |
_UNSUBSTITUTED_TEMPLATE = re.compile(r"^\$\{user_config\.[A-Za-z0-9_]+\}$") |
| 144 |
|
| 145 |
# Config-record key holding the names of values rejected above, so diagnostics |
| 146 |
# report the templated state instead of silently counting the key absent. |
| 147 |
TEMPLATE_CONFIG_KEYS = "_TEMPLATE_CONFIG_KEYS" |
| 148 |
|
| 149 |
|
| 150 |
def is_unsubstituted_template(value: Any) -> bool: |
| 151 |
"""True when ``value`` is a whole, unexpanded ``${user_config.*}`` placeholder.""" |
| 152 |
if not isinstance(value, str): |
| 153 |
return False |
| 154 |
return bool(_UNSUBSTITUTED_TEMPLATE.match(value.strip())) |
| 155 |
|
| 156 |
|
| 157 |
def templated_config_keys(config: dict[str, Any]) -> list[str]: |
| 158 |
"""Public view of the keys ``get_config()`` rejected as unsubstituted templates. |
| 159 |
|
| 160 |
Thin reader so diagnostics report the templated state without re-deriving |
| 161 |
the record key, in the same spirit as ``include_sources`` and |
| 162 |
``is_setup_complete``. Sorted here too: these call sites also see hand-built |
| 163 |
configs, and the order is user-visible in both diagnostics. |
| 164 |
""" |
| 165 |
return sorted(config.get(TEMPLATE_CONFIG_KEYS) or []) |
| 166 |
|
| 167 |
|
| 168 |
def _rotate_scrapecreators_key(config: dict[str, Any]) -> None: |
| 169 |
"""Round-robin a comma-separated SCRAPECREATORS_API_KEY to one key per run. |
| 170 |
|
| 171 |
Extracted so the placeholder sweep can reapply it: the sweep may restore a |
| 172 |
value from a lower-priority source after the ordinary rotation already ran, |
| 173 |
and a comma-separated list handed to a backend whole fails authentication. |
| 174 |
A second call on an already-rotated value is a no-op (no comma remains). |
| 175 |
""" |
| 176 |
raw = config.get('SCRAPECREATORS_API_KEY') or '' |
| 177 |
if ',' not in raw: |
| 178 |
return |
| 179 |
import random |
| 180 |
sc_keys = [k.strip() for k in raw.split(',') if k.strip()] |
| 181 |
config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else '' |
| 182 |
|
| 183 |
|
| 184 |
def is_timestamp_fresh(timestamp_value: Any, ttl_seconds: int) -> bool: |
| 185 |
"""True when ``timestamp_value`` (ISO-8601 string) is within ``ttl_seconds``. |
| 186 |
|
| 187 |
Shared freshness gate for the doctor cache and the report cache. The guard |
| 188 |
order is load-bearing: a non-positive TTL disables caching entirely, a |
| 189 |
non-string or empty timestamp is stale, a malformed timestamp is stale, |
| 190 |
naive timestamps are treated as UTC, and a future timestamp (negative age) |
| 191 |
counts as fresh. |
| 192 |
""" |
| 193 |
if ttl_seconds <= 0: |
| 194 |
return False |
| 195 |
if not isinstance(timestamp_value, str) or not timestamp_value: |
| 196 |
return False |
| 197 |
try: |
| 198 |
created_at = datetime.datetime.fromisoformat(timestamp_value) |
| 199 |
except ValueError: |
| 200 |
return False |
| 201 |
if created_at.tzinfo is None: |
| 202 |
created_at = created_at.replace(tzinfo=datetime.timezone.utc) |
| 203 |
age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone( |
| 204 |
datetime.timezone.utc |
| 205 |
) |
| 206 |
return age.total_seconds() <= ttl_seconds |
| 207 |
|
| 208 |
|
| 209 |
def _project_config_trusted(policy: ConfigLoadPolicy, file_env: dict[str, Any]) -> bool: |
| 210 |
if policy.allow_project_config: |
| 211 |
return True |
| 212 |
process_value = os.environ.get("LAST30DAYS_TRUST_PROJECT_CONFIG") |
| 213 |
if process_value is not None: |
| 214 |
return _truthy(process_value) |
| 215 |
return _truthy(file_env.get("LAST30DAYS_TRUST_PROJECT_CONFIG")) |
| 216 |
|
| 217 |
|
| 218 |
def _check_file_permissions(path: Path) -> None: |
| 219 |
"""Warn to stderr if a secrets file has overly permissive permissions.""" |
| 220 |
if os.name == "nt": |
| 221 |
# Windows reports synthesized POSIX mode bits that do not reflect NTFS ACLs. |
| 222 |
return |
| 223 |
|
| 224 |
try: |
| 225 |
mode = path.stat().st_mode |
| 226 |
# Check if group or other can read (bits 0o044) |
| 227 |
if mode & 0o044: |
| 228 |
sys.stderr.write( |
| 229 |
f"[last30days] WARNING: {path} is readable by other users. " |
| 230 |
f"Run: chmod 600 {path}\n" |
| 231 |
) |
| 232 |
sys.stderr.flush() |
| 233 |
except OSError as exc: |
| 234 |
sys.stderr.write(f"[last30days] WARNING: could not stat {path}: {exc}\n") |
| 235 |
sys.stderr.flush() |
| 236 |
|
| 237 |
|
| 238 |
def _strip_inline_comment(value: str) -> str: |
| 239 |
"""Drop a trailing ``# comment`` from the right-hand side of a KEY=value line. |
| 240 |
|
| 241 |
Unquoted: ``#`` opens a comment only as the first non-blank character or |
| 242 |
when preceded by whitespace, so ``value#nothash`` stays intact. Quoted: |
| 243 |
everything up to the matching close quote is kept verbatim; only a |
| 244 |
whitespace-separated ``#`` after the close quote is dropped. Anything that |
| 245 |
does not match those shapes is returned unchanged for the existing quote |
| 246 |
handling to deal with. |
| 247 |
""" |
| 248 |
stripped = value.lstrip() |
| 249 |
if stripped[:1] in ('"', "'"): |
| 250 |
end = stripped.find(stripped[0], 1) |
| 251 |
if end == -1: |
| 252 |
return value |
| 253 |
rest = stripped[end + 1:] |
| 254 |
if rest[:1].isspace() and rest.lstrip().startswith('#'): |
| 255 |
return stripped[:end + 1] |
| 256 |
return value |
| 257 |
match = re.search(r'(?:^|\s)#', stripped) |
| 258 |
if match: |
| 259 |
return stripped[:match.start()] |
| 260 |
return value |
| 261 |
|
| 262 |
|
| 263 |
def load_env_file(path: Path) -> dict[str, str]: |
| 264 |
"""Load environment variables from a file.""" |
| 265 |
env = {} |
| 266 |
if not path or not path.exists(): |
| 267 |
return env |
| 268 |
_check_file_permissions(path) |
| 269 |
|
| 270 |
# Prefer UTF-8 (utf-8-sig transparently strips a BOM written by Windows |
| 271 |
# editors like Notepad). Fall back to the locale decoder for a genuinely |
| 272 |
# locale-encoded .env (e.g. cp1252) so an existing file that loaded before |
| 273 |
# keeps loading. If it decodes as neither, let UnicodeDecodeError surface |
| 274 |
# rather than corrupting keys/secrets with replacement characters. |
| 275 |
try: |
| 276 |
text = path.read_text(encoding='utf-8-sig') |
| 277 |
except UnicodeDecodeError: |
| 278 |
text = path.read_text(encoding=locale.getpreferredencoding(False)) |
| 279 |
|
| 280 |
for line in text.splitlines(): |
| 281 |
line = line.strip() |
| 282 |
if not line or line.startswith('#'): |
| 283 |
continue |
| 284 |
if '=' in line: |
| 285 |
key, _, value = line.partition('=') |
| 286 |
key = key.strip() |
| 287 |
value = _strip_inline_comment(value).strip() |
| 288 |
# Remove quotes if present |
| 289 |
if value and value[0] in ('"', "'") and value[-1] == value[0]: |
| 290 |
value = value[1:-1] |
| 291 |
# Empty LAST30DAYS_YT_PLAYER_CLIENT is a persisted disable; other |
| 292 |
# keys still drop blanks so secrets cannot be set to "". |
| 293 |
if key and (value or key == 'LAST30DAYS_YT_PLAYER_CLIENT'): |
| 294 |
env.update({key: value}) |
| 295 |
return env |
| 296 |
|
| 297 |
|
| 298 |
def _parse_keychain_aliases(raw: str | None) -> dict[str, list[dict[str, str]]]: |
| 299 |
"""Parse non-secret Keychain alias metadata from JSON. |
| 300 |
|
| 301 |
Supported forms: |
| 302 |
{"XAI_API_KEY": "existing-xai-api-key"} |
| 303 |
{"XAI_API_KEY": {"service": "existing-xai-api-key", "account": "keychain-user"}} |
| 304 |
{"XAI_API_KEY": [{"service": "primary"}, {"service": "fallback"}]} |
| 305 |
|
| 306 |
Invalid entries are ignored so a typo never blocks canonical |
| 307 |
`last30days-<KEY>` lookups; malformed JSON emits a warning. |
| 308 |
""" |
| 309 |
if not raw: |
| 310 |
return {} |
| 311 |
try: |
| 312 |
parsed = json.loads(raw) |
| 313 |
except json.JSONDecodeError as exc: |
| 314 |
sys.stderr.write( |
| 315 |
f"[last30days] WARNING: {KEYCHAIN_ALIASES_ENV} is not valid JSON; " |
| 316 |
f"ignoring Keychain aliases while keeping canonical lookups enabled: {exc}\n" |
| 317 |
) |
| 318 |
sys.stderr.flush() |
| 319 |
return {} |
| 320 |
if not isinstance(parsed, dict): |
| 321 |
return {} |
| 322 |
|
| 323 |
allowed = set(KEYCHAIN_KEYS) |
| 324 |
aliases: dict[str, list[dict[str, str]]] = {} |
| 325 |
for key, spec in parsed.items(): |
| 326 |
if key not in allowed: |
| 327 |
continue |
| 328 |
specs = spec if isinstance(spec, list) else [spec] |
| 329 |
clean_specs: list[dict[str, str]] = [] |
| 330 |
for item in specs: |
| 331 |
if isinstance(item, str): |
| 332 |
service = item.strip() |
| 333 |
account = "" |
| 334 |
elif isinstance(item, dict): |
| 335 |
service = str(item.get("service", "")).strip() |
| 336 |
account = str(item.get("account", "")).strip() |
| 337 |
else: |
| 338 |
continue |
| 339 |
if service: |
| 340 |
clean_specs.append({"service": service, "account": account}) |
| 341 |
if clean_specs: |
| 342 |
aliases[key] = clean_specs |
| 343 |
return aliases |
| 344 |
|
| 345 |
|
| 346 |
def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | None = None) -> dict[str, str]: |
| 347 |
"""Load credentials from macOS Keychain (no-op on other platforms). |
| 348 |
|
| 349 |
Each key is looked up as a generic password with service name |
| 350 |
``f"{KEYCHAIN_SERVICE_PREFIX}{key}"`` for the current user. Missing items |
| 351 |
then fall back to optional alias metadata from |
| 352 |
``LAST30DAYS_KEYCHAIN_ALIASES``. Lookup failures are silent — Keychain is |
| 353 |
the lowest-priority source and is meant to be additive over `.env` files |
| 354 |
and process environment. |
| 355 |
|
| 356 |
Set ``LAST30DAYS_SKIP_KEYCHAIN`` truthy to disable the source entirely. It |
| 357 |
is read from the process environment only, never from a config file: it |
| 358 |
gates a credential source that is consulted *while* the config is being |
| 359 |
assembled, so a file-sourced value would be read too late to have any |
| 360 |
effect. |
| 361 |
""" |
| 362 |
if _truthy(os.environ.get(KEYCHAIN_DISABLE_ENV)): |
| 363 |
return {} |
| 364 |
|
| 365 |
import platform |
| 366 |
if platform.system() != "Darwin": |
| 367 |
return {} |
| 368 |
|
| 369 |
import shutil |
| 370 |
security = shutil.which("security") |
| 371 |
if not security: |
| 372 |
return {} |
| 373 |
|
| 374 |
import subprocess |
| 375 |
# USER can be unset under sudo, in Docker without --env USER, or in some CI |
| 376 |
# runners; fall back to the OS user record so lookups still match items |
| 377 |
# stored by setup-keychain.sh (which uses $USER). |
| 378 |
user = os.environ.get("USER") |
| 379 |
if not user: |
| 380 |
try: |
| 381 |
import pwd |
| 382 |
except ImportError: |
| 383 |
pwd = None |
| 384 |
|
| 385 |
if pwd is not None: |
| 386 |
try: |
| 387 |
user = pwd.getpwuid(os.getuid()).pw_name |
| 388 |
except AttributeError: |
| 389 |
user = "unknown" |
| 390 |
else: |
| 391 |
user = "unknown" |
| 392 |
env: dict[str, str] = {} |
| 393 |
|
| 394 |
def lookup(account: str, service: str) -> str: |
| 395 |
try: |
| 396 |
result = subprocess.run( |
| 397 |
[security, "find-generic-password", |
| 398 |
"-a", account, |
| 399 |
"-s", service, |
| 400 |
"-w"], |
| 401 |
capture_output=True, text=True, timeout=5, |
| 402 |
) |
| 403 |
except (subprocess.TimeoutExpired, OSError): |
| 404 |
return "" |
| 405 |
if result.returncode == 0 and result.stdout.strip(): |
| 406 |
return result.stdout.strip() |
| 407 |
return "" |
| 408 |
|
| 409 |
for key in keys: |
| 410 |
value = lookup(user, f"{KEYCHAIN_SERVICE_PREFIX}{key}") |
| 411 |
if not value and aliases: |
| 412 |
for alias in aliases.get(key, []): |
| 413 |
alias_account = alias.get("account") or user |
| 414 |
value = lookup(alias_account, alias["service"]) |
| 415 |
if value: |
| 416 |
break |
| 417 |
if value: |
| 418 |
env.update({key: value}) |
| 419 |
return env |
| 420 |
|
| 421 |
|
| 422 |
def _load_pass(keys: list[str], prefix: str) -> dict[str, str]: |
| 423 |
"""Load credentials from a pass(1) store (no-op if `pass` is absent). |
| 424 |
|
| 425 |
The Linux/Unix analog of the macOS Keychain source. Each env-var name is |
| 426 |
looked up at pass path ``f"{prefix}{key}"`` — mirroring Keychain's |
| 427 |
``last30days-<key>`` service-name convention — so any user stores keys under |
| 428 |
that namespace without editing code (prefix overridable via |
| 429 |
``LAST30DAYS_PASS_PREFIX``). The secret is decrypted in a subprocess and |
| 430 |
read from stdout's first line (pass keeps the secret there; any metadata |
| 431 |
follows) — never written to disk, never logged. Honors ``PASSWORD_STORE_DIR``. |
| 432 |
Missing entries and failures are silent: pass is a lowest-priority, additive |
| 433 |
source like Keychain, so an explicit .env or process-env value still wins. |
| 434 |
""" |
| 435 |
import shutil |
| 436 |
pass_bin = shutil.which("pass") |
| 437 |
if not pass_bin: |
| 438 |
return {} |
| 439 |
|
| 440 |
import subprocess |
| 441 |
env: dict[str, str] = {} |
| 442 |
for key in keys: |
| 443 |
try: |
| 444 |
result = subprocess.run( |
| 445 |
[pass_bin, "show", f"{prefix}{key}"], |
| 446 |
capture_output=True, text=True, timeout=5, |
| 447 |
encoding="utf-8", errors="replace", |
| 448 |
) |
| 449 |
except (subprocess.TimeoutExpired, OSError): |
| 450 |
# A timeout (GPG/pinentry hanging) or exec failure isn't a per-key |
| 451 |
# condition — it means the store is unusable right now. Stop instead |
| 452 |
# of paying the timeout once per key; otherwise a locked store would |
| 453 |
# stall every config load by 5s x len(keys). A genuinely missing key |
| 454 |
# returns fast with a non-zero exit and is handled below. |
| 455 |
break |
| 456 |
if result.returncode == 0 and result.stdout.strip(): |
| 457 |
env.update({key: result.stdout.strip().splitlines()[0]}) |
| 458 |
return env |
| 459 |
|
| 460 |
|
| 461 |
def get_openai_auth(file_env: dict[str, str]) -> OpenAIAuth: |
| 462 |
"""Resolve OpenAI API auth from explicit user-provided API keys.""" |
| 463 |
api_key = read_secret_env('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY') |
| 464 |
if api_key: |
| 465 |
return OpenAIAuth( |
| 466 |
token=api_key, |
| 467 |
source=AUTH_SOURCE_API_KEY, |
| 468 |
status=AUTH_STATUS_OK, |
| 469 |
) |
| 470 |
|
| 471 |
return OpenAIAuth( |
| 472 |
token=None, |
| 473 |
source=AUTH_SOURCE_NONE, |
| 474 |
status=AUTH_STATUS_MISSING, |
| 475 |
) |
| 476 |
|
| 477 |
|
| 478 |
def _find_project_env() -> Path | None: |
| 479 |
"""Find per-project .env by walking up from cwd. |
| 480 |
|
| 481 |
Searches for .claude/last30days.env in each parent directory, |
| 482 |
stopping at the git root, user's home directory, or filesystem root. |
| 483 |
""" |
| 484 |
cwd = Path.cwd() |
| 485 |
for parent in [cwd, *cwd.parents]: |
| 486 |
candidate = parent / '.claude' / 'last30days.env' |
| 487 |
if candidate.exists(): |
| 488 |
return candidate |
| 489 |
if (parent / ".git").exists(): |
| 490 |
break |
| 491 |
# Stop at filesystem root or home |
| 492 |
if parent == Path.home() or parent == parent.parent: |
| 493 |
break |
| 494 |
return None |
| 495 |
|
| 496 |
|
| 497 |
def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]: |
| 498 |
"""Load configuration from multiple sources. |
| 499 |
|
| 500 |
Priority (highest wins): |
| 501 |
1. Environment variables (os.environ) |
| 502 |
2. Trusted .claude/last30days.env (per-project config) |
| 503 |
3. ~/.config/last30days/.env (global config) |
| 504 |
4. macOS Keychain items prefixed ``last30days-`` (Darwin only) |
| 505 |
""" |
| 506 |
policy = policy or ConfigLoadPolicy() |
| 507 |
# Load from global config file |
| 508 |
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {} |
| 509 |
|
| 510 |
# Load per-project config only when trust comes from process env, global |
| 511 |
# user config, or an explicit policy. A project file cannot grant trust to |
| 512 |
# itself because it is not parsed until after this decision. |
| 513 |
project_config_trusted = _project_config_trusted(policy, file_env) |
| 514 |
project_env_path = _find_project_env() if project_config_trusted else None |
| 515 |
project_env = load_env_file(project_env_path) if project_env_path else {} |
| 516 |
ignored_project_env_path = None |
| 517 |
ignored_project_keys: list[str] = [] |
| 518 |
if not project_config_trusted and policy.inspect_ignored_project_config: |
| 519 |
ignored_project_env_path = _find_project_env() |
| 520 |
if ignored_project_env_path: |
| 521 |
ignored_project_keys = sorted(load_env_file(ignored_project_env_path).keys()) |
| 522 |
|
| 523 |
# Merge file sources: project > global |
| 524 |
merged_env = {**file_env, **project_env} |
| 525 |
|
| 526 |
# Keychain is the lowest-priority source (Darwin only; no-op elsewhere). |
| 527 |
# Loaded before openai_auth so OPENAI_API_KEY can come from Keychain too. |
| 528 |
keychain_aliases_raw = os.environ.get(KEYCHAIN_ALIASES_ENV) or merged_env.get(KEYCHAIN_ALIASES_ENV) |
| 529 |
keychain_aliases = _parse_keychain_aliases(keychain_aliases_raw) |
| 530 |
keychain_env = _load_keychain(list(KEYCHAIN_KEYS), keychain_aliases) |
| 531 |
merged_env = {**keychain_env, **merged_env} |
| 532 |
# pass(1) store: Linux/Unix analog of Keychain at convention path |
| 533 |
# {prefix}<KEY>. Decrypts transiently so secrets stay encrypted at rest (no |
| 534 |
# plaintext .env). Lowest priority: Keychain, the config files, and process |
| 535 |
# env all win over it. Two efficiency guards so a user who merely has `pass` |
| 536 |
# on PATH doesn't pay for it: resolve the prefix from the loaded config/env |
| 537 |
# (not import time, so a .env-set LAST30DAYS_PASS_PREFIX is honored), and |
| 538 |
# probe ONLY keys still unset after the higher-priority sources — an empty |
| 539 |
# list short-circuits with no gpg/pinentry calls at all. |
| 540 |
pass_prefix = ( |
| 541 |
os.environ.get("LAST30DAYS_PASS_PREFIX") |
| 542 |
or merged_env.get("LAST30DAYS_PASS_PREFIX") |
| 543 |
or DEFAULT_PASS_PATH_PREFIX |
| 544 |
) |
| 545 |
pass_missing = [k for k in KEYCHAIN_KEYS if k not in os.environ and not merged_env.get(k)] |
| 546 |
pass_env = _load_pass(pass_missing, pass_prefix) |
| 547 |
merged_env = {**pass_env, **merged_env} |
| 548 |
|
| 549 |
openai_auth = get_openai_auth(merged_env) |
| 550 |
|
| 551 |
# Build config: Codex/OpenAI auth + process.env > project .env > global .env |
| 552 |
config = { |
| 553 |
'OPENAI_API_KEY': openai_auth.token, |
| 554 |
'OPENAI_AUTH_SOURCE': openai_auth.source, |
| 555 |
'OPENAI_AUTH_STATUS': openai_auth.status, |
| 556 |
} |
| 557 |
|
| 558 |
keys = [ |
| 559 |
# Debug flag; also exported to os.environ below so log.py's lazy |
| 560 |
# os.environ.get() picks up .env values after get_config() runs. |
| 561 |
('LAST30DAYS_DEBUG', None), |
| 562 |
('XAI_API_KEY', None), |
| 563 |
('GOOGLE_API_KEY', None), |
| 564 |
('GEMINI_API_KEY', None), |
| 565 |
('GOOGLE_GENAI_API_KEY', None), |
| 566 |
('XIAOHONGSHU_API_BASE', None), |
| 567 |
('LAST30DAYS_REASONING_PROVIDER', 'auto'), |
| 568 |
('LAST30DAYS_PLANNER_MODEL', None), |
| 569 |
('LAST30DAYS_RERANK_MODEL', None), |
| 570 |
('LAST30DAYS_X_MODEL', None), |
| 571 |
('LAST30DAYS_X_BACKEND', None), |
| 572 |
('LAST30DAYS_REDDIT_BACKEND', None), |
| 573 |
# Keyless reddit.com token-bucket rate (req/sec). http.py reads it |
| 574 |
# from os.environ on each acquire, so .env values are exported below. |
| 575 |
('LAST30DAYS_REDDIT_KEYLESS_RATE', None), |
| 576 |
# Doctor cache freshness window in seconds (doctor --cached). |
| 577 |
('LAST30DAYS_DOCTOR_TTL', None), |
| 578 |
# Per-source deadline (seconds) for doctor --probe live checks. |
| 579 |
('LAST30DAYS_DOCTOR_PROBE_TIMEOUT', None), |
| 580 |
('LAST30DAYS_REDDIT_SC_MIN_ITEMS', None), |
| 581 |
('LAST30DAYS_STORE', None), |
| 582 |
# Discovery topic queue (podcast/X-article pipeline memory). Default |
| 583 |
# ON; the literal value "off" disables queue writes and annotations. |
| 584 |
('LAST30DAYS_DISCOVERY_QUEUE', None), |
| 585 |
# Wall-clock budget (seconds) for the deep-tier enrichment batch on |
| 586 |
# the discovery resume leg (--discover --judgments). Read from the |
| 587 |
# resolved config only (pipeline._resume_enrich_budget_seconds); |
| 588 |
# unset/invalid falls back to 450s. The one-shot --discover path |
| 589 |
# keeps its fixed 240s quick budget regardless. |
| 590 |
('LAST30DAYS_ENRICH_BUDGET_SECONDS', None), |
| 591 |
# Opt-in strict exit: truthy -> CLI exits 3 when any source outcome is |
| 592 |
# degraded (neither ok, no-results, nor skipped-unconfigured). #384. |
| 593 |
('LAST30DAYS_STRICT_EXIT', None), |
| 594 |
('LAST30DAYS_MEMORY_DIR', None), |
| 595 |
# Optional local-only evidence source. Paths are separated with the |
| 596 |
# platform path separator (":" on macOS/Linux, ";" on Windows). |
| 597 |
('LAST30DAYS_CORPUS_DIRS', None), |
| 598 |
# Corpus evidence is omitted from the stable agent JSON export unless |
| 599 |
# this explicit privacy opt-in is truthy. |
| 600 |
('LAST30DAYS_CORPUS_IN_EXPORT', None), |
| 601 |
('LAST30DAYS_LIBRARY_OWNER', None), |
| 602 |
('LAST30DAYS_LIBRARY_CONTEXT', 'on'), |
| 603 |
('LAST30DAYS_PUBLISH_PASSWORD', None), |
| 604 |
('OPENAI_MODEL_PIN', None), |
| 605 |
('XAI_MODEL_PIN', None), |
| 606 |
('OPENAI_BASE_URL', None), |
| 607 |
('XAI_BASE_URL', None), |
| 608 |
('OPENROUTER_BASE_URL', None), |
| 609 |
('SCRAPECREATORS_API_KEY', None), |
| 610 |
('APIFY_API_TOKEN', None), |
| 611 |
('AUTH_TOKEN', None), |
| 612 |
('CT0', None), |
| 613 |
('BSKY_HANDLE', None), |
| 614 |
('BSKY_APP_PASSWORD', None), |
| 615 |
('BSKY_SEARCH_HOST', None), |
| 616 |
('TRUTHSOCIAL_TOKEN', None), |
| 617 |
('BRAVE_API_KEY', None), |
| 618 |
('EXA_API_KEY', None), |
| 619 |
('SERPER_API_KEY', None), |
| 620 |
('OPENROUTER_API_KEY', None), |
| 621 |
('PERPLEXITY_API_KEY', None), |
| 622 |
('LAST30DAYS_PERPLEXITY_MODE', 'agent'), |
| 623 |
# Legacy Sonar setting. Retain it during migration so existing env |
| 624 |
# files load, but the Agent adapter does not map it to a dynamic preset. |
| 625 |
('LAST30DAYS_PERPLEXITY_MODEL', None), |
| 626 |
('LAST30DAYS_PERPLEXITY_AGENT_MODEL', None), |
| 627 |
('LAST30DAYS_PERPLEXITY_AGENT_PRESET', None), |
| 628 |
('LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS', None), |
| 629 |
('LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS', None), |
| 630 |
('LAST30DAYS_PERPLEXITY_AGENT_TIMEOUT_SECONDS', '120'), |
| 631 |
('LAST30DAYS_PERPLEXITY_MAX_RESULTS', None), |
| 632 |
('LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE', None), |
| 633 |
('LAST30DAYS_PERPLEXITY_SEARCH_MODE', None), |
| 634 |
('LAST30DAYS_PERPLEXITY_DOMAIN_FILTER', None), |
| 635 |
('LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER', None), |
| 636 |
('LAST30DAYS_PERPLEXITY_COUNTRY', None), |
| 637 |
('LAST30DAYS_PERPLEXITY_RECENCY_FILTER', None), |
| 638 |
('LAST30DAYS_PERPLEXITY_REASONING_EFFORT', None), |
| 639 |
('LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS', '600'), |
| 640 |
('PARALLEL_API_KEY', None), |
| 641 |
('XQUIK_API_KEY', None), |
| 642 |
# Bright Data CLI. Optional: the CLI normally owns its own auth via |
| 643 |
# `brightdata login`, so this only matters for users who prefer an |
| 644 |
# explicit key in a `.env` file or the keychain. Registered here so |
| 645 |
# those layers reach the gate and the subprocess (-k) alike. |
| 646 |
('BRIGHTDATA_API_KEY', None), |
| 647 |
# Amazon marketplace the amazon source searches. Non-US users point |
| 648 |
# this at their own storefront (e.g. https://www.amazon.co.uk). |
| 649 |
('LAST30DAYS_AMAZON_DOMAIN', 'https://www.amazon.com'), |
| 650 |
# Ad Library country for the meta_ads source, as a two-letter code. The |
| 651 |
# endpoint takes exactly one country per call. There is deliberately no |
| 652 |
# durable env form of the advertiser-page override: a page id is |
| 653 |
# per-topic state, and env keys ride through the competitor runner's |
| 654 |
# config copy, which would attach one brand's ads to every peer. |
| 655 |
('LAST30DAYS_META_ADS_COUNTRY', 'US'), |
| 656 |
# Host-native search signal: set by the SKILL.md agent-host path when the |
| 657 |
# invoking runtime has its own (better) web-search tool, so the engine's |
| 658 |
# keyless search floor stays off there. Defaults unset -> floor allowed. |
| 659 |
('LAST30DAYS_NATIVE_SEARCH', None), |
| 660 |
# Optional SearXNG instance for the keyless-search fallback rung. |
| 661 |
('LAST30DAYS_SEARXNG_URL', None), |
| 662 |
# Truthy -> disable Trustpilot's headless-Chrome WAF-cookie harvest in |
| 663 |
# automated contexts (cron/CI/eval). Read by trustpilot._harvest_allowed. |
| 664 |
('LAST30DAYS_TRUSTPILOT_NO_BROWSER', None), |
| 665 |
('FROM_BROWSER', None), |
| 666 |
# agentcookie sidecar: soft-dep X cookie source (lib/agentcookie.py), |
| 667 |
# active only on extra hosts (Linux / Mac mini / Darwin sink) or when |
| 668 |
# set to "on". "off" disables the sidecar reader. |
| 669 |
('AGENTCOOKIE', None), |
| 670 |
# Explicit Chrome DevTools endpoint for the extra-host CDP cookie |
| 671 |
# lookup (lib/chrome_cdp.py), e.g. http://127.0.0.1:18800. Preferred |
| 672 |
# over the 18800 / 9222+$DISPLAY defaults when set. |
| 673 |
('BROWSER_CDP_URL', None), |
| 674 |
('LAST30DAYS_TRUST_PROJECT_CONFIG', None), |
| 675 |
('SETUP_COMPLETE', None), |
| 676 |
('INCLUDE_SOURCES', ''), |
| 677 |
('EXCLUDE_SOURCES', ''), |
| 678 |
('LAST30DAYS_DEFAULT_SEARCH', ''), |
| 679 |
# Resolve the user-facing default in last30days.py so an absent value |
| 680 |
# stays distinguishable from an explicit `default`. That distinction |
| 681 |
# lets the new key override legacy ELI5_MODE=true configurations. |
| 682 |
('LAST30DAYS_REGISTER', None), |
| 683 |
('FUN_LEVEL', 'medium'), |
| 684 |
# Backward compatibility for configs written by the original `eli5 on` |
| 685 |
# follow-up command. New writes use LAST30DAYS_REGISTER=eli5. |
| 686 |
('ELI5_MODE', None), |
| 687 |
('LAST30DAYS_YOUTUBE_SSH_HOST', None), |
| 688 |
('LAST30DAYS_REPORT_CACHE_TTL_SECONDS', None), |
| 689 |
('LAST30DAYS_VERIFY_FRESHNESS', None), |
| 690 |
('LAST30DAYS_TRANSCRIPT_TIMEOUT', None), |
| 691 |
('DEGRADED_TRANSCRIPT_THRESHOLD', None), |
| 692 |
(KEYCHAIN_ALIASES_ENV, None), |
| 693 |
# Whisper transcription provider for caption-free audio/video. Groq's |
| 694 |
# free tier is preferred; OPENAI_API_KEY is the paid backstop (already |
| 695 |
# resolved above via openai_auth). |
| 696 |
('GROQ_API_KEY', None), |
| 697 |
('LAST30DAYS_YT_SUB_LANGS', 'en,es,pt'), |
| 698 |
# youtube_yt reads this lazily from os.environ; default android is |
| 699 |
# applied there when the key is absent. Empty disables. |
| 700 |
('LAST30DAYS_YT_PLAYER_CLIENT', None), |
| 701 |
('LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT', None), |
| 702 |
('LAST30DAYS_YT_SEARCH_TIMEOUT', None), |
| 703 |
('GITHUB_TOKEN', None), |
| 704 |
# Host self-identification. `grok-bot` switches the X policy |
| 705 |
# to official-only (see x_policy); the engine never sniffs the host |
| 706 |
# any other way. Persisted by first-run setup and exported per |
| 707 |
# invocation by the SKILL.md rule. |
| 708 |
(X_HOST_VAR, None), |
| 709 |
# App-only bearer token for the direct X API v2 backend (`xapi`). |
| 710 |
('X_BEARER_TOKEN', None), |
| 711 |
# Per-session X connector lane signal. Read from the process |
| 712 |
# environment ONLY: a .env line is deliberately ignored (a removed |
| 713 |
# connector must never leave a stale declaration), so it is handled |
| 714 |
# in the loop below rather than via merged_env. |
| 715 |
(X_HOST_LANE_VAR, None), |
| 716 |
] |
| 717 |
|
| 718 |
for key, default in keys: |
| 719 |
if key == X_HOST_LANE_VAR: |
| 720 |
# Process env only; the .env value never reaches config. |
| 721 |
config[key] = os.environ.get(key) or default |
| 722 |
continue |
| 723 |
if key == 'LAST30DAYS_YT_PLAYER_CLIENT': |
| 724 |
# Empty string is a valid disable; `or` would treat it as unset. |
| 725 |
if key in os.environ: |
| 726 |
config[key] = os.environ.get(key) |
| 727 |
elif key in merged_env: |
| 728 |
# Mapping lookup via .get; bracket form trips a CRITICAL |
| 729 |
# scanner false positive on this identifier. |
| 730 |
config[key] = merged_env.get(key) |
| 731 |
else: |
| 732 |
config[key] = default |
| 733 |
else: |
| 734 |
config[key] = os.environ.get(key) or merged_env.get(key, default) |
| 735 |
|
| 736 |
# Export debug flag to os.environ so log.py's lazy os.environ.get() |
| 737 |
# picks up .env values. setdefault ensures a shell-exported value is |
| 738 |
# never overwritten by the (lower-priority) .env value. |
| 739 |
if config.get('LAST30DAYS_DEBUG'): |
| 740 |
os.environ.setdefault('LAST30DAYS_DEBUG', config['LAST30DAYS_DEBUG']) |
| 741 |
|
| 742 |
# youtube_yt reads these tuning knobs lazily from os.environ, so values |
| 743 |
# loaded from .env must be exported into the current engine process. |
| 744 |
for key in ( |
| 745 |
'LAST30DAYS_YT_SUB_LANGS', |
| 746 |
'LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT', |
| 747 |
'LAST30DAYS_YT_SEARCH_TIMEOUT', |
| 748 |
'LAST30DAYS_REDDIT_KEYLESS_RATE', |
| 749 |
'LAST30DAYS_YT_PLAYER_CLIENT', |
| 750 |
): |
| 751 |
value = config.get(key) |
| 752 |
# Empty LAST30DAYS_YT_PLAYER_CLIENT is a valid disable; other knobs |
| 753 |
# treat empty as unset and keep their code defaults. |
| 754 |
if key == 'LAST30DAYS_YT_PLAYER_CLIENT': |
| 755 |
if value is not None: |
| 756 |
os.environ.setdefault(key, value) |
| 757 |
elif value: |
| 758 |
os.environ.setdefault(key, value) |
| 759 |
|
| 760 |
# Backward-compat: ScrapeCreators' own examples and tutorials use the |
| 761 |
# SCRAPE_CREATORS_API_KEY spelling (with underscore between SCRAPE and |
| 762 |
# CREATORS). Accept that form too so users who follow the vendor's docs |
| 763 |
# don't silently end up with has_scrapecreators=False. Canonical name |
| 764 |
# wins when both are set. |
| 765 |
if not config.get('SCRAPECREATORS_API_KEY'): |
| 766 |
legacy = read_secret_env('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY') |
| 767 |
if legacy: |
| 768 |
config['SCRAPECREATORS_API_KEY'] = legacy |
| 769 |
|
| 770 |
# Multi-key rotation: comma-separated SCRAPECREATORS_API_KEY round-robins |
| 771 |
# via random.choice per run. Originally added in #268, accidentally dropped |
| 772 |
# in v3.0.6, restored here. |
| 773 |
_rotate_scrapecreators_key(config) |
| 774 |
|
| 775 |
# Track which config source was used (highest-priority file source wins |
| 776 |
# the label; keychain is only reported when nothing else is configured). |
| 777 |
if project_env_path: |
| 778 |
config['_CONFIG_SOURCE'] = f'project:{project_env_path}' |
| 779 |
elif CONFIG_FILE and CONFIG_FILE.exists(): |
| 780 |
config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}' |
| 781 |
elif keychain_env: |
| 782 |
config['_CONFIG_SOURCE'] = 'keychain' |
| 783 |
elif pass_env: |
| 784 |
config['_CONFIG_SOURCE'] = 'pass' |
| 785 |
else: |
| 786 |
config['_CONFIG_SOURCE'] = 'env_only' |
| 787 |
if ignored_project_env_path: |
| 788 |
config['_IGNORED_PROJECT_CONFIG'] = str(ignored_project_env_path) |
| 789 |
config['_IGNORED_PROJECT_CONFIG_KEYS'] = ignored_project_keys |
| 790 |
config['_BROWSER_COOKIE_MODE'] = policy.browser_cookies |
| 791 |
# A LAST30DAYS_X_HOST_LANE line in a config file is ignored; |
| 792 |
# remember that it was there so doctor can say so. |
| 793 |
config['_X_HOST_LANE_FILE_IGNORED'] = bool(merged_env.get(X_HOST_LANE_VAR)) |
| 794 |
# Evaluated after the host and pin keys are merged: on an official-only |
| 795 |
# host the browser list is empty unless bird is pinned (x_policy). |
| 796 |
config['_BROWSER_COOKIE_BROWSERS'] = cookie_extraction_browsers(config) |
| 797 |
|
| 798 |
# Reject unsubstituted extension placeholders last among the value-producing |
| 799 |
# steps, so the legacy ScrapeCreators spelling, the multi-key rotation, and |
| 800 |
# the OpenAI auth fields assembled above are all covered by one sweep rather |
| 801 |
# than by a predicate repeated at each presence check. Rejection means |
| 802 |
# "absent", not "empty": the placeholder is removed from the process |
| 803 |
# environment and the key is then re-resolved from the lower-priority |
| 804 |
# sources exactly as it would be had the host never written it, so a real |
| 805 |
# .env, Keychain, or pass credential it was shadowing is not discarded. |
| 806 |
# Every consumer of a rejected config key therefore agrees the credential is |
| 807 |
# unset, and the keys left genuinely unset are published for the diagnostics |
| 808 |
# to report. The sweep is bounded by the keys get_config registers: a |
| 809 |
# credential read straight from the environment under a name it does not |
| 810 |
# register - LAST30DAYS_API_KEY, or a bare SCRAPE_CREATORS_API_KEY spelling |
| 811 |
# left behind after the canonical key resolved - keeps its placeholder. |
| 812 |
declared_defaults = {key: default for key, default in keys} |
| 813 |
templated_keys = sorted( |
| 814 |
key |
| 815 |
for key, value in config.items() |
| 816 |
if not key.startswith('_') and is_unsubstituted_template(value) |
| 817 |
) |
| 818 |
for key in templated_keys: |
| 819 |
os.environ.pop(key, None) |
| 820 |
fallback = merged_env.get(key) |
| 821 |
# A lower-priority value that is itself a placeholder is not a credential. |
| 822 |
if is_unsubstituted_template(fallback): |
| 823 |
fallback = None |
| 824 |
resolved = fallback if fallback is not None else declared_defaults.get(key) |
| 825 |
config[key] = resolved if resolved is not None else '' |
| 826 |
# The rotation ran before this sweep, so a fallback restored from a |
| 827 |
# comma-separated list would otherwise reach a backend whole. Reapply it, |
| 828 |
# then reject the picked key if it is itself a placeholder. |
| 829 |
_rotate_scrapecreators_key(config) |
| 830 |
if is_unsubstituted_template(config.get('SCRAPECREATORS_API_KEY')): |
| 831 |
config['SCRAPECREATORS_API_KEY'] = '' |
| 832 |
# Report only the keys still leaving the credential unset. A placeholder that |
| 833 |
# fell through to a real lower-priority credential (or to a usable default) |
| 834 |
# is handled, and reporting it would nag about a setup that works. |
| 835 |
config[TEMPLATE_CONFIG_KEYS] = [ |
| 836 |
key for key in templated_keys if not config.get(key) |
| 837 |
] |
| 838 |
if 'OPENAI_API_KEY' in templated_keys and not config.get('OPENAI_API_KEY'): |
| 839 |
# Keep the derived auth record consistent with the token it describes. |
| 840 |
config['OPENAI_AUTH_SOURCE'] = AUTH_SOURCE_NONE |
| 841 |
config['OPENAI_AUTH_STATUS'] = AUTH_STATUS_MISSING |
| 842 |
|
| 843 |
if policy.browser_cookies == "read": |
| 844 |
_discover_and_apply_x_credentials(config) |
| 845 |
|
| 846 |
# Fixture recording (--record-fixtures) must redact a credential that |
| 847 |
# came from a file, Keychain, or pass, not only one exported in the |
| 848 |
# shell. No-op outside a recording session. |
| 849 |
from . import http as _http |
| 850 |
_http.add_fixture_redactions(_http.config_secret_values(config)) |
| 851 |
|
| 852 |
return config |
| 853 |
|
| 854 |
|
| 855 |
# --------------------------------------------------------------------------- |
| 856 |
# Extra-host X cookie discovery (Linux, Mac mini, Darwin agentcookie sink) |
| 857 |
# --------------------------------------------------------------------------- |
| 858 |
|
| 859 |
|
| 860 |
def _mac_model() -> str: |
| 861 |
"""Darwin hardware model via ``sysctl -n hw.model``, or "" otherwise. |
| 862 |
|
| 863 |
Returns "" on non-Darwin and on any sysctl failure (missing binary, |
| 864 |
non-zero exit, timeout) — the caller treats "" as "not a Mac mini", i.e. a |
| 865 |
MacBook, which is the conservative default (no extra cookie lookups). |
| 866 |
""" |
| 867 |
import platform |
| 868 |
if platform.system() != "Darwin": |
| 869 |
return "" |
| 870 |
import subprocess |
| 871 |
try: |
| 872 |
out = subprocess.run( |
| 873 |
["sysctl", "-n", "hw.model"], |
| 874 |
capture_output=True, text=True, timeout=3, |
| 875 |
) |
| 876 |
except (OSError, subprocess.SubprocessError): |
| 877 |
return "" |
| 878 |
if out.returncode != 0: |
| 879 |
return "" |
| 880 |
return (out.stdout or "").strip() |
| 881 |
|
| 882 |
|
| 883 |
def _is_mac_mini() -> bool: |
| 884 |
"""True on a Darwin Mac mini (``hw.model`` prefix ``Macmini``). |
| 885 |
|
| 886 |
sysctl failure yields "" -> False, so an unreadable model is treated as a |
| 887 |
MacBook (no extras), per the plan. |
| 888 |
""" |
| 889 |
return _mac_model().startswith("Macmini") |
| 890 |
|
| 891 |
|
| 892 |
def x_extras_enabled(config: dict[str, Any]) -> bool: |
| 893 |
"""Whether the two EXTRA bird cookie lookups (agentcookie sidecar, live |
| 894 |
Chrome CDP) apply on this host. |
| 895 |
|
| 896 |
Extras apply when ANY of: |
| 897 |
* ``AGENTCOOKIE=on`` — explicit per-host opt-in (works on a MacBook too); |
| 898 |
* platform is Linux; |
| 899 |
* a Darwin Mac mini (``hw.model`` prefix ``Macmini``); |
| 900 |
* a Darwin agentcookie **sink** role (parse failure = not sink). |
| 901 |
|
| 902 |
A plain MacBook (Darwin, source/unknown role, no opt-in) stays on the |
| 903 |
mainline path — no agentcookie subprocess, no CDP socket. The host is NEVER |
| 904 |
inferred from the home directory, PATH, or ``HERMES_AGENT``/``OPENCLAW_CLI`` |
| 905 |
env: only the signals above. |
| 906 |
""" |
| 907 |
import platform |
| 908 |
raw = (config.get("AGENTCOOKIE") or read_secret_env("AGENTCOOKIE") or "").strip().lower() |
| 909 |
if raw == "on": |
| 910 |
return True |
| 911 |
system = platform.system() |
| 912 |
if system == "Linux": |
| 913 |
return True |
| 914 |
if system == "Darwin": |
| 915 |
if _is_mac_mini(): |
| 916 |
return True |
| 917 |
from . import agentcookie |
| 918 |
return agentcookie.role_is_sink(config) |
| 919 |
return False |
| 920 |
|
| 921 |
|
| 922 |
def _apply_x_pair(config: dict[str, Any], auth_token: str, ct0: str, source: str) -> None: |
| 923 |
"""Apply a COMPLETE X cookie pair from one source, labeling its origin. |
| 924 |
|
| 925 |
Atomic on purpose (both keys from the same source) so a half-pair from one |
| 926 |
source is never merged with a half-pair from another. Never written to the |
| 927 |
``.env``; values are never logged. |
| 928 |
""" |
| 929 |
config["AUTH_TOKEN"] = auth_token |
| 930 |
config["CT0"] = ct0 |
| 931 |
config["_AUTH_TOKEN_SOURCE"] = source |
| 932 |
config["_CT0_SOURCE"] = source |
| 933 |
|
| 934 |
|
| 935 |
def _apply_browser_extract(config: dict[str, Any]) -> None: |
| 936 |
"""Run the mainline in-process browser cookie extractor (unchanged from |
| 937 |
main): fills X (when a browser is opted in via FROM_BROWSER) and non-X |
| 938 |
cookie domains like truthsocial. Missing keys only; source label ``browser``.""" |
| 939 |
browser_creds = extract_browser_credentials(config) |
| 940 |
for key, value in browser_creds.items(): |
| 941 |
if not config.get(key): |
| 942 |
config[key] = value |
| 943 |
config[f"_{key}_SOURCE"] = "browser" |
| 944 |
|
| 945 |
|
| 946 |
def _discover_and_apply_x_credentials(config: dict[str, Any]) -> None: |
| 947 |
"""Fill AUTH_TOKEN/CT0 for the bird backend, first COMPLETE pair wins. |
| 948 |
|
| 949 |
Mainline (every host): the in-process browser extractor, gated by |
| 950 |
FROM_BROWSER exactly as on ``main``. EXTRA lookups (agentcookie sidecar, |
| 951 |
then live Chrome CDP) run ONLY on extra hosts (``x_extras_enabled``), so a |
| 952 |
MacBook with FROM_BROWSER unset/off does no agentcookie spawn and no CDP |
| 953 |
socket. Probe order: |
| 954 |
|
| 955 |
1. an explicit env AUTH_TOKEN+CT0 already present — never overwritten; |
| 956 |
2. agentcookie sidecar (extras only); |
| 957 |
3. live Chrome CDP (extras only); |
| 958 |
4. the mainline browser extract (all hosts; X only when FROM_BROWSER |
| 959 |
lists a browser). |
| 960 |
|
| 961 |
On a Mac mini that has already opted into browser reads (FROM_BROWSER set), |
| 962 |
the native extract runs BEFORE CDP (R19) — a local Keychain read beats a |
| 963 |
debug-port scrape. Never persists cookies; values are never logged. |
| 964 |
|
| 965 |
On an official-only host (``x_policy``: ``LAST30DAYS_HOST=grok-bot``) |
| 966 |
this returns before ANY leg, for every cookie domain, unless the pin is |
| 967 |
``bird`` (the one path that re-enables discovery for that run). |
| 968 |
""" |
| 969 |
if not x_policy(config).cookie_discovery: |
| 970 |
return |
| 971 |
|
| 972 |
from . import agentcookie, chrome_cdp |
| 973 |
|
| 974 |
def have_pair() -> bool: |
| 975 |
return bool(config.get("AUTH_TOKEN") and config.get("CT0")) |
| 976 |
|
| 977 |
extras = x_extras_enabled(config) |
| 978 |
|
| 979 |
# (2) agentcookie sidecar — extras only, complete pair only. |
| 980 |
if extras and not have_pair(): |
| 981 |
pair = agentcookie.read_x_cookies(config) |
| 982 |
if pair: |
| 983 |
_apply_x_pair(config, pair["auth_token"], pair["ct0"], "agentcookie") |
| 984 |
|
| 985 |
# Mac mini + browser opted in: native extract before CDP (R19). |
| 986 |
mini_extract_first = ( |
| 987 |
extras and _is_mac_mini() and bool(cookie_extraction_browsers(config)) |
| 988 |
) |
| 989 |
if mini_extract_first and not have_pair(): |
| 990 |
_apply_browser_extract(config) |
| 991 |
|
| 992 |
# (3) live Chrome CDP — extras only, complete pair only. |
| 993 |
if extras and not have_pair(): |
| 994 |
pair = chrome_cdp.read_x_cookies(config) |
| 995 |
if pair: |
| 996 |
_apply_x_pair(config, pair["auth_token"], pair["ct0"], "chrome cdp") |
| 997 |
|
| 998 |
# (4) mainline browser extract (unless already run above for the mini case). |
| 999 |
if not mini_extract_first: |
| 1000 |
_apply_browser_extract(config) |
| 1001 |
|
| 1002 |
|
| 1003 |
# --------------------------------------------------------------------------- |
| 1004 |
# Browser cookie extraction |
| 1005 |
# --------------------------------------------------------------------------- |
| 1006 |
|
| 1007 |
COOKIE_DOMAINS: dict[str, dict[str, Any]] = { |
| 1008 |
"x": { |
| 1009 |
"domain": ".x.com", |
| 1010 |
"cookies": ["auth_token", "ct0"], |
| 1011 |
"mapping": {"auth_token": "AUTH_TOKEN", "ct0": "CT0"}, |
| 1012 |
}, |
| 1013 |
"truthsocial": { |
| 1014 |
"domain": ".truthsocial.com", |
| 1015 |
"cookies": ["_session_id"], |
| 1016 |
"mapping": {"_session_id": "TRUTHSOCIAL_TOKEN"}, |
| 1017 |
}, |
| 1018 |
} |
| 1019 |
|
| 1020 |
|
| 1021 |
def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]: |
| 1022 |
"""Browsers to try for cookie extraction, honoring FROM_BROWSER. |
| 1023 |
|
| 1024 |
Default (FROM_BROWSER unset): no browser-cookie reads. The Chromium family |
| 1025 |
(Chrome, Brave, Edge, Vivaldi, Opera, Arc, Chromium) is available only when |
| 1026 |
explicitly selected because reading their cookies on macOS requires the |
| 1027 |
browser's Safe Storage Keychain key, which triggers a system password prompt |
| 1028 |
that cannot be reliably suppressed. On Windows only Firefox cookie |
| 1029 |
extraction is supported; Chrome and Edge use DPAPI-encrypted cookie stores |
| 1030 |
that are not yet supported. |
| 1031 |
|
| 1032 |
- ``FROM_BROWSER=<name>`` - a single browser (e.g. ``firefox``, ``brave``, |
| 1033 |
``edge``, ``arc``). |
| 1034 |
- ``FROM_BROWSER=firefox,safari`` - a comma-separated explicit browser list. |
| 1035 |
- ``FROM_BROWSER=auto`` - also try every Chromium browser (user accepts the |
| 1036 |
Keychain dialog when needed). |
| 1037 |
- ``FROM_BROWSER=off`` - returns [] (extraction disabled). |
| 1038 |
|
| 1039 |
Returning the browser list from one place keeps the setup wizard and the |
| 1040 |
steady-state path on the same policy, so neither surprises the user with an |
| 1041 |
unrequested Keychain prompt. On an official-only host (``x_policy``) the |
| 1042 |
list is empty regardless of ``FROM_BROWSER`` unless ``bird`` is pinned. |
| 1043 |
""" |
| 1044 |
if not x_policy(config).cookie_discovery: |
| 1045 |
return [] |
| 1046 |
silent_browsers = ["firefox", "safari"] |
| 1047 |
chromium_browsers = ["chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"] |
| 1048 |
known_browsers = silent_browsers + chromium_browsers |
| 1049 |
from_browser = (config.get("FROM_BROWSER") or "").strip().lower() |
| 1050 |
if not from_browser: |
| 1051 |
return [] |
| 1052 |
if from_browser == "off": |
| 1053 |
return [] |
| 1054 |
if from_browser == "auto": |
| 1055 |
return silent_browsers + chromium_browsers |
| 1056 |
if "," in from_browser: |
| 1057 |
requested = [b.strip() for b in from_browser.split(",") if b.strip()] |
| 1058 |
resolved = [b for b in requested if b in known_browsers] |
| 1059 |
unknown = [b for b in requested if b not in known_browsers] |
| 1060 |
if unknown: |
| 1061 |
sys.stderr.write( |
| 1062 |
"[last30days] WARNING: FROM_BROWSER ignored unrecognized browser(s): " |
| 1063 |
f"{', '.join(unknown)} (known: {', '.join(known_browsers)})\n" |
| 1064 |
) |
| 1065 |
sys.stderr.flush() |
| 1066 |
return resolved |
| 1067 |
if from_browser in known_browsers: |
| 1068 |
return [from_browser] |
| 1069 |
# Non-empty, not off/auto, not a known browser, not a list: unrecognized. |
| 1070 |
# Warn rather than fail silently so a typo (FROM_BROWSER=chrme) is visible |
| 1071 |
# instead of looking like "no cookies found". |
| 1072 |
sys.stderr.write( |
| 1073 |
f"[last30days] WARNING: FROM_BROWSER='{from_browser}' is not a recognized " |
| 1074 |
f"browser; no cookies will be read (known: {', '.join(known_browsers)}, " |
| 1075 |
"or 'auto'/'off')\n" |
| 1076 |
) |
| 1077 |
sys.stderr.flush() |
| 1078 |
return [] |
| 1079 |
|
| 1080 |
|
| 1081 |
|
| 1082 |
def extract_browser_credentials(config: dict[str, Any]) -> dict[str, str]: |
| 1083 |
"""Extract auth cookies from local browsers. |
| 1084 |
|
| 1085 |
Browser selection (and the Chrome-prompt caveat) is handled by |
| 1086 |
``cookie_extraction_browsers``; this function just runs the extraction for |
| 1087 |
each configured cookie domain. |
| 1088 |
""" |
| 1089 |
browsers = cookie_extraction_browsers(config) |
| 1090 |
if not browsers: |
| 1091 |
return {} |
| 1092 |
try: |
| 1093 |
from . import cookie_extract |
| 1094 |
except ImportError: |
| 1095 |
return {} |
| 1096 |
extracted: dict[str, str] = {} |
| 1097 |
for _service, spec in COOKIE_DOMAINS.items(): |
| 1098 |
if all(config.get(env_key) for env_key in spec["mapping"].values()): |
| 1099 |
continue |
| 1100 |
for browser in browsers: |
| 1101 |
try: |
| 1102 |
cookies = cookie_extract.extract_cookies(browser, spec["domain"], spec["cookies"]) |
| 1103 |
except Exception: |
| 1104 |
continue |
| 1105 |
if cookies: |
| 1106 |
for cookie_name, env_key in spec["mapping"].items(): |
| 1107 |
if cookie_name in cookies and not config.get(env_key): |
| 1108 |
extracted[env_key] = cookies[cookie_name] |
| 1109 |
break # Found cookies for this service, stop trying browsers |
| 1110 |
return extracted |
| 1111 |
|
| 1112 |
|
| 1113 |
# Auth-origin label per X backend for ``get_x_source_with_method`` (bird's |
| 1114 |
# label is the cookie source recorded in ``_AUTH_TOKEN_SOURCE``). |
| 1115 |
_X_METHOD_LABELS = { |
| 1116 |
"xai": "xai", |
| 1117 |
"xurl": "oauth2", # xurl CLI (official X API v2, OAuth2, free developer app) |
| 1118 |
"xapi": "bearer", |
| 1119 |
"xquik": "api_key", |
| 1120 |
} |
| 1121 |
|
| 1122 |
|
| 1123 |
def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]: |
| 1124 |
"""Return (source, method) for X search, where method describes the auth origin. |
| 1125 |
|
| 1126 |
Walks the policy's unpinned auto chain (``x_auto_chain``): on a default |
| 1127 |
host bird first (cookies beat XAI_API_KEY when both are present), then |
| 1128 |
xai, xurl, xquik; on an official-only host xapi, xai, xurl. Opt-in |
| 1129 |
backends (grok, and xapi off Grok Bot) are never auto-selected here. |
| 1130 |
""" |
| 1131 |
has_bird_creds = bool(config.get("AUTH_TOKEN") and config.get("CT0")) |
| 1132 |
for backend in x_auto_chain(config): |
| 1133 |
if backend == "bird": |
| 1134 |
# Cookie presence only: the scraper install is not consulted |
| 1135 |
# here (unlike ``x_backend_chain``), so a fresh cookie-bearing |
| 1136 |
# config reports bird before the binary is checked. |
| 1137 |
if not has_bird_creds: |
| 1138 |
continue |
| 1139 |
elif not _x_backend_available(backend, config, has_bird_creds): |
| 1140 |
continue |
| 1141 |
if backend == "bird": |
| 1142 |
return "bird", config.get("_AUTH_TOKEN_SOURCE", "env") |
| 1143 |
return backend, _X_METHOD_LABELS.get(backend, "none") |
| 1144 |
return None, "none" |
| 1145 |
|
| 1146 |
|
| 1147 |
def config_exists(policy: ConfigLoadPolicy | None = None) -> bool: |
| 1148 |
"""Check if any configuration source exists.""" |
| 1149 |
policy = policy or ConfigLoadPolicy() |
| 1150 |
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE and CONFIG_FILE.exists() else {} |
| 1151 |
if _project_config_trusted(policy, file_env) and _find_project_env(): |
| 1152 |
return True |
| 1153 |
if CONFIG_FILE: |
| 1154 |
return CONFIG_FILE.exists() |
| 1155 |
return False |
| 1156 |
|
| 1157 |
|
| 1158 |
def get_reddit_source(config: dict[str, Any]) -> str | None: |
| 1159 |
"""Determine which Reddit backend to use. |
| 1160 |
|
| 1161 |
Returns: 'scrapecreators' or None |
| 1162 |
""" |
| 1163 |
if config.get('SCRAPECREATORS_API_KEY'): |
| 1164 |
return 'scrapecreators' |
| 1165 |
return None |
| 1166 |
|
| 1167 |
|
| 1168 |
# Default X backend priority. The first available backend is the primary X |
| 1169 |
# source; the rest are ordered failover backups, tried only if the one before |
| 1170 |
# returns nothing or errors. There is one X source ("x"); these are its |
| 1171 |
# interchangeable backends, never run in parallel. |
| 1172 |
# bird — X GraphQL scrape via the user's browser cookies (AUTH_TOKEN/CT0) |
| 1173 |
# xai — xAI/Grok live search (XAI_API_KEY) |
| 1174 |
# xurl — official X API v2 (xurl CLI, OAuth2) |
| 1175 |
# xquik — key-based REST X search (XQUIK_API_KEY) |
| 1176 |
_X_BACKEND_ORDER = ("bird", "xai", "xurl", "xquik") |
| 1177 |
|
| 1178 |
# Opt-in backends: never in the default unpinned auto chain; require an |
| 1179 |
# explicit pin. grok is here because a leftover ~/.grok/auth.json must never |
| 1180 |
# steal the X lane. xapi (direct X API v2 with X_BEARER_TOKEN) is here so an |
| 1181 |
# ambient bearer exported for some other tool never spends X API credits |
| 1182 |
# every time the cookie scraper comes back empty; on an official-only |
| 1183 |
# host it is the first rung of the auto chain instead (see _X_OFFICIAL). |
| 1184 |
_X_BACKEND_OPT_IN = ("grok", "xapi") |
| 1185 |
|
| 1186 |
# All known backends (auto chain + opt-in): valid values for the pin var. |
| 1187 |
_X_BACKEND_KNOWN = _X_BACKEND_ORDER + _X_BACKEND_OPT_IN |
| 1188 |
|
| 1189 |
# Licensed / official backends: the unpinned auto chain on an official-only |
| 1190 |
# host. xapi = X API v2 with an app-only bearer, xai = xAI's licensed |
| 1191 |
# X search, xurl = the X API through X's own CLI. |
| 1192 |
_X_OFFICIAL = ("xapi", "xai", "xurl") |
| 1193 |
|
| 1194 |
# Host self-identification key and the one value that switches the X |
| 1195 |
# policy. The engine trusts this key alone: it never infers the host from |
| 1196 |
# the home directory, PATH, platform, or agent env vars. |
| 1197 |
X_HOST_VAR = 'LAST30DAYS_HOST' |
| 1198 |
GROK_BOT_HOST = 'grok-bot' |
| 1199 |
# Per-session X connector lane signal: process env only. |
| 1200 |
X_HOST_LANE_VAR = 'LAST30DAYS_X_HOST_LANE' |
| 1201 |
|
| 1202 |
# Public routing definitions for the doctor/backend-descriptor layer |
| 1203 |
# (lib/backends.py). These are aliases for knowledge this module already |
| 1204 |
# owns — the declared X chain order and the pin/floor env var names — so |
| 1205 |
# descriptors import one source of truth instead of restating it. |
| 1206 |
X_BACKEND_ORDER = _X_BACKEND_ORDER |
| 1207 |
X_BACKEND_OPT_IN = _X_BACKEND_OPT_IN |
| 1208 |
X_BACKEND_KNOWN = _X_BACKEND_KNOWN |
| 1209 |
X_OFFICIAL = _X_OFFICIAL |
| 1210 |
X_BACKEND_PIN_VAR = 'LAST30DAYS_X_BACKEND' |
| 1211 |
REDDIT_BACKEND_PIN_VAR = 'LAST30DAYS_REDDIT_BACKEND' |
| 1212 |
REDDIT_SC_MIN_ITEMS_VAR = 'LAST30DAYS_REDDIT_SC_MIN_ITEMS' |
| 1213 |
|
| 1214 |
|
| 1215 |
@dataclass(frozen=True) |
| 1216 |
class XPolicy: |
| 1217 |
"""The host-conditional X routing rule, resolved once per config. |
| 1218 |
|
| 1219 |
``host`` is the normalized ``LAST30DAYS_HOST`` value; ``official_only`` |
| 1220 |
is true on a Grok Bot host; ``auto_chain`` is the unpinned chain |
| 1221 |
(``_X_OFFICIAL`` when official-only, else ``_X_BACKEND_ORDER``); |
| 1222 |
``cookie_discovery`` is false when official-only unless the pin is |
| 1223 |
``bird``; ``hint_namespace`` (``official`` or ``default``) is derived |
| 1224 |
from ``official_only`` as a plain string so this module never imports |
| 1225 |
``prescriptions``. |
| 1226 |
""" |
| 1227 |
|
| 1228 |
host: str |
| 1229 |
official_only: bool |
| 1230 |
auto_chain: tuple[str, ...] |
| 1231 |
cookie_discovery: bool |
| 1232 |
|
| 1233 |
@property |
| 1234 |
def hint_namespace(self) -> str: |
| 1235 |
return 'official' if self.official_only else 'default' |
| 1236 |
|
| 1237 |
|
| 1238 |
def x_backend_pin(config: dict[str, Any]) -> str: |
| 1239 |
"""The normalized ``LAST30DAYS_X_BACKEND`` pin value ("" when unset).""" |
| 1240 |
return (config.get(X_BACKEND_PIN_VAR) or '').strip().lower() |
| 1241 |
|
| 1242 |
|
| 1243 |
def x_policy(config: dict[str, Any]) -> XPolicy: |
| 1244 |
"""Resolve the X policy from ``LAST30DAYS_HOST`` and the pin. |
| 1245 |
|
| 1246 |
This is the ONLY place the Grok Bot host string is compared. It reads |
| 1247 |
just the host key, the pin, and the config dict: no platform, PATH, home |
| 1248 |
directory, or agent env-var inspection (the same rule ``x_extras_enabled`` |
| 1249 |
follows), and nothing imported from ``lib``. The pin keeps its exclusive |
| 1250 |
semantics on every host and may name any known backend; a ``bird`` pin |
| 1251 |
is the one path that re-enables cookie discovery on an official-only host. |
| 1252 |
""" |
| 1253 |
host = str(config.get(X_HOST_VAR) or '').strip().lower() |
| 1254 |
official_only = host == GROK_BOT_HOST |
| 1255 |
pin = x_backend_pin(config) |
| 1256 |
return XPolicy( |
| 1257 |
host=host, |
| 1258 |
official_only=official_only, |
| 1259 |
auto_chain=_X_OFFICIAL if official_only else _X_BACKEND_ORDER, |
| 1260 |
cookie_discovery=(not official_only) or pin == 'bird', |
| 1261 |
) |
| 1262 |
|
| 1263 |
|
| 1264 |
def x_auto_chain(config: dict[str, Any]) -> list[str]: |
| 1265 |
"""The unpinned X auto chain for this host, in failover order.""" |
| 1266 |
return list(x_policy(config).auto_chain) |
| 1267 |
|
| 1268 |
|
| 1269 |
def x_host_lane_declared(config: dict[str, Any]) -> bool: |
| 1270 |
"""True when the hosting model declared the X connector lane. |
| 1271 |
|
| 1272 |
``get_config`` fills ``LAST30DAYS_X_HOST_LANE`` from the process |
| 1273 |
environment only, so a ``.env`` line never declares the lane. |
| 1274 |
Deliberately NOT ``x_pending_browser_auth``: that predicate is false in |
| 1275 |
cookie-read mode by contract, which would leave the envelope path dead at |
| 1276 |
research time. Host-independent: the envelope is accepted anywhere. |
| 1277 |
""" |
| 1278 |
return _truthy(config.get(X_HOST_LANE_VAR)) |
| 1279 |
|
| 1280 |
|
| 1281 |
def _x_backend_available( |
| 1282 |
backend: str, |
| 1283 |
config: dict[str, Any], |
| 1284 |
has_bird_creds: bool, |
| 1285 |
local_only: bool = False, |
| 1286 |
) -> bool: |
| 1287 |
if backend == 'xai': |
| 1288 |
return bool(config.get('XAI_API_KEY')) |
| 1289 |
if backend == 'grok': |
| 1290 |
# Keyless relative to X: needs only an installed, signed-in grok CLI. |
| 1291 |
# Both surfaces are filesystem-only (PATH lookup + credential store), |
| 1292 |
# so local_only needs no separate branch. |
| 1293 |
from . import grok_x |
| 1294 |
return grok_x.has_stored_auth() |
| 1295 |
if backend == 'bird': |
| 1296 |
from . import bird_x |
| 1297 |
return has_bird_creds and bird_x.is_bird_installed() |
| 1298 |
if backend == 'xurl': |
| 1299 |
from . import xurl_x |
| 1300 |
if local_only: |
| 1301 |
# Doctor/safe-diagnose path: local evidence only (PATH lookup + |
| 1302 |
# token store) — never the live `xurl whoami` network call. |
| 1303 |
return xurl_x.has_stored_auth() |
| 1304 |
return xurl_x.is_available() |
| 1305 |
if backend == 'xquik': |
| 1306 |
return is_xquik_available(config) |
| 1307 |
if backend == 'xapi': |
| 1308 |
# Key presence only (no network); local_only needs no branch. |
| 1309 |
return bool(config.get('X_BEARER_TOKEN')) |
| 1310 |
return False |
| 1311 |
|
| 1312 |
|
| 1313 |
def x_backend_chain(config: dict[str, Any], local_only: bool = False) -> list[str]: |
| 1314 |
"""Ordered list of available X backends. |
| 1315 |
|
| 1316 |
``chain[0]`` is the default X source; the remaining entries are failover |
| 1317 |
backups, used only when the one before yields no items or errors. There is |
| 1318 |
exactly one X source — these are its backends, never fetched in parallel. |
| 1319 |
|
| 1320 |
A ``LAST30DAYS_X_BACKEND`` pin forces a single backend (no failover): the |
| 1321 |
user explicitly chose it. Valid pin values are in ``_X_BACKEND_KNOWN`` |
| 1322 |
(the auto chain plus opt-in backends like grok). Browser-cookie probing |
| 1323 |
is intentionally avoided (automatic Keychain access causes popups); bird |
| 1324 |
counts as available only when AUTH_TOKEN and CT0 are present explicitly. |
| 1325 |
|
| 1326 |
Unpinned runs walk only ``_X_BACKEND_ORDER``: opt-in backends like grok |
| 1327 |
are never auto-selected. A leftover ~/.grok/auth.json must not steal the |
| 1328 |
X lane; pin ``LAST30DAYS_X_BACKEND=grok`` to enable it explicitly. |
| 1329 |
|
| 1330 |
``local_only=True`` is the doctor/safe-diagnose flavor: availability is |
| 1331 |
answered from local evidence only (no subprocess spawns that reach the |
| 1332 |
network — xurl's live `whoami` check is replaced by its on-disk token |
| 1333 |
store). Research-time callers keep the default live semantics. |
| 1334 |
|
| 1335 |
The unpinned walk is ``x_policy(config).auto_chain``: the default order |
| 1336 |
above on every host, or ``_X_OFFICIAL`` (xapi -> xai -> xurl) on an |
| 1337 |
official-only host. The scraper is primed with cookies only when bird |
| 1338 |
ends up in the resulting chain (never on an official-only host unless |
| 1339 |
bird is pinned). |
| 1340 |
""" |
| 1341 |
has_bird_creds = bool(config.get('AUTH_TOKEN') and config.get('CT0')) |
| 1342 |
|
| 1343 |
preferred = x_backend_pin(config) |
| 1344 |
# Pin accepted from _X_BACKEND_KNOWN (auto chain + opt-in like grok). |
| 1345 |
if preferred in _X_BACKEND_KNOWN: |
| 1346 |
if _x_backend_available(preferred, config, has_bird_creds, local_only): |
| 1347 |
chain = [preferred] |
| 1348 |
else: |
| 1349 |
chain = [] |
| 1350 |
else: |
| 1351 |
# Unpinned: walk the policy's auto chain. Opt-in backends (grok, and |
| 1352 |
# xapi off an official-only host) are never auto-selected. |
| 1353 |
chain = [ |
| 1354 |
b for b in x_policy(config).auto_chain |
| 1355 |
if _x_backend_available(b, config, has_bird_creds, local_only) |
| 1356 |
] |
| 1357 |
|
| 1358 |
if 'bird' in chain: |
| 1359 |
from . import bird_x |
| 1360 |
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0')) |
| 1361 |
return chain |
| 1362 |
|
| 1363 |
|
| 1364 |
def get_x_source(config: dict[str, Any], local_only: bool = False) -> str | None: |
| 1365 |
"""The default (primary) X backend, or None if no X source is available. |
| 1366 |
|
| 1367 |
Thin wrapper over ``x_backend_chain`` returning the first/primary backend; |
| 1368 |
callers that want failover should use ``x_backend_chain`` directly. |
| 1369 |
``local_only`` is forwarded (see ``x_backend_chain``). |
| 1370 |
""" |
| 1371 |
chain = x_backend_chain(config, local_only=local_only) |
| 1372 |
return chain[0] if chain else None |
| 1373 |
|
| 1374 |
|
| 1375 |
def x_pending_browser_auth(config: dict[str, Any], local_only: bool = False) -> bool: |
| 1376 |
"""True when X is not available now but ``FROM_BROWSER`` will authenticate it at run time. |
| 1377 |
|
| 1378 |
``--diagnose`` / ``--preflight`` load config in ``plan_only`` mode, which |
| 1379 |
deliberately skips browser-cookie extraction (no Keychain popup, |
| 1380 |
``reads_values: false``). As a result ``get_x_source`` returns None and X is |
| 1381 |
dropped from ``available_sources`` even though a normal run would extract the |
| 1382 |
same cookies and authenticate X fine. This predicate reports that |
| 1383 |
"available pending browser auth" state without reading a single cookie — it |
| 1384 |
keys only on the resolved browser list (``cookie_extraction_browsers`` |
| 1385 |
derives it from ``FROM_BROWSER`` alone, no secrets) OR — on extra hosts |
| 1386 |
only (``x_extras_enabled``) — the agentcookie sidecar being on PATH (a plain |
| 1387 |
``which`` lookup), bird being installed, and X having a cookie-domain |
| 1388 |
mapping. A plain MacBook must NOT predict bird from an agentcookie binary on |
| 1389 |
PATH (R18), so the sidecar leg is gated behind ``x_extras_enabled``. |
| 1390 |
Side-effect free, so the safe-inspection contract of diagnose/preflight is |
| 1391 |
preserved. |
| 1392 |
|
| 1393 |
Returns False whenever X is already available outright (static AUTH_TOKEN/CT0, |
| 1394 |
or xAI/xurl/xquik backend), and in ``read`` mode (a real run has already |
| 1395 |
extracted creds, so its status must be unchanged — never "pending"). |
| 1396 |
""" |
| 1397 |
# Already available via a static backend (bird creds, xAI, xurl, xquik). |
| 1398 |
# local_only (doctor/safe-diagnose) answers the xurl leg from the token |
| 1399 |
# store instead of the live `xurl whoami` network call. |
| 1400 |
if get_x_source(config, local_only=local_only): |
| 1401 |
return False |
| 1402 |
# Only meaningful in inspection modes that skip extraction; a real ``read`` |
| 1403 |
# run has already attempted extraction and must report its true state. |
| 1404 |
if config.get('_BROWSER_COOKIE_MODE') == 'read': |
| 1405 |
return False |
| 1406 |
# Cookie-only predicate: on an official-only host no run-time cookie |
| 1407 |
# source exists unless bird is pinned (x_policy), so nothing is pending. |
| 1408 |
if not x_policy(config).cookie_discovery: |
| 1409 |
return False |
| 1410 |
if 'x' not in COOKIE_DOMAINS: |
| 1411 |
return False |
| 1412 |
from . import bird_x |
| 1413 |
if not bird_x.is_bird_installed(): |
| 1414 |
return False |
| 1415 |
# A FROM_BROWSER browser is a run-time cookie source on any host. |
| 1416 |
if cookie_extraction_browsers(config): |
| 1417 |
return True |
| 1418 |
# The agentcookie sidecar is a run-time cookie source ONLY on extra hosts |
| 1419 |
# (Linux / Mac mini / Darwin sink / AGENTCOOKIE=on). Gating this keeps a |
| 1420 |
# plain MacBook from predicting bird off a stray agentcookie binary (R18). |
| 1421 |
if x_extras_enabled(config): |
| 1422 |
from . import agentcookie |
| 1423 |
if agentcookie.is_available(config): |
| 1424 |
return True |
| 1425 |
return False |
| 1426 |
|
| 1427 |
|
| 1428 |
def is_ytdlp_available() -> bool: |
| 1429 |
"""Check if yt-dlp is installed for YouTube search.""" |
| 1430 |
from . import youtube_yt |
| 1431 |
return youtube_yt.is_ytdlp_installed() |
| 1432 |
|
| 1433 |
|
| 1434 |
def is_youtube_comments_available(config: dict[str, Any]) -> bool: |
| 1435 |
"""Check if YouTube comment enrichment is available. |
| 1436 |
|
| 1437 |
yt-dlp fetches YouTube comments free and keyless, so when it is installed |
| 1438 |
comments need no credential and no ``INCLUDE_SOURCES`` opt-in — the opt-in |
| 1439 |
only ever existed to gate ScrapeCreators credit spend, and there is none to |
| 1440 |
gate. ``EXCLUDE_SOURCES=youtube_comments`` remains the off-switch. |
| 1441 |
|
| 1442 |
Without yt-dlp, the legacy ScrapeCreators path still applies: it requires |
| 1443 |
SCRAPECREATORS_API_KEY AND ``youtube_comments`` in ``INCLUDE_SOURCES`` |
| 1444 |
(mirroring ``is_tiktok_comments_available``), bounded by |
| 1445 |
``enrich_with_comments(max_videos=3)`` at ~3 credits per run. |
| 1446 |
""" |
| 1447 |
if 'youtube_comments' in _parse_exclude_sources(config): |
| 1448 |
return False |
| 1449 |
if is_ytdlp_available(): |
| 1450 |
return True |
| 1451 |
if not config.get('SCRAPECREATORS_API_KEY'): |
| 1452 |
return False |
| 1453 |
return 'youtube_comments' in _parse_include_sources(config) |
| 1454 |
|
| 1455 |
|
| 1456 |
def is_tiktok_comments_available(config: dict[str, Any]) -> bool: |
| 1457 |
"""Check if TikTok comment enrichment is available. |
| 1458 |
|
| 1459 |
Requires SCRAPECREATORS_API_KEY AND tiktok_comments in INCLUDE_SOURCES. |
| 1460 |
Mirrors the youtube_comments opt-in pattern. |
| 1461 |
""" |
| 1462 |
if not config.get('SCRAPECREATORS_API_KEY'): |
| 1463 |
return False |
| 1464 |
include = _parse_include_sources(config) |
| 1465 |
return 'tiktok_comments' in include |
| 1466 |
|
| 1467 |
|
| 1468 |
def is_instagram_comments_available(config: dict[str, Any]) -> bool: |
| 1469 |
"""Check if Instagram comment enrichment is available. |
| 1470 |
|
| 1471 |
Requires SCRAPECREATORS_API_KEY AND instagram_comments in INCLUDE_SOURCES. |
| 1472 |
Mirrors the youtube_comments / tiktok_comments opt-in pattern. Comments are |
| 1473 |
fetched via ScrapeCreators (GET /v2/instagram/post/comments) with each |
| 1474 |
comment's ``comment_like_count`` used as its vote for ranking. Part of the |
| 1475 |
default onboarding tier (posts on -> comments on for TikTok/Instagram/YouTube). |
| 1476 |
""" |
| 1477 |
if not config.get('SCRAPECREATORS_API_KEY'): |
| 1478 |
return False |
| 1479 |
return 'instagram_comments' in _parse_include_sources(config) |
| 1480 |
|
| 1481 |
|
| 1482 |
def is_youtube_sc_available(config: dict[str, Any]) -> bool: |
| 1483 |
"""Check if ScrapeCreators YouTube search fallback is available. |
| 1484 |
|
| 1485 |
Used when yt-dlp is not installed or fails. |
| 1486 |
""" |
| 1487 |
return bool(config.get('SCRAPECREATORS_API_KEY')) |
| 1488 |
|
| 1489 |
|
| 1490 |
def is_hackernews_available() -> bool: |
| 1491 |
"""Check if Hacker News source is available. |
| 1492 |
|
| 1493 |
Always returns True - HN uses free Algolia API, no key needed. |
| 1494 |
""" |
| 1495 |
return True |
| 1496 |
|
| 1497 |
|
| 1498 |
def is_native_search(config: dict[str, Any]) -> bool: |
| 1499 |
"""Whether the invoking host has its own (better) native web search. |
| 1500 |
|
| 1501 |
Defined by capability, not host identity: the SKILL.md agent-host path sets |
| 1502 |
``LAST30DAYS_NATIVE_SEARCH`` when the runtime actually has a native web-search |
| 1503 |
tool (e.g. Claude Code's WebSearch). When true, the engine's keyless search |
| 1504 |
floor is suppressed so a worse free search never preempts the model's own. |
| 1505 |
Defaults False (unset), so headless/cron and hosts without native search fall |
| 1506 |
to the keyless floor. |
| 1507 |
""" |
| 1508 |
raw = config.get('LAST30DAYS_NATIVE_SEARCH') |
| 1509 |
if raw is None: |
| 1510 |
return False |
| 1511 |
return str(raw).strip().lower() in ('1', 'true', 'yes', 'on') |
| 1512 |
|
| 1513 |
|
| 1514 |
def keyless_web_allowed(config: dict[str, Any]) -> bool: |
| 1515 |
"""Whether the engine may use its keyless web-search floor for this run. |
| 1516 |
|
| 1517 |
Allowed only when the host does NOT have native search. Independent of |
| 1518 |
whether a paid key is set (the grounding dispatcher prefers paid first and |
| 1519 |
falls to keyless on empty/error for non-native runs). |
| 1520 |
""" |
| 1521 |
return not is_native_search(config) |
| 1522 |
|
| 1523 |
|
| 1524 |
def transcription_providers(config: dict[str, Any]) -> list[tuple[str, str]]: |
| 1525 |
"""Ordered (name, api_key) Whisper providers for caption-free transcription. |
| 1526 |
|
| 1527 |
Groq (free tier) first, OpenAI (paid) as the backstop. Empty when neither |
| 1528 |
key is set, in which case transcription degrades rather than runs. |
| 1529 |
""" |
| 1530 |
providers: list[tuple[str, str]] = [] |
| 1531 |
if config.get('GROQ_API_KEY'): |
| 1532 |
providers.append(('groq', config['GROQ_API_KEY'])) |
| 1533 |
if config.get('OPENAI_API_KEY'): |
| 1534 |
providers.append(('openai', config['OPENAI_API_KEY'])) |
| 1535 |
return providers |
| 1536 |
|
| 1537 |
|
| 1538 |
def is_bluesky_available(config: dict[str, Any]) -> bool: |
| 1539 |
"""Check if Bluesky source is available. |
| 1540 |
|
| 1541 |
Requires BSKY_HANDLE and BSKY_APP_PASSWORD (app password from bsky.app/settings). |
| 1542 |
""" |
| 1543 |
return bool(config.get('BSKY_HANDLE') and config.get('BSKY_APP_PASSWORD')) |
| 1544 |
|
| 1545 |
|
| 1546 |
def is_truthsocial_available(config: dict[str, Any]) -> bool: |
| 1547 |
"""Check if Truth Social source is available. |
| 1548 |
|
| 1549 |
Requires TRUTHSOCIAL_TOKEN (bearer token from browser dev tools). |
| 1550 |
""" |
| 1551 |
return bool(config.get('TRUTHSOCIAL_TOKEN')) |
| 1552 |
|
| 1553 |
|
| 1554 |
def is_polymarket_available() -> bool: |
| 1555 |
"""Check if Polymarket source is available. |
| 1556 |
|
| 1557 |
Always returns True - Gamma API is free, no key needed. |
| 1558 |
""" |
| 1559 |
return True |
| 1560 |
|
| 1561 |
|
| 1562 |
def is_tiktok_available(config: dict[str, Any]) -> bool: |
| 1563 |
"""Check if TikTok source is available (ScrapeCreators or legacy Apify). |
| 1564 |
|
| 1565 |
Returns True if SCRAPECREATORS_API_KEY or APIFY_API_TOKEN is set. |
| 1566 |
""" |
| 1567 |
return bool(config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN')) |
| 1568 |
|
| 1569 |
|
| 1570 |
def get_tiktok_token(config: dict[str, Any]) -> str: |
| 1571 |
"""Get TikTok API token, preferring ScrapeCreators over legacy Apify.""" |
| 1572 |
return config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN') or '' |
| 1573 |
|
| 1574 |
|
| 1575 |
def _parse_include_sources(config: dict[str, Any]) -> set[str]: |
| 1576 |
"""Parse INCLUDE_SOURCES config value into a set of lowercase source names.""" |
| 1577 |
raw = config.get('INCLUDE_SOURCES') or '' |
| 1578 |
return {s.strip().lower() for s in raw.split(',') if s.strip()} |
| 1579 |
|
| 1580 |
|
| 1581 |
def _parse_exclude_sources(config: dict[str, Any]) -> set[str]: |
| 1582 |
"""Parse EXCLUDE_SOURCES config value into a set of lowercase source names.""" |
| 1583 |
raw = config.get('EXCLUDE_SOURCES') or '' |
| 1584 |
return {s.strip().lower() for s in raw.split(',') if s.strip()} |
| 1585 |
|
| 1586 |
|
| 1587 |
def include_sources(config: dict[str, Any]) -> set[str]: |
| 1588 |
"""Public view of the parsed INCLUDE_SOURCES set. |
| 1589 |
|
| 1590 |
Thin wrapper over ``_parse_include_sources`` so other modules (doctor, |
| 1591 |
etc.) don't reach into env's privates. |
| 1592 |
""" |
| 1593 |
return _parse_include_sources(config) |
| 1594 |
|
| 1595 |
|
| 1596 |
def is_setup_complete(config: dict[str, Any]) -> bool: |
| 1597 |
"""Whether guided setup marked this config complete (SETUP_COMPLETE truthy). |
| 1598 |
|
| 1599 |
Thin wrapper over ``_truthy`` so other modules don't reach into env's |
| 1600 |
privates. |
| 1601 |
""" |
| 1602 |
return _truthy(config.get('SETUP_COMPLETE')) |
| 1603 |
|
| 1604 |
|
| 1605 |
def is_threads_available(config: dict[str, Any]) -> bool: |
| 1606 |
"""Check if the Threads credential is available. |
| 1607 |
|
| 1608 |
Returns True when SCRAPECREATORS_API_KEY is set. This is an availability |
| 1609 |
predicate only: whether Threads is actually *scheduled* is gated in the |
| 1610 |
pipeline's ``available_sources`` by an ``INCLUDE_SOURCES=threads`` opt-in |
| 1611 |
(the onboarding "Everything" tier), so a key alone no longer runs Threads. |
| 1612 |
""" |
| 1613 |
return bool(config.get('SCRAPECREATORS_API_KEY')) |
| 1614 |
|
| 1615 |
|
| 1616 |
def is_instagram_available(config: dict[str, Any]) -> bool: |
| 1617 |
"""Check if Instagram source is available (ScrapeCreators). |
| 1618 |
|
| 1619 |
Returns True if SCRAPECREATORS_API_KEY is set. |
| 1620 |
Instagram uses the same key as TikTok. |
| 1621 |
""" |
| 1622 |
return bool(config.get('SCRAPECREATORS_API_KEY')) |
| 1623 |
|
| 1624 |
|
| 1625 |
def get_instagram_token(config: dict[str, Any]) -> str: |
| 1626 |
"""Get Instagram API token (same ScrapeCreators key as TikTok).""" |
| 1627 |
return config.get('SCRAPECREATORS_API_KEY') or '' |
| 1628 |
|
| 1629 |
|
| 1630 |
def get_xiaohongshu_api_base(config: dict[str, Any]) -> str: |
| 1631 |
"""Get Xiaohongshu HTTP API base URL. |
| 1632 |
|
| 1633 |
The availability probe caches the first logged-in local service it finds so |
| 1634 |
the later search request uses the same browser-backed session endpoint. |
| 1635 |
""" |
| 1636 |
cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY) |
| 1637 |
if cached: |
| 1638 |
return str(cached).rstrip("/") |
| 1639 |
|
| 1640 |
explicit = config.get("XIAOHONGSHU_API_BASE") |
| 1641 |
if explicit: |
| 1642 |
return str(explicit).rstrip("/") |
| 1643 |
|
| 1644 |
return XIAOHONGSHU_DEFAULT_API_BASES[0] |
| 1645 |
|
| 1646 |
|
| 1647 |
def _xiaohongshu_api_base_candidates(config: dict[str, Any]) -> list[str]: |
| 1648 |
explicit = config.get("XIAOHONGSHU_API_BASE") |
| 1649 |
if explicit: |
| 1650 |
return [str(explicit).rstrip("/")] |
| 1651 |
|
| 1652 |
candidates: list[str] = [] |
| 1653 |
cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY) |
| 1654 |
if cached: |
| 1655 |
candidates.append(str(cached).rstrip("/")) |
| 1656 |
|
| 1657 |
for base in XIAOHONGSHU_DEFAULT_API_BASES: |
| 1658 |
if base not in candidates: |
| 1659 |
candidates.append(base) |
| 1660 |
return candidates |
| 1661 |
|
| 1662 |
|
| 1663 |
def _xiaohongshu_base_logged_in(base: str, http_module: Any) -> bool: |
| 1664 |
# Keep the health probe snappy, but allow one retry for transient hiccups. |
| 1665 |
health = http_module.get(f"{base}/health", timeout=3, retries=2) |
| 1666 |
if not isinstance(health, dict): |
| 1667 |
return False |
| 1668 |
if not health.get("success"): |
| 1669 |
return False |
| 1670 |
|
| 1671 |
# Login checks can be slower because some services consult the browser |
| 1672 |
# profile/session, so use a slightly longer timeout than the health probe. |
| 1673 |
login = http_module.get(f"{base}/api/v1/login/status", timeout=8, retries=2) |
| 1674 |
is_logged_in = ( |
| 1675 |
login.get("data", {}).get("is_logged_in") |
| 1676 |
if isinstance(login, dict) else False |
| 1677 |
) |
| 1678 |
return bool(is_logged_in) |
| 1679 |
|
| 1680 |
|
| 1681 |
def is_xiaohongshu_available(config: dict[str, Any]) -> bool: |
| 1682 |
"""Check whether Xiaohongshu HTTP API is reachable and logged in.""" |
| 1683 |
# Import here to avoid heavy imports at module load. |
| 1684 |
from . import http |
| 1685 |
|
| 1686 |
for base in _xiaohongshu_api_base_candidates(config): |
| 1687 |
try: |
| 1688 |
if _xiaohongshu_base_logged_in(base, http): |
| 1689 |
config[XIAOHONGSHU_RESOLVED_API_BASE_KEY] = base |
| 1690 |
return True |
| 1691 |
except (OSError, http.HTTPError): |
| 1692 |
continue |
| 1693 |
except Exception as exc: |
| 1694 |
sys.stderr.write( |
| 1695 |
f"[last30days] WARNING: unexpected error checking Xiaohongshu " |
| 1696 |
f"at {base}: {type(exc).__name__}: {exc}\n" |
| 1697 |
) |
| 1698 |
sys.stderr.flush() |
| 1699 |
return False |
| 1700 |
|
| 1701 |
|
| 1702 |
# Backward compat alias |
| 1703 |
is_apify_available = is_tiktok_available |
| 1704 |
|
| 1705 |
|
| 1706 |
def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str, Any]: |
| 1707 |
"""Get detailed X source status for UI decisions. |
| 1708 |
|
| 1709 |
Args: |
| 1710 |
probe: when True, run a cheap 1-tweet bird probe and downgrade |
| 1711 |
``bird_authenticated`` to False when X clearly returns nothing, |
| 1712 |
so ``--diagnose`` reflects runtime reality instead of static |
| 1713 |
credential presence. A transient timeout leaves the status |
| 1714 |
unchanged (fail open). When False (the safe/diagnose path that |
| 1715 |
doctor uses), NO network is touched: xurl availability comes |
| 1716 |
from local evidence (``xurl_x.has_stored_auth``), never the |
| 1717 |
live ``xurl whoami`` call. |
| 1718 |
|
| 1719 |
Returns: |
| 1720 |
Dict with keys: source, bird_installed, bird_authenticated, |
| 1721 |
bird_username, xai_available, can_install_bird |
| 1722 |
""" |
| 1723 |
from . import bird_x |
| 1724 |
|
| 1725 |
# Backends this host may run: the policy's auto chain plus a known pin. |
| 1726 |
# Bird is primed/probed and xquik is probed only when they are in that |
| 1727 |
# set, so an official-only host never touches the scraper or the |
| 1728 |
# third-party API unless the backend is pinned. |
| 1729 |
policy = x_policy(config) |
| 1730 |
pin = x_backend_pin(config) |
| 1731 |
considered = set(policy.auto_chain) |
| 1732 |
if pin in _X_BACKEND_KNOWN: |
| 1733 |
considered.add(pin) |
| 1734 |
|
| 1735 |
if 'bird' in considered and config.get('AUTH_TOKEN') and config.get('CT0'): |
| 1736 |
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0')) |
| 1737 |
bird_status = dict(bird_x.get_bird_status()) |
| 1738 |
if 'bird' not in considered: |
| 1739 |
# Never report the scraper as usable where the policy forbids it. |
| 1740 |
bird_status["authenticated"] = False |
| 1741 |
xai_available = bool(config.get('XAI_API_KEY')) |
| 1742 |
xapi_available = bool(config.get('X_BEARER_TOKEN')) |
| 1743 |
|
| 1744 |
# Report the TRUE auth lane (browser / env / keychain) rather than the static |
| 1745 |
# "env AUTH_TOKEN" label — tokens usually come from live browser cookies, and |
| 1746 |
# mislabeling the lane sent past debugging down a 30-minute wrong path. |
| 1747 |
if bird_status["authenticated"]: |
| 1748 |
lane = config.get('_AUTH_TOKEN_SOURCE') or 'env' |
| 1749 |
bird_status["username"] = f"{lane} AUTH_TOKEN" |
| 1750 |
|
| 1751 |
# Optional runtime probe: don't show X green when it's effectively dead. |
| 1752 |
if probe and bird_status["authenticated"]: |
| 1753 |
if bird_x.probe_works() is False: |
| 1754 |
bird_status["authenticated"] = False |
| 1755 |
bird_status["username"] = "probe failed (no working X auth)" |
| 1756 |
|
| 1757 |
# Xquik: the key-based X source used when bird's cookie auth isn't available. |
| 1758 |
# Probe so --diagnose reports the true state — funded, or configured-but- |
| 1759 |
# unpaid (402) — instead of false-green on mere key presence. |
| 1760 |
xquik_available = is_xquik_available(config) |
| 1761 |
xquik_working: bool | None = None |
| 1762 |
xquik_status = "" |
| 1763 |
if xquik_available and 'xquik' in considered: |
| 1764 |
if probe: |
| 1765 |
from . import xquik |
| 1766 |
xquik_working = xquik.probe_works(get_xquik_token(config)) |
| 1767 |
xquik_status = xquik.probe_reason() |
| 1768 |
else: |
| 1769 |
xquik_status = "configured (not probed)" |
| 1770 |
|
| 1771 |
# Xurl availability, computed ONCE. probe=True (a live diagnose) may run |
| 1772 |
# the real `xurl whoami`; probe=False is the safe path (doctor, |
| 1773 |
# --diagnose, --preflight) and must stay local-only — the live check is |
| 1774 |
# an authenticated X API network call. |
| 1775 |
from . import xurl_x as _xurl_x |
| 1776 |
xurl_available = _xurl_x.is_available() if probe else _xurl_x.has_stored_auth() |
| 1777 |
|
| 1778 |
# Grok availability is filesystem-only on both paths (PATH lookup plus the |
| 1779 |
# credential store), so it is safe to compute here regardless of `probe`. |
| 1780 |
# Grok is opt-in only: it appears in grok_available but never wins the |
| 1781 |
# unpinned source selection. |
| 1782 |
from . import grok_x as _grok_x |
| 1783 |
grok_available = _grok_x.has_stored_auth() |
| 1784 |
|
| 1785 |
# Determine active source. A pin forces a single backend (R4): ANY known |
| 1786 |
# pin is exclusive, mirroring x_backend_chain's [] semantics. Pinned |
| 1787 |
# backend available -> that source. Pinned backend unavailable -> None. |
| 1788 |
# Otherwise walk the policy's auto chain (default: bird first, cookies |
| 1789 |
# beat XAI_API_KEY when both are present, then xai, xurl, xquik; |
| 1790 |
# official-only: xapi, xai, xurl). Opt-in backends are never |
| 1791 |
# auto-selected; a leftover ~/.grok/auth.json must not steal the X lane. |
| 1792 |
usable = { |
| 1793 |
'bird': bird_status["authenticated"], |
| 1794 |
'xai': xai_available, |
| 1795 |
'xurl': xurl_available, |
| 1796 |
'xquik': xquik_available and xquik_working is not False, |
| 1797 |
'grok': grok_available, |
| 1798 |
'xapi': xapi_available, |
| 1799 |
} |
| 1800 |
if pin in _X_BACKEND_KNOWN: |
| 1801 |
# Pin is exclusive: pinned backend if available, else None (no fallback). |
| 1802 |
source = pin if usable.get(pin) else None |
| 1803 |
else: |
| 1804 |
source = next((b for b in policy.auto_chain if usable.get(b)), None) |
| 1805 |
|
| 1806 |
return { |
| 1807 |
"source": source, |
| 1808 |
"bird_installed": bird_status["installed"], |
| 1809 |
"bird_authenticated": bird_status["authenticated"], |
| 1810 |
"bird_username": bird_status["username"], |
| 1811 |
"xai_available": xai_available, |
| 1812 |
"xapi_available": xapi_available, |
| 1813 |
"grok_available": grok_available, |
| 1814 |
"xurl_available": xurl_available, |
| 1815 |
"xquik_available": xquik_available, |
| 1816 |
"xquik_working": xquik_working, |
| 1817 |
"xquik_status": xquik_status, |
| 1818 |
"can_install_bird": bird_status["can_install"], |
| 1819 |
} |
| 1820 |
|
| 1821 |
|
| 1822 |
# Pinterest |
| 1823 |
def is_pinterest_available(config: dict[str, Any]) -> bool: |
| 1824 |
"""Check if Pinterest source is available. |
| 1825 |
|
| 1826 |
Returns True when SCRAPECREATORS_API_KEY is set AND 'pinterest' is in |
| 1827 |
INCLUDE_SOURCES (or requested_sources at the pipeline level). Pinterest |
| 1828 |
is opt-in because not every topic benefits from visual pin results. |
| 1829 |
""" |
| 1830 |
return bool(config.get('SCRAPECREATORS_API_KEY')) |
| 1831 |
|
| 1832 |
|
| 1833 |
def get_pinterest_token(config: dict[str, Any]) -> str: |
| 1834 |
"""Get Pinterest API token (same ScrapeCreators key as TikTok/Instagram).""" |
| 1835 |
return config.get('SCRAPECREATORS_API_KEY') or '' |
| 1836 |
|
| 1837 |
|
| 1838 |
# Xquik |
| 1839 |
def is_xquik_available(config: dict[str, Any]) -> bool: |
| 1840 |
"""Check if Xquik X search source is available. |
| 1841 |
|
| 1842 |
Requires XQUIK_API_KEY (API key from xquik.com). |
| 1843 |
""" |
| 1844 |
return bool(config.get('XQUIK_API_KEY')) |
| 1845 |
|
| 1846 |
|
| 1847 |
def get_xquik_token(config: dict[str, Any]) -> str: |
| 1848 |
"""Get Xquik API key.""" |
| 1849 |
return config.get('XQUIK_API_KEY') or '' |
| 1850 |
|