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:
@@ -0,0 +1,199 @@
|
||||
"""Tests for Codex auth integration (env.py + openai_reddit.py)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Add scripts directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from lib import env, openai_reddit
|
||||
|
||||
|
||||
def _make_jwt(payload: dict) -> str:
|
||||
"""Build a fake JWT with the given payload (no signature verification)."""
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=")
|
||||
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
|
||||
return f"{header.decode()}.{body.decode()}.fakesig"
|
||||
|
||||
|
||||
class TestDecodeJwtPayload(unittest.TestCase):
|
||||
|
||||
def test_valid_jwt(self):
|
||||
token = _make_jwt({"sub": "user123", "exp": 9999999999})
|
||||
result = env._decode_jwt_payload(token)
|
||||
self.assertEqual(result["sub"], "user123")
|
||||
|
||||
def test_invalid_jwt(self):
|
||||
self.assertIsNone(env._decode_jwt_payload("not-a-jwt"))
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertIsNone(env._decode_jwt_payload(""))
|
||||
|
||||
|
||||
class TestTokenExpired(unittest.TestCase):
|
||||
|
||||
def test_not_expired(self):
|
||||
token = _make_jwt({"exp": int(time.time()) + 3600})
|
||||
self.assertFalse(env._token_expired(token))
|
||||
|
||||
def test_expired(self):
|
||||
token = _make_jwt({"exp": int(time.time()) - 100})
|
||||
self.assertTrue(env._token_expired(token))
|
||||
|
||||
def test_no_exp_claim(self):
|
||||
token = _make_jwt({"sub": "user"})
|
||||
self.assertFalse(env._token_expired(token))
|
||||
|
||||
|
||||
class TestExtractChatgptAccountId(unittest.TestCase):
|
||||
|
||||
def test_extracts_account_id(self):
|
||||
token = _make_jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct_abc123"
|
||||
}
|
||||
})
|
||||
self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123")
|
||||
|
||||
def test_missing_auth_claim(self):
|
||||
token = _make_jwt({"sub": "user"})
|
||||
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||
|
||||
def test_missing_account_id_in_claim(self):
|
||||
token = _make_jwt({
|
||||
"https://api.openai.com/auth": {"other_field": "value"}
|
||||
})
|
||||
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||
|
||||
|
||||
class TestGetOpenaiAuth(unittest.TestCase):
|
||||
|
||||
def test_api_key_takes_priority(self):
|
||||
"""OPENAI_API_KEY in env file should be preferred over Codex."""
|
||||
file_env = {"OPENAI_API_KEY": "sk-test123"}
|
||||
auth = env.get_openai_auth(file_env)
|
||||
self.assertEqual(auth.source, "api_key")
|
||||
self.assertEqual(auth.status, "ok")
|
||||
self.assertEqual(auth.token, "sk-test123")
|
||||
self.assertIsNone(auth.account_id)
|
||||
|
||||
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False)
|
||||
def test_env_var_takes_priority(self):
|
||||
"""OPENAI_API_KEY env var should be preferred over file."""
|
||||
file_env = {}
|
||||
auth = env.get_openai_auth(file_env)
|
||||
self.assertEqual(auth.source, "api_key")
|
||||
self.assertEqual(auth.token, "sk-from-env")
|
||||
|
||||
def test_no_keys_returns_none_source(self):
|
||||
"""No API key and no Codex auth → source=none."""
|
||||
fake_path = Path("/tmp/nonexistent_codex_auth_test.json")
|
||||
with patch.object(env, 'CODEX_AUTH_FILE', fake_path):
|
||||
# Also patch get_codex_access_token to avoid reading real auth file
|
||||
with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")):
|
||||
environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
|
||||
with patch.dict(os.environ, environ_copy, clear=True):
|
||||
auth = env.get_openai_auth({})
|
||||
self.assertEqual(auth.source, "none")
|
||||
self.assertIsNone(auth.token)
|
||||
|
||||
|
||||
class TestLoadCodexAuth(unittest.TestCase):
|
||||
|
||||
def test_nonexistent_file(self):
|
||||
result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json"))
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_valid_json(self):
|
||||
import tempfile
|
||||
data = {"tokens": {"access_token": "tok123"}}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(data, f)
|
||||
f.flush()
|
||||
result = env.load_codex_auth(Path(f.name))
|
||||
os.unlink(f.name)
|
||||
self.assertEqual(result["tokens"]["access_token"], "tok123")
|
||||
|
||||
|
||||
class TestGetAvailableSourcesWithAuth(unittest.TestCase):
|
||||
|
||||
def test_codex_auth_ok_counts_as_openai(self):
|
||||
config = {
|
||||
"OPENAI_API_KEY": "codex-token",
|
||||
"OPENAI_AUTH_STATUS": "ok",
|
||||
"XAI_API_KEY": None,
|
||||
}
|
||||
result = env.get_available_sources(config)
|
||||
self.assertIn("reddit", result)
|
||||
|
||||
def test_codex_auth_expired_not_counted(self):
|
||||
config = {
|
||||
"OPENAI_API_KEY": None,
|
||||
"OPENAI_AUTH_STATUS": "expired",
|
||||
"XAI_API_KEY": None,
|
||||
}
|
||||
result = env.get_available_sources(config)
|
||||
self.assertEqual(result, "web")
|
||||
|
||||
|
||||
class TestParseCodexStream(unittest.TestCase):
|
||||
|
||||
def test_response_completed_event(self):
|
||||
"""Should extract response from response.completed SSE event."""
|
||||
sse = (
|
||||
'data: {"type":"response.created","response":{"id":"r1"}}\n\n'
|
||||
'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n'
|
||||
)
|
||||
result = openai_reddit._parse_codex_stream(sse)
|
||||
self.assertIn("output", result)
|
||||
|
||||
def test_delta_fallback(self):
|
||||
"""Should reconstruct text from delta events."""
|
||||
sse = (
|
||||
'data: {"delta":"hel"}\n\n'
|
||||
'data: {"delta":"lo"}\n\n'
|
||||
)
|
||||
result = openai_reddit._parse_codex_stream(sse)
|
||||
self.assertIn("output", result)
|
||||
text = result["output"][0]["content"][0]["text"]
|
||||
self.assertEqual(text, "hello")
|
||||
|
||||
def test_empty_stream(self):
|
||||
result = openai_reddit._parse_codex_stream("")
|
||||
self.assertEqual(result, {})
|
||||
|
||||
|
||||
class TestBuildPayload(unittest.TestCase):
|
||||
|
||||
def test_api_key_payload(self):
|
||||
payload = openai_reddit._build_payload(
|
||||
"gpt-4o", "instructions", "input text", "api_key"
|
||||
)
|
||||
self.assertEqual(payload["model"], "gpt-4o")
|
||||
self.assertEqual(payload["input"], "input text")
|
||||
self.assertNotIn("stream", payload)
|
||||
|
||||
def test_codex_payload_has_stream(self):
|
||||
payload = openai_reddit._build_payload(
|
||||
"gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX
|
||||
)
|
||||
self.assertTrue(payload["stream"])
|
||||
# Input should be structured message format for Codex
|
||||
self.assertIsInstance(payload["input"], list)
|
||||
self.assertEqual(payload["input"][0]["role"], "user")
|
||||
|
||||
def test_codex_payload_has_store_false(self):
|
||||
payload = openai_reddit._build_payload(
|
||||
"gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX
|
||||
)
|
||||
self.assertFalse(payload["store"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user