feat(codex): inject host credentials via egress
This commit is contained in:
@@ -352,10 +352,14 @@ auth through egress and gitea.dideric.is over SSH.
|
|||||||
For a Codex-backed base bottle, set `agent_provider.template: codex`.
|
For a Codex-backed base bottle, set `agent_provider.template: codex`.
|
||||||
The Codex template expects ChatGPT/device login state instead of an
|
The Codex template expects ChatGPT/device login state instead of an
|
||||||
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
|
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
|
||||||
agent. To let headless device-code login request a user code, add an
|
agent. To let bot-bottle read the host's current Codex ChatGPT access
|
||||||
unauthenticated egress route for the device-auth endpoint:
|
token and inject it from egress only, opt in explicitly:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
agent_provider:
|
||||||
|
template: codex
|
||||||
|
forward_host_credentials: true
|
||||||
|
|
||||||
egress:
|
egress:
|
||||||
routes:
|
routes:
|
||||||
- host: auth.openai.com
|
- host: auth.openai.com
|
||||||
@@ -363,6 +367,15 @@ egress:
|
|||||||
- /api/accounts/deviceauth/
|
- /api/accounts/deviceauth/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run `codex login --device-auth` on the host before launch. The
|
||||||
|
launcher reads only `tokens.access_token` from the host's
|
||||||
|
`~/.codex/auth.json`, verifies it is fresh ChatGPT auth, and passes it
|
||||||
|
to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container does
|
||||||
|
not receive `auth.json`, refresh tokens, access-token env vars, or
|
||||||
|
`OPENAI_API_KEY`. The effective egress table automatically adds or
|
||||||
|
upgrades `chatgpt.com` to an authenticated route when
|
||||||
|
`forward_host_credentials` is true.
|
||||||
|
|
||||||
The built-in Codex template uses `Dockerfile.codex`; set
|
The built-in Codex template uses `Dockerfile.codex`; set
|
||||||
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
|
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
|
||||||
while keeping the bot-bottle sidecars in place.
|
while keeping the bot-bottle sidecars in place.
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...egress import egress_resolve_token_values
|
from ...codex_auth import codex_host_access_token
|
||||||
|
from ...egress import (
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
egress_resolve_token_values,
|
||||||
|
)
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -181,6 +185,13 @@ def launch(
|
|||||||
token_values = egress_resolve_token_values(
|
token_values = egress_resolve_token_values(
|
||||||
plan.egress_plan.token_env_map, dict(os.environ),
|
plan.egress_plan.token_env_map, dict(os.environ),
|
||||||
)
|
)
|
||||||
|
if plan.spec.manifest.bottle_for(
|
||||||
|
plan.spec.agent_name,
|
||||||
|
).agent_provider.forward_host_credentials:
|
||||||
|
access_token = codex_host_access_token(dict(os.environ))
|
||||||
|
for token_env, token_ref in plan.egress_plan.token_env_map.items():
|
||||||
|
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
|
||||||
|
token_values[token_env] = access_token
|
||||||
compose_env: dict[str, str] = {
|
compose_env: dict[str, str] = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
**plan.forwarded_env,
|
**plan.forwarded_env,
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
from ...codex_auth import codex_host_access_token
|
||||||
|
from ...egress import (
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
EGRESS_ROUTES_IN_CONTAINER,
|
||||||
|
egress_resolve_token_values,
|
||||||
|
)
|
||||||
from ...pipelock import (
|
from ...pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
@@ -423,7 +428,16 @@ def _resolve_token_env(
|
|||||||
ep = plan.egress_plan
|
ep = plan.egress_plan
|
||||||
if not ep.routes:
|
if not ep.routes:
|
||||||
return {}
|
return {}
|
||||||
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
|
env = dict(host_env)
|
||||||
|
token_values = egress_resolve_token_values(ep.token_env_map, env)
|
||||||
|
if plan.spec.manifest.bottle_for(
|
||||||
|
plan.spec.agent_name,
|
||||||
|
).agent_provider.forward_host_credentials:
|
||||||
|
access_token = codex_host_access_token(env)
|
||||||
|
for token_env, token_ref in ep.token_env_map.items():
|
||||||
|
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
|
||||||
|
token_values[token_env] = access_token
|
||||||
|
return token_values
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Host Codex auth helpers.
|
||||||
|
|
||||||
|
Reads the host's Codex ChatGPT/device-login auth state and returns only
|
||||||
|
the short-lived access token needed by egress. This module deliberately
|
||||||
|
does not expose refresh tokens or raw auth payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .log import die
|
||||||
|
from .util import expand_tilde
|
||||||
|
|
||||||
|
|
||||||
|
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
||||||
|
env = os.environ if host_env is None else host_env
|
||||||
|
home = env.get("CODEX_HOME")
|
||||||
|
if home:
|
||||||
|
return Path(expand_tilde(home)) / "auth.json"
|
||||||
|
return Path.home() / ".codex" / "auth.json"
|
||||||
|
|
||||||
|
|
||||||
|
def codex_host_access_token(
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
path = codex_auth_path(host_env)
|
||||||
|
if not path.is_file():
|
||||||
|
die(
|
||||||
|
f"codex host credentials: auth file missing at {path}. "
|
||||||
|
"Run `codex login --device-auth` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
die(f"codex host credentials: {path} must contain a JSON object")
|
||||||
|
|
||||||
|
if raw.get("auth_mode") != "chatgpt":
|
||||||
|
die(
|
||||||
|
"codex host credentials: host Codex auth is not ChatGPT/device "
|
||||||
|
"auth. Run `codex login --device-auth` on the host."
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = raw.get("tokens")
|
||||||
|
if not isinstance(tokens, dict):
|
||||||
|
die(f"codex host credentials: {path} is missing tokens")
|
||||||
|
access = tokens.get("access_token")
|
||||||
|
if not isinstance(access, str) or not access:
|
||||||
|
die(
|
||||||
|
f"codex host credentials: {path} is missing tokens.access_token. "
|
||||||
|
"Run `codex login --device-auth` on the host."
|
||||||
|
)
|
||||||
|
|
||||||
|
exp = _jwt_exp(access)
|
||||||
|
if exp is None:
|
||||||
|
die("codex host credentials: tokens.access_token is not a JWT with exp")
|
||||||
|
check_now = now or datetime.now(timezone.utc)
|
||||||
|
if exp <= check_now:
|
||||||
|
die(
|
||||||
|
"codex host credentials: host Codex access token is expired. "
|
||||||
|
"Run `codex login --device-auth` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
return access
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_exp(token: str) -> datetime | None:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(_b64url_decode(parts[1]))
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if not isinstance(exp, (int, float)):
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(exp, timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(value: str) -> str:
|
||||||
|
padded = value + ("=" * (-len(value) % 4))
|
||||||
|
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["codex_auth_path", "codex_host_access_token"]
|
||||||
+48
-4
@@ -31,6 +31,9 @@ from pathlib import Path
|
|||||||
from .log import die
|
from .log import die
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
|
CODEX_CHATGPT_HOST = "chatgpt.com"
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
|
|
||||||
|
|
||||||
# DNS name agents will dial for the per-bottle egress sidecar.
|
# DNS name agents will dial for the per-bottle egress sidecar.
|
||||||
# Backend-agnostic by contract: every concrete backend (Docker today,
|
# Backend-agnostic by contract: every concrete backend (Docker today,
|
||||||
@@ -174,11 +177,50 @@ def egress_routes_for_bottle(
|
|||||||
"""Effective egress routes. This is what gets rendered into
|
"""Effective egress routes. This is what gets rendered into
|
||||||
routes.yaml + what the addon enforces.
|
routes.yaml + what the addon enforces.
|
||||||
|
|
||||||
Operators that want to allow a host declare it directly in
|
Operators that want to allow a host usually declare it directly in
|
||||||
`bottle.egress.routes` as an authenticated route or bare-pass entry
|
`bottle.egress.routes` as an authenticated route or bare-pass entry
|
||||||
(`- host: <name>`). The legacy `bottle.egress.allowlist`
|
(`- host: <name>`). Codex host-credential forwarding is the
|
||||||
folding is gone — egress is the single allowlist surface."""
|
provider-owned exception: when explicitly enabled, it adds or
|
||||||
return egress_manifest_routes(bottle)
|
upgrades `chatgpt.com` to an egress-owned authenticated route. The
|
||||||
|
legacy `bottle.egress.allowlist` folding is gone — egress is the
|
||||||
|
single allowlist surface."""
|
||||||
|
routes = list(egress_manifest_routes(bottle))
|
||||||
|
if not bottle.agent_provider.forward_host_credentials:
|
||||||
|
return tuple(routes)
|
||||||
|
|
||||||
|
if bottle.agent_provider.template != "codex":
|
||||||
|
return tuple(routes)
|
||||||
|
|
||||||
|
for idx, route in enumerate(routes):
|
||||||
|
if route.host.lower() != CODEX_CHATGPT_HOST:
|
||||||
|
continue
|
||||||
|
if route.auth_scheme or route.token_ref:
|
||||||
|
die(
|
||||||
|
"codex host credential forwarding conflicts with an "
|
||||||
|
"authenticated egress route for chatgpt.com. Remove that "
|
||||||
|
"route auth block or disable agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
routes[idx] = EgressRoute(
|
||||||
|
host=route.host,
|
||||||
|
path_allowlist=route.path_allowlist,
|
||||||
|
auth_scheme="Bearer",
|
||||||
|
token_env=_next_token_env(routes),
|
||||||
|
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
roles=route.roles,
|
||||||
|
)
|
||||||
|
return tuple(routes)
|
||||||
|
|
||||||
|
routes.append(EgressRoute(
|
||||||
|
host=CODEX_CHATGPT_HOST,
|
||||||
|
auth_scheme="Bearer",
|
||||||
|
token_env=_next_token_env(routes),
|
||||||
|
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
))
|
||||||
|
return tuple(routes)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_token_env(routes: list[EgressRoute]) -> str:
|
||||||
|
return f"EGRESS_TOKEN_{len({r.token_env for r in routes if r.token_env})}"
|
||||||
|
|
||||||
|
|
||||||
def egress_token_env_map(
|
def egress_token_env_map(
|
||||||
@@ -251,6 +293,8 @@ def egress_resolve_token_values(
|
|||||||
a sealed mapping without touching `os.environ`."""
|
a sealed mapping without touching `os.environ`."""
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
for token_env, token_ref in token_env_map.items():
|
for token_env, token_ref in token_env_map.items():
|
||||||
|
if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
|
||||||
|
continue
|
||||||
value = host_env.get(token_ref)
|
value = host_env.get(token_ref)
|
||||||
if value is None:
|
if value is None:
|
||||||
die(
|
die(
|
||||||
|
|||||||
+19
-3
@@ -228,15 +228,16 @@ class AgentProvider:
|
|||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
dockerfile: str = ""
|
dockerfile: str = ""
|
||||||
|
forward_host_credentials: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
||||||
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
|
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"template", "dockerfile"}:
|
if k not in {"template", "dockerfile", "forward_host_credentials"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
||||||
f"allowed: template, dockerfile"
|
f"allowed: template, dockerfile, forward_host_credentials"
|
||||||
)
|
)
|
||||||
template = d.get("template", "claude")
|
template = d.get("template", "claude")
|
||||||
if not isinstance(template, str) or not template:
|
if not isinstance(template, str) or not template:
|
||||||
@@ -255,7 +256,22 @@ class AgentProvider:
|
|||||||
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
|
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
|
||||||
f"string (was {type(dockerfile).__name__})"
|
f"string (was {type(dockerfile).__name__})"
|
||||||
)
|
)
|
||||||
return cls(template=template, dockerfile=dockerfile)
|
forward_host_credentials = d.get("forward_host_credentials", False)
|
||||||
|
if not isinstance(forward_host_credentials, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
f"must be a boolean (was {type(forward_host_credentials).__name__})"
|
||||||
|
)
|
||||||
|
if forward_host_credentials and template != "codex":
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
"is currently only supported for template 'codex'"
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
template=template,
|
||||||
|
dockerfile=dockerfile,
|
||||||
|
forward_host_credentials=forward_host_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Unit: host Codex auth extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.codex_auth import codex_auth_path, codex_host_access_token
|
||||||
|
from bot_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt(exp: int) -> str:
|
||||||
|
def enc(obj: dict) -> str:
|
||||||
|
raw = json.dumps(obj, separators=(",", ":")).encode()
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
return f"{enc({'alg': 'none'})}.{enc({'exp': exp})}.sig"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodexHostAccessToken(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="cb-codex-auth.")
|
||||||
|
self.home = Path(self.tmp.name)
|
||||||
|
self.auth_path = self.home / "auth.json"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _write(self, payload: dict) -> None:
|
||||||
|
self.auth_path.write_text(json.dumps(payload))
|
||||||
|
|
||||||
|
def test_auth_path_uses_codex_home(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth_path,
|
||||||
|
codex_auth_path({"CODEX_HOME": str(self.home)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_fresh_chatgpt_access_token(self):
|
||||||
|
token = _jwt(2000000000)
|
||||||
|
self._write({
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"tokens": {"access_token": token, "refresh_token": "hidden"},
|
||||||
|
})
|
||||||
|
out = codex_host_access_token(
|
||||||
|
{"CODEX_HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(token, out)
|
||||||
|
|
||||||
|
def test_missing_auth_file_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
codex_host_access_token({"CODEX_HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_non_chatgpt_auth_dies(self):
|
||||||
|
self._write({"auth_mode": "api_key", "tokens": {"access_token": _jwt(2)}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
codex_host_access_token({"CODEX_HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_expired_token_dies(self):
|
||||||
|
self._write({
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"tokens": {"access_token": _jwt(1000)},
|
||||||
|
})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
codex_host_access_token(
|
||||||
|
{"CODEX_HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_jwt_token_dies(self):
|
||||||
|
self._write({
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"tokens": {"access_token": "not-a-jwt"},
|
||||||
|
})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
codex_host_access_token({"CODEX_HOME": str(self.home)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,6 +4,7 @@ resolution (PRD 0017)."""
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.egress import (
|
from bot_bottle.egress import (
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
egress_manifest_routes,
|
egress_manifest_routes,
|
||||||
egress_render_routes,
|
egress_render_routes,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
@@ -22,6 +23,21 @@ def _bottle(routes):
|
|||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_bottle(*, forward_host_credentials: bool, routes):
|
||||||
|
return Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"agent_provider": {
|
||||||
|
"template": "codex",
|
||||||
|
"forward_host_credentials": forward_host_credentials,
|
||||||
|
},
|
||||||
|
"egress": {"routes": routes},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
class TestRoutesForBottle(unittest.TestCase):
|
class TestRoutesForBottle(unittest.TestCase):
|
||||||
def test_authenticated_route_gets_slot(self):
|
def test_authenticated_route_gets_slot(self):
|
||||||
b = _bottle([{
|
b = _bottle([{
|
||||||
@@ -107,6 +123,38 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase):
|
|||||||
effective = [r.host for r in egress_routes_for_bottle(b)]
|
effective = [r.host for r in egress_routes_for_bottle(b)]
|
||||||
self.assertEqual(["x.example"], effective)
|
self.assertEqual(["x.example"], effective)
|
||||||
|
|
||||||
|
def test_codex_forward_host_credentials_adds_chatgpt_route(self):
|
||||||
|
b = _codex_bottle(forward_host_credentials=True, routes=[])
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
self.assertEqual(["chatgpt.com"], [r.host for r in routes])
|
||||||
|
self.assertEqual("Bearer", routes[0].auth_scheme)
|
||||||
|
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||||
|
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
|
||||||
|
|
||||||
|
def test_codex_forward_host_credentials_upgrades_bare_chatgpt_route(self):
|
||||||
|
b = _codex_bottle(
|
||||||
|
forward_host_credentials=True,
|
||||||
|
routes=[{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}],
|
||||||
|
)
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
self.assertEqual(1, len(routes))
|
||||||
|
self.assertEqual("chatgpt.com", routes[0].host)
|
||||||
|
self.assertEqual("Bearer", routes[0].auth_scheme)
|
||||||
|
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||||
|
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
|
||||||
|
self.assertEqual(("/backend-api/",), routes[0].path_allowlist)
|
||||||
|
|
||||||
|
def test_codex_forward_host_credentials_conflicts_with_authed_route(self):
|
||||||
|
b = _codex_bottle(
|
||||||
|
forward_host_credentials=True,
|
||||||
|
routes=[{
|
||||||
|
"host": "chatgpt.com",
|
||||||
|
"auth": {"scheme": "Bearer", "token_ref": "OTHER"},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
egress_routes_for_bottle(b)
|
||||||
|
|
||||||
|
|
||||||
class TestTokenEnvMap(unittest.TestCase):
|
class TestTokenEnvMap(unittest.TestCase):
|
||||||
def test_only_authenticated_routes_contribute(self):
|
def test_only_authenticated_routes_contribute(self):
|
||||||
@@ -217,6 +265,13 @@ class TestResolveTokenValues(unittest.TestCase):
|
|||||||
{"GH_PAT": ""},
|
{"GH_PAT": ""},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_codex_host_credential_ref_is_resolved_by_launch(self):
|
||||||
|
out = egress_resolve_token_values(
|
||||||
|
{"EGRESS_TOKEN_0": CODEX_HOST_CREDENTIAL_TOKEN_REF},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
self.assertEqual({}, out)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ def _provider_bottle(provider, routes):
|
|||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_config_bottle(agent_provider):
|
||||||
|
return Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {"agent_provider": agent_provider}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
class TestMinimalRoute(unittest.TestCase):
|
class TestMinimalRoute(unittest.TestCase):
|
||||||
def test_host_only(self):
|
def test_host_only(self):
|
||||||
b = _bottle([{"host": "api.example.com"}])
|
b = _bottle([{"host": "api.example.com"}])
|
||||||
@@ -52,6 +59,33 @@ class TestMinimalRoute(unittest.TestCase):
|
|||||||
_bottle([{"host": "x.example", "wat": "yes"}])
|
_bottle([{"host": "x.example", "wat": "yes"}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentProviderHostCredentials(unittest.TestCase):
|
||||||
|
def test_forward_host_credentials_defaults_false(self):
|
||||||
|
b = _provider_config_bottle({"template": "codex"})
|
||||||
|
self.assertFalse(b.agent_provider.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_host_credentials_allowed_for_codex(self):
|
||||||
|
b = _provider_config_bottle({
|
||||||
|
"template": "codex",
|
||||||
|
"forward_host_credentials": True,
|
||||||
|
})
|
||||||
|
self.assertTrue(b.agent_provider.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_host_credentials_must_be_boolean(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
_provider_config_bottle({
|
||||||
|
"template": "codex",
|
||||||
|
"forward_host_credentials": "yes",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_forward_host_credentials_rejected_for_claude(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
_provider_config_bottle({
|
||||||
|
"template": "claude",
|
||||||
|
"forward_host_credentials": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class TestPathAllowlist(unittest.TestCase):
|
class TestPathAllowlist(unittest.TestCase):
|
||||||
def test_optional(self):
|
def test_optional(self):
|
||||||
b = _bottle([{"host": "x.example"}])
|
b = _bottle([{"host": "x.example"}])
|
||||||
|
|||||||
Reference in New Issue
Block a user