fix(codex): make host-credential bottles actually authenticate
Debugging a live codex smolmachines bottle surfaced three independent
failures past the sign-in screen; fix each so forward_host_credentials
works end to end:
- codex_auth: dummy access/id tokens now inherit the *real* host token's
exp instead of now+1h. Codex (0.135) refreshes when its local token's
JWT exp lapses; with a placeholder refresh_token that refresh fails and
drops to the sign-in screen. Aligning exp tracks the real token's life.
- prepare: set CODEX_CA_CERTIFICATE to the agent CA bundle for codex
bottles. Codex is rustls and ignores the system store / NODE_EXTRA_CA_
CERTS; it reads CODEX_CA_CERTIFICATE (fallback SSL_CERT_FILE) for custom
roots across HTTPS + wss, so it must be pointed at the egress MITM CA or
injection can't work without tls_passthrough.
- pipelock: auto tls_passthrough the Codex API hosts when
forward_host_credentials is on. Egress injects the bearer before
pipelock, whose header DLP then flags the JWT ("request header contains
secret") and the retry storm trips its 429. passthrough host-gates the
CONNECT but skips decrypt+rescan of egress-owned auth. The auto-added
routes aren't in bottle.egress.routes, so the hosts are added explicitly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -120,7 +120,7 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
||||
self.assertNotEqual(access, dummy["tokens"]["access_token"])
|
||||
self.assertNotEqual(refresh, dummy["tokens"]["refresh_token"])
|
||||
self.assertEqual("bot-bottle-placeholder", dummy["tokens"]["refresh_token"])
|
||||
self.assertEqual("bot-bottle-placeholder", dummy["tokens"]["account_id"])
|
||||
self.assertEqual("acct-host", dummy["tokens"]["account_id"])
|
||||
self.assertIsNotNone(
|
||||
codex_host_access_token(
|
||||
{"CODEX_HOME": str(self.home)},
|
||||
@@ -128,6 +128,31 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_dummy_auth_tokens_inherit_host_token_exp(self):
|
||||
# Codex refreshes when its local access token is at/past exp;
|
||||
# the dummy must carry the host token's real exp so Codex does
|
||||
# not drop to the sign-in screen after an artificial TTL while
|
||||
# egress still holds a valid bearer.
|
||||
host_exp = 2000000000
|
||||
self._write({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": _jwt(host_exp),
|
||||
"id_token": _jwt(host_exp),
|
||||
"refresh_token": "hidden",
|
||||
},
|
||||
})
|
||||
dummy = json.loads(codex_dummy_auth_json(
|
||||
{"CODEX_HOME": str(self.home)},
|
||||
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
self.assertEqual(
|
||||
host_exp, _jwt_payload(dummy["tokens"]["access_token"])["exp"],
|
||||
)
|
||||
self.assertEqual(
|
||||
host_exp, _jwt_payload(dummy["tokens"]["id_token"])["exp"],
|
||||
)
|
||||
|
||||
def test_dummy_auth_keeps_required_account_claim_shape(self):
|
||||
def jwt(payload: dict) -> str:
|
||||
def enc(obj: dict) -> str:
|
||||
@@ -172,7 +197,7 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
||||
auth = access_payload["https://api.openai.com/auth"]
|
||||
profile = access_payload["https://api.openai.com/profile"]
|
||||
self.assertEqual("plus", auth["chatgpt_plan_type"])
|
||||
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_account_id"])
|
||||
self.assertEqual("acct-real", auth["chatgpt_account_id"])
|
||||
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_user_id"])
|
||||
self.assertEqual("bot-bottle@example.invalid", profile["email"])
|
||||
self.assertTrue(profile["email_verified"])
|
||||
|
||||
@@ -113,6 +113,25 @@ class TestTlsPassthrough(unittest.TestCase):
|
||||
])))
|
||||
self.assertEqual(["api.openai.com"], passthrough)
|
||||
|
||||
def test_forward_host_credentials_passes_through_codex_hosts(self):
|
||||
# Egress injects the host bearer on the Codex API hosts; pipelock
|
||||
# must pass them through or its header DLP blocks the injected JWT
|
||||
# ("request header contains secret"). These routes are auto-added
|
||||
# (not in bottle.egress.routes), so passthrough is host-derived.
|
||||
passthrough = pipelock_effective_tls_passthrough(_bottle({
|
||||
"agent_provider": {
|
||||
"template": "codex",
|
||||
"forward_host_credentials": True,
|
||||
},
|
||||
}))
|
||||
self.assertEqual(["api.openai.com", "chatgpt.com"], passthrough)
|
||||
|
||||
def test_no_codex_passthrough_without_forward_host_credentials(self):
|
||||
passthrough = pipelock_effective_tls_passthrough(_bottle({
|
||||
"agent_provider": {"template": "codex"},
|
||||
}))
|
||||
self.assertEqual([], passthrough)
|
||||
|
||||
|
||||
class TestSsrfIpAllowlist(unittest.TestCase):
|
||||
def test_default_empty(self):
|
||||
|
||||
@@ -59,10 +59,9 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
||||
"smolvm", "machine", "exec", "--name",
|
||||
"bot-bottle-dev-abc",
|
||||
"-i", "-t",
|
||||
"-e", "HOME=/home/node",
|
||||
"-e", "USER=node",
|
||||
"--",
|
||||
"runuser", "-u", "node", "--",
|
||||
"env", "HOME=/home/node", "USER=node",
|
||||
"claude",
|
||||
],
|
||||
argv,
|
||||
@@ -107,7 +106,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
||||
HTTPS_PROXY="http://127.0.0.1:1234",
|
||||
NO_PROXY="localhost",
|
||||
).agent_argv([]))
|
||||
self.assertIn("-e", argv)
|
||||
self.assertIn("env", argv)
|
||||
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
|
||||
self.assertIn("NO_PROXY=localhost", argv)
|
||||
|
||||
@@ -119,8 +118,8 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
||||
argv = _bottle().agent_argv([])
|
||||
agent_idx = argv.index("claude")
|
||||
self.assertEqual(
|
||||
["runuser", "-u", "node", "--"],
|
||||
argv[agent_idx - 4:agent_idx],
|
||||
["runuser", "-u", "node", "--", "env"],
|
||||
argv[agent_idx - 7:agent_idx - 2],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ class TestProvisionProviderAuth(unittest.TestCase):
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec"
|
||||
) as ex:
|
||||
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "")
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
|
||||
"bot-bottle-demo-abc12",
|
||||
@@ -226,6 +227,29 @@ class TestProvisionProviderAuth(unittest.TestCase):
|
||||
argv_seen,
|
||||
)
|
||||
self.assertIn(["chmod", "600", "/home/node/.codex/auth.json"], argv_seen)
|
||||
self.assertIn(
|
||||
[
|
||||
"runuser", "-u", "node", "--",
|
||||
"env",
|
||||
"HOME=/home/node",
|
||||
"CODEX_HOME=/home/node/.codex",
|
||||
"codex", "login", "status",
|
||||
],
|
||||
argv_seen,
|
||||
)
|
||||
|
||||
def test_dies_when_codex_rejects_dummy_auth(self):
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_cp"
|
||||
), patch(
|
||||
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec"
|
||||
) as ex:
|
||||
ex.return_value = SmolvmRunResult(1, "Not logged in\n", "")
|
||||
with self.assertRaises(SystemExit):
|
||||
_provider_auth.provision_provider_auth(
|
||||
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
|
||||
"bot-bottle-demo-abc12",
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionSkills(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user