Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions codex/hooks/test_get_api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Tests for get_api_key in codex/hooks/unbound.py (WEB-4812).

The hook must fall back to ~/.unbound/config.json when UNBOUND_CODEX_API_KEY
is not in the environment — codex launched from VS Code, GUI launchers, or
terminals opened before setup ran never sees shell-profile env vars, and
without the fallback every exchange is silently dropped (send_to_api no-ops,
policy checks fail open, and error reporting itself needs the key).

Mirrors the WEB-4145 fix that gave Claude Code (then cursor and copilot)
the same two-tier lookup.
"""

import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import unbound


class TestGetApiKey(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.config_path = Path(self._tmp.name) / "config.json"
patcher = patch.object(unbound, "UNBOUND_CONFIG_PATH", self.config_path)
patcher.start()
self.addCleanup(patcher.stop)

def test_env_var_wins_over_config(self):
self.config_path.write_text(json.dumps({"api_key": "from-config"}))
with patch.dict(os.environ, {"UNBOUND_CODEX_API_KEY": "from-env"}):
self.assertEqual(unbound.get_api_key(), "from-env")

def test_falls_back_to_unbound_config(self):
self.config_path.write_text(json.dumps({"api_key": "from-config"}))
with patch.dict(os.environ):
os.environ.pop("UNBOUND_CODEX_API_KEY", None)
self.assertEqual(unbound.get_api_key(), "from-config")

def test_no_env_no_config_returns_none(self):
with patch.dict(os.environ):
os.environ.pop("UNBOUND_CODEX_API_KEY", None)
self.assertIsNone(unbound.get_api_key())

def test_invalid_config_json_returns_none(self):
self.config_path.write_text("{not json")
with patch.dict(os.environ), \
patch.object(unbound, "log_error") as mock_log:
os.environ.pop("UNBOUND_CODEX_API_KEY", None)
self.assertIsNone(unbound.get_api_key())
mock_log.assert_called_once()
self.assertEqual(mock_log.call_args[0][1], "config")

def test_config_without_api_key_returns_none(self):
self.config_path.write_text(json.dumps({"base_url": "https://x"}))
with patch.dict(os.environ):
os.environ.pop("UNBOUND_CODEX_API_KEY", None)
self.assertIsNone(unbound.get_api_key())


class TestStopEventUsesConfigKey(unittest.TestCase):
"""Regression for the WEB-4812 silent drop: with no env var, a Stop
exchange must still be sent using the ~/.unbound/config.json key."""

def test_stop_event_sends_with_config_key(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "config.json"
config_path.write_text(json.dumps({"api_key": "cfg-key"}))
prompt_log = [{
"timestamp": "2026-06-12T00:00:00Z",
"session_id": "sess-1",
"event": {
"hook_event_name": "UserPromptSubmit",
"session_id": "sess-1",
"prompt": "hi",
},
}]
stop_event = json.dumps({
"hook_event_name": "Stop",
"session_id": "sess-1",
"last_assistant_message": "answer",
})
sent = {}

def fake_send(exchange, api_key):
sent["exchange"] = exchange
sent["api_key"] = api_key
return True

with patch.dict(os.environ), \
patch.object(unbound, "UNBOUND_CONFIG_PATH", config_path), \
patch.object(unbound, "send_to_api", fake_send), \
patch.object(unbound, "load_existing_logs", return_value=prompt_log), \
patch.object(unbound, "append_to_audit_log"), \
patch.object(unbound, "cleanup_old_logs"), \
patch.object(unbound.sys, "stdin", io.StringIO(stop_event)):
os.environ.pop("UNBOUND_CODEX_API_KEY", None)
unbound.main()

self.assertEqual(sent.get("api_key"), "cfg-key")
self.assertEqual(sent["exchange"]["conversation_id"], "sess-1")
self.assertEqual(
sent["exchange"]["messages"][0]["content"], "hi")


if __name__ == "__main__":
unittest.main()
27 changes: 26 additions & 1 deletion codex/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,31 @@ def process_stop_event(event: Dict, api_key: str):
send_to_api(exchange, api_key)


def get_api_key():
"""Read API key from env, falling back to ~/.unbound/config.json.

Codex launched outside a login shell (VS Code extension host, GUI
launchers, terminals opened before setup ran) doesn't inherit
shell-profile env vars — same root cause WEB-4145 fixed for Claude
Code, ported here. setup.py already writes the key to
~/.unbound/config.json, so use it as a tier-2 lookup.
"""
key = os.getenv('UNBOUND_CODEX_API_KEY')
if key:
return key
try:
with open(UNBOUND_CONFIG_PATH, 'r', encoding='utf-8') as f:
return json.loads(f.read()).get('api_key')
except FileNotFoundError:
return None
except json.JSONDecodeError as e:
log_error(f"~/.unbound/config.json is not valid JSON: {e}", 'config')
return None
except Exception as e:
log_error(f"Failed to read config file: {e}", 'config')
return None
Comment on lines +1143 to +1146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 log_error fires before _cached_api_key is populated

log_error internally calls report_error_to_gateway(message, category, _cached_api_key). When get_api_key() is invoked at the top of main(), the module-level _cached_api_key is still None (it's only updated on the next line after get_api_key() returns). So if the config file contains malformed JSON, the error is correctly written to error.log, but gateway reporting silently no-ops because the key is None. In this particular failure mode the key was unreadable anyway so there is no additional loss, but the ordering means gateway-side visibility for startup config errors will remain limited until the key is resolved.



_GATEWAY_URL_RE = re.compile(r'^https?://[A-Za-z0-9._\-]+(:\d+)?(/[A-Za-z0-9._/\-]*)?$')
_BAKED_GATEWAY_RE = re.compile(r'os\.environ\.get\(\s*"UNBOUND_GATEWAY_URL"\s*,\s*"([^"]*)"')

Expand Down Expand Up @@ -1520,7 +1545,7 @@ def _dispatch_discovery() -> None:

def main():
global _cached_api_key
api_key = os.getenv('UNBOUND_CODEX_API_KEY')
api_key = get_api_key()
_cached_api_key = api_key

try:
Expand Down