fix(bird_x): pass .env credentials to Node subprocesses for WSL2/headless auth

* chore: fix YAML error in argument-hint

* add codex auth support to responses API

* Use gpt-5.1-codex-mini as default model for Codex auth

Add CODEX_FALLBACK_MODELS chain (gpt-5.1-codex-mini → gpt-5.2) for
Codex endpoint which doesn't support standard OpenAI models like
gpt-4o-mini. Adds model fallback retry on 400 errors in the Codex
search path. Also adds test_codex_auth.py with 22 unit tests covering
JWT decoding, auth resolution, SSE parsing, and payload building.

* Pass .env credentials to Bird Node subprocesses for X auth

On platforms without browser cookie access (e.g. WSL2), Bird's
vendored Node.js module cannot read AUTH_TOKEN/CT0 from Firefox
or Chrome cookie stores. The .env config file already supports
these values, but they were only loaded into the Python config
dict — never exported to the environment of Node subprocesses.

- Add AUTH_TOKEN/CT0 to env.py config key loading
- Add set_credentials()/\_subprocess_env() to bird_x.py to inject
  credentials into the env dict passed to subprocess.run/Popen
- Call set_credentials() in main() before Bird auth detection

---------

Co-authored-by: Justin Williams <jblwilliams@gmail.com>
This commit is contained in:
Ilia Alshanetsky
2026-03-03 02:24:59 -05:00
committed by GitHub
parent 1ed990a081
commit d7bff81757
12 changed files with 582 additions and 37 deletions
+7 -1
View File
@@ -111,7 +111,6 @@ from lib import (
schema,
score,
ui,
websearch,
xai_x,
youtube_yt,
)
@@ -154,6 +153,8 @@ def _search_reddit(
from_date,
to_date,
depth=depth,
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
)
except http.HTTPError as e:
raw_openai = {"error": str(e)}
@@ -176,6 +177,8 @@ def _search_reddit(
core,
from_date, to_date,
depth=depth,
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
)
retry_items = openai_reddit.parse_reddit_response(retry_raw)
# Add items not already found (by URL)
@@ -1032,6 +1035,9 @@ def main():
# Load config
config = env.get_config()
# Inject .env credentials into Bird module before auth check
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
# Auto-detect Bird (no prompts - just use it if available)
x_source_status = env.get_x_source_status(config)
x_source = x_source_status["source"] # 'bird', 'xai', or None
+20
View File
@@ -24,6 +24,24 @@ DEPTH_CONFIG = {
"deep": 60,
}
# Module-level credentials injected from .env config
_credentials: Dict[str, str] = {}
def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
"""Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them."""
if auth_token:
_credentials['AUTH_TOKEN'] = auth_token
if ct0:
_credentials['CT0'] = ct0
def _subprocess_env() -> Dict[str, str]:
"""Build env dict for Node subprocesses, merging injected credentials."""
env = os.environ.copy()
env.update(_credentials)
return env
def _log(msg: str):
"""Log to stderr."""
@@ -112,6 +130,7 @@ def is_bird_authenticated() -> Optional[str]:
capture_output=True,
text=True,
timeout=15,
env=_subprocess_env(),
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split('\n')[0]
@@ -187,6 +206,7 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
env=_subprocess_env(),
)
# Register for cleanup tracking (if available)
+151 -7
View File
@@ -1,9 +1,12 @@
"""Environment and API key management for last30days skill."""
import base64
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Literal
# Allow override via environment variable for testing
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
@@ -20,6 +23,29 @@ else:
CONFIG_DIR = Path.home() / ".config" / "last30days"
CONFIG_FILE = CONFIG_DIR / ".env"
CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json")))
AuthSource = Literal["api_key", "codex", "none"]
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
AUTH_SOURCE_API_KEY: AuthSource = "api_key"
AUTH_SOURCE_CODEX: AuthSource = "codex"
AUTH_SOURCE_NONE: AuthSource = "none"
AUTH_STATUS_OK: AuthStatus = "ok"
AUTH_STATUS_MISSING: AuthStatus = "missing"
AUTH_STATUS_EXPIRED: AuthStatus = "expired"
AUTH_STATUS_MISSING_ACCOUNT_ID: AuthStatus = "missing_account_id"
@dataclass(frozen=True)
class OpenAIAuth:
token: Optional[str]
source: AuthSource
status: AuthStatus
account_id: Optional[str]
codex_auth_file: str
def load_env_file(path: Path) -> Dict[str, str]:
"""Load environment variables from a file."""
@@ -44,14 +70,131 @@ def load_env_file(path: Path) -> Dict[str, str]:
return env
def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
"""Decode JWT payload without verification."""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
pad = "=" * (-len(payload_b64) % 4)
decoded = base64.urlsafe_b64decode(payload_b64 + pad)
return json.loads(decoded.decode("utf-8"))
except Exception:
return None
def _token_expired(token: str, leeway_seconds: int = 60) -> bool:
"""Check if JWT token is expired."""
payload = _decode_jwt_payload(token)
if not payload:
return False
exp = payload.get("exp")
if not exp:
return False
return exp <= (time.time() + leeway_seconds)
def extract_chatgpt_account_id(access_token: str) -> Optional[str]:
"""Extract chatgpt_account_id from JWT token."""
payload = _decode_jwt_payload(access_token)
if not payload:
return None
auth_claim = payload.get("https://api.openai.com/auth", {})
if isinstance(auth_claim, dict):
return auth_claim.get("chatgpt_account_id")
return None
def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> Dict[str, Any]:
"""Load Codex auth JSON."""
if not path.exists():
return {}
try:
with open(path, "r") as f:
return json.load(f)
except Exception:
return {}
def get_codex_access_token() -> tuple[Optional[str], str]:
"""Get Codex access token from auth.json.
Returns:
(token, status) where status is 'ok', 'missing', or 'expired'
"""
auth = load_codex_auth()
token = None
if isinstance(auth, dict):
tokens = auth.get("tokens") or {}
if isinstance(tokens, dict):
token = tokens.get("access_token")
if not token:
token = auth.get("access_token")
if not token:
return None, AUTH_STATUS_MISSING
if _token_expired(token):
return None, AUTH_STATUS_EXPIRED
return token, AUTH_STATUS_OK
def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
"""Resolve OpenAI auth from API key or Codex login."""
api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
if api_key:
return OpenAIAuth(
token=api_key,
source=AUTH_SOURCE_API_KEY,
status=AUTH_STATUS_OK,
account_id=None,
codex_auth_file=str(CODEX_AUTH_FILE),
)
codex_token, codex_status = get_codex_access_token()
if codex_token:
account_id = extract_chatgpt_account_id(codex_token)
if account_id:
return OpenAIAuth(
token=codex_token,
source=AUTH_SOURCE_CODEX,
status=AUTH_STATUS_OK,
account_id=account_id,
codex_auth_file=str(CODEX_AUTH_FILE),
)
return OpenAIAuth(
token=None,
source=AUTH_SOURCE_CODEX,
status=AUTH_STATUS_MISSING_ACCOUNT_ID,
account_id=None,
codex_auth_file=str(CODEX_AUTH_FILE),
)
return OpenAIAuth(
token=None,
source=AUTH_SOURCE_NONE,
status=codex_status,
account_id=None,
codex_auth_file=str(CODEX_AUTH_FILE),
)
def get_config() -> Dict[str, Any]:
"""Load configuration from ~/.config/last30days/.env and environment."""
# Load from config file first (if configured)
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
# Build config: process.env > .env file
openai_auth = get_openai_auth(file_env)
# Build config: Codex/OpenAI auth + process.env > .env file
config = {
'OPENAI_API_KEY': openai_auth.token,
'OPENAI_AUTH_SOURCE': openai_auth.source,
'OPENAI_AUTH_STATUS': openai_auth.status,
'OPENAI_CHATGPT_ACCOUNT_ID': openai_auth.account_id,
'CODEX_AUTH_FILE': openai_auth.codex_auth_file,
}
keys = [
('OPENAI_API_KEY', None),
('XAI_API_KEY', None),
('OPENROUTER_API_KEY', None),
('PARALLEL_API_KEY', None),
@@ -60,9 +203,10 @@ def get_config() -> Dict[str, Any]:
('OPENAI_MODEL_PIN', None),
('XAI_MODEL_POLICY', 'latest'),
('XAI_MODEL_PIN', None),
('AUTH_TOKEN', None),
('CT0', None),
]
config = {}
for key, default in keys:
config[key] = os.environ.get(key) or file_env.get(key, default)
@@ -79,7 +223,7 @@ def get_available_sources(config: Dict[str, Any]) -> str:
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
"""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config)
@@ -121,7 +265,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
"""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config)
@@ -170,7 +314,7 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
elif requested == 'web':
return 'web', None
else:
return 'web', f"Only web search keys configured. Add OPENAI_API_KEY for Reddit, XAI_API_KEY for X."
return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X."
if requested == 'auto':
# Add web to sources if include_web is set
+11 -4
View File
@@ -38,6 +38,7 @@ def request(
json_data: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
raw: bool = False,
) -> Dict[str, Any]:
"""Make an HTTP request and return JSON response.
@@ -50,7 +51,7 @@ def request(
retries: Number of retries on failure
Returns:
Parsed JSON response
Parsed JSON response (or raw text if raw=True)
Raises:
HTTPError: On request failure
@@ -66,8 +67,6 @@ def request(
req = urllib.request.Request(url, data=data, headers=headers, method=method)
log(f"{method} {url}")
if json_data:
log(f"Payload keys: {list(json_data.keys())}")
last_error = None
for attempt in range(retries):
@@ -75,6 +74,8 @@ def request(
with urllib.request.urlopen(req, timeout=timeout) as response:
body = response.read().decode('utf-8')
log(f"Response: {response.status} ({len(body)} bytes)")
if raw:
return body
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
body = None
@@ -84,7 +85,8 @@ def request(
pass
log(f"HTTP Error {e.code}: {e.reason}")
if body:
log(f"Error body: {body[:500]}")
snippet = " ".join(body.split())
log(f"Error body: {snippet[:200]}")
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
# Don't retry client errors (4xx) except rate limits
@@ -137,6 +139,11 @@ def post(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]]
return request("POST", url, headers=headers, json_data=json_data, **kwargs)
def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, **kwargs) -> str:
"""Make a POST request with JSON body and return raw text."""
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
"""Fetch Reddit thread JSON.
+17 -7
View File
@@ -3,11 +3,12 @@
import re
from typing import Dict, List, Optional, Tuple
from . import cache, http
from . import cache, http, env
# OpenAI API
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"]
CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
# xAI API - Agent Tools API requires grok-4 family
XAI_MODELS_URL = "https://api.x.ai/v1/models"
@@ -157,12 +158,21 @@ def get_models(
result = {"openai": None, "xai": None}
if config.get("OPENAI_API_KEY"):
result["openai"] = select_openai_model(
config["OPENAI_API_KEY"],
config.get("OPENAI_MODEL_POLICY", "auto"),
config.get("OPENAI_MODEL_PIN"),
mock_openai_models,
)
if config.get("OPENAI_AUTH_SOURCE") == env.AUTH_SOURCE_CODEX:
# Codex auth doesn't use the OpenAI models list endpoint
policy = config.get("OPENAI_MODEL_POLICY", "auto")
pin = config.get("OPENAI_MODEL_PIN")
if policy == "pinned" and pin:
result["openai"] = pin
else:
result["openai"] = CODEX_FALLBACK_MODELS[0]
else:
result["openai"] = select_openai_model(
config["OPENAI_API_KEY"],
config.get("OPENAI_MODEL_POLICY", "auto"),
config.get("OPENAI_MODEL_PIN"),
mock_openai_models,
)
if config.get("XAI_API_KEY"):
result["xai"] = select_xai_model(
+159 -6
View File
@@ -5,7 +5,7 @@ import re
import sys
from typing import Any, Dict, List, Optional
from . import http
from . import http, env
# Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5)
# Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it
@@ -42,6 +42,93 @@ def _is_model_access_error(error: http.HTTPError) -> bool:
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
CODEX_INSTRUCTIONS = (
"You are a research assistant for a skill that summarizes what people are "
"discussing in the last 30 days. Your goal is to find relevant Reddit threads "
"about the topic and return ONLY the required JSON. Be inclusive (return more "
"rather than fewer), but avoid irrelevant results. Prefer threads with discussion "
"and comments. If you can infer a date, include it; otherwise use null. "
"Do not include developers.reddit.com or business.reddit.com."
)
def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]:
"""Parse a single SSE chunk into a JSON object."""
lines = chunk.split("\n")
data_lines = []
for line in lines:
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if not data_lines:
return None
data = "\n".join(data_lines).strip()
if not data or data == "[DONE]":
return None
try:
return json.loads(data)
except json.JSONDecodeError:
return None
def _parse_sse_stream_raw(raw: str) -> List[Dict[str, Any]]:
"""Parse SSE stream from raw text and return JSON events."""
events: List[Dict[str, Any]] = []
buffer = ""
for chunk in raw.splitlines(keepends=True):
buffer += chunk
while "\n\n" in buffer:
event_chunk, buffer = buffer.split("\n\n", 1)
event = _parse_sse_chunk(event_chunk)
if event is not None:
events.append(event)
if buffer.strip():
event = _parse_sse_chunk(buffer)
if event is not None:
events.append(event)
return events
def _parse_codex_stream(raw: str) -> Dict[str, Any]:
"""Parse SSE stream from Codex responses into a response-like dict."""
events = _parse_sse_stream_raw(raw)
# Prefer explicit completed response payload if present
for evt in reversed(events):
if isinstance(evt, dict):
if evt.get("type") == "response.completed" and isinstance(evt.get("response"), dict):
return evt["response"]
if isinstance(evt.get("response"), dict):
return evt["response"]
# Fallback: reconstruct output text from deltas
output_text = ""
for evt in events:
if not isinstance(evt, dict):
continue
delta = evt.get("delta")
if isinstance(delta, str):
output_text += delta
continue
text = evt.get("text")
if isinstance(text, str):
output_text += text
if output_text:
return {
"output": [
{
"type": "message",
"content": [{"type": "output_text", "text": output_text}],
}
]
}
return {}
# Depth configurations: (min, max) threads to request
# Request MORE than needed since many get filtered by date
@@ -116,6 +203,35 @@ def _build_subreddit_query(topic: str) -> str:
return f"r/{sub_name} site:reddit.com"
def _build_payload(model: str, instructions_text: str, input_text: str, auth_source: str) -> Dict[str, Any]:
"""Build responses payload for OpenAI or Codex endpoints."""
payload = {
"model": model,
"store": False,
"tools": [
{
"type": "web_search",
"filters": {
"allowed_domains": ["reddit.com"]
}
}
],
"include": ["web_search_call.action.sources"],
"instructions": instructions_text,
"input": input_text,
}
if auth_source == env.AUTH_SOURCE_CODEX:
payload["input"] = [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": input_text}],
}
]
payload["stream"] = True
return payload
def search_reddit(
api_key: str,
model: str,
@@ -123,6 +239,8 @@ def search_reddit(
from_date: str,
to_date: str,
depth: str = "default",
auth_source: str = "api_key",
account_id: Optional[str] = None,
mock_response: Optional[Dict] = None,
_retry: bool = False,
) -> Dict[str, Any]:
@@ -145,10 +263,23 @@ def search_reddit(
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if auth_source == env.AUTH_SOURCE_CODEX:
if not account_id:
raise ValueError("Missing chatgpt_account_id for Codex auth")
headers = {
"Authorization": f"Bearer {api_key}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": "pi",
"Content-Type": "application/json",
}
url = CODEX_RESPONSES_URL
else:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
url = OPENAI_RESPONSES_URL
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
@@ -166,6 +297,28 @@ def search_reddit(
max_items=max_items,
)
if auth_source == env.AUTH_SOURCE_CODEX:
# Codex auth: try model with fallback chain
from . import models as models_mod
codex_models_to_try = [model] + [m for m in models_mod.CODEX_FALLBACK_MODELS if m != model]
instructions_text = CODEX_INSTRUCTIONS + "\n\n" + input_text
last_error = None
for current_model in codex_models_to_try:
try:
payload = _build_payload(current_model, instructions_text, topic, auth_source)
raw = http.post_raw(url, payload, headers=headers, timeout=timeout)
return _parse_codex_stream(raw or "")
except http.HTTPError as e:
last_error = e
if e.status_code == 400:
_log_info(f"Model {current_model} not supported on Codex, trying fallback...")
continue
raise
if last_error:
raise last_error
raise http.HTTPError("No Codex-compatible models available")
# Standard API key auth: try model fallback chain
last_error = None
for current_model in models_to_try:
payload = {
@@ -183,7 +336,7 @@ def search_reddit(
}
try:
return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
return http.post(url, payload, headers=headers, timeout=timeout)
except http.HTTPError as e:
last_error = e
if _is_model_access_error(e):
+8 -5
View File
@@ -4,7 +4,7 @@ import json
import os
import tempfile
from pathlib import Path
from typing import List, Optional
from typing import Optional
from . import schema
@@ -101,10 +101,11 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
lines.append("")
lines.append("---")
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
lines.append("**⚡ Want better results?** Add API keys or sign in to Codex to unlock Reddit & X data:")
lines.append("- `OPENAI_API_KEY` or `codex login` → Reddit threads with real upvotes & comments")
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
lines.append("- Edit `~/.config/last30days/.env` to add keys")
lines.append("- If already signed in but still seeing this, re-run `codex login`")
lines.append("---")
lines.append("")
@@ -129,7 +130,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
lines.append("")
elif report.mode == "x-only" and missing_keys == "reddit":
lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
lines.append("*💡 Tip: Add OPENAI_API_KEY or run `codex login` for Reddit data and better triangulation. If already signed in, re-run `codex login`.*")
lines.append("")
# Reddit items
@@ -168,7 +169,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
# Top comment insights
if item.comment_insights:
lines.append(f" Insights:")
lines.append(" Insights:")
for insight in item.comment_insights[:3]:
lines.append(f" - {insight}")
@@ -630,6 +631,8 @@ def render_full_report(report: schema.Report) -> str:
return "\n".join(lines)
def write_outputs(
report: schema.Report,
raw_openai: Optional[dict] = None,
+2 -3
View File
@@ -1,6 +1,5 @@
"""Terminal UI utilities for last30days skill."""
import os
import sys
import time
import threading
@@ -117,7 +116,7 @@ I just researched that for you. Here's what I've got right now:
{status_line}
You can unlock more sources with API keys — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
You can unlock more sources with API keys or by signing in to Codex — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
Some examples of what you can do:
- "last30 what are people saying about Figma"
@@ -131,7 +130,7 @@ Just start with "last30" and talk to me like normal.
# Shorter promo for single missing key
PROMO_SINGLE_KEY = {
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key — just ask me how.\n",
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n",
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
}