PRD 0029: provision egress routes via AgentProvisionPlan #115
@@ -13,11 +13,17 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from .codex_auth import write_codex_dummy_auth_file
|
||||
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
|
||||
|
||||
PROVIDER_CLAUDE = "claude"
|
||||
PROVIDER_CODEX = "codex"
|
||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
||||
|
||||
# Hosts that egress injects the host ChatGPT bearer on when Codex
|
||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||
PromptMode = Literal["append_file", "read_prompt_file"]
|
||||
|
||||
|
||||
@@ -27,8 +33,6 @@ class AgentProviderRuntime:
|
||||
command: str
|
||||
image: str
|
||||
dockerfile: str
|
||||
auth_role: str
|
||||
placeholder_env: str
|
||||
prompt_mode: PromptMode
|
||||
bypass_args: tuple[str, ...]
|
||||
resume_args: tuple[str, ...]
|
||||
@@ -63,6 +67,16 @@ class AgentProvisionPlan:
|
||||
Backends interpret this plan with their own copy/exec primitives.
|
||||
Provider-specific content stays here so future provider plugins can
|
||||
return the same shape without adding backend-plan fields.
|
||||
|
||||
`egress_routes` are provider-declared EgressRoutes that backends
|
||||
pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps
|
||||
provider logic out of the egress and pipelock modules — they merge
|
||||
provider routes generically without knowing the provider type.
|
||||
|
||||
`hidden_env_names` is the set of env var names the provider injected
|
||||
as non-secret placeholders. `print_util.visible_agent_env_names` uses
|
||||
this to suppress them from the preflight summary so operators don't
|
||||
mistake them for real credentials.
|
||||
"""
|
||||
|
||||
template: str
|
||||
@@ -76,6 +90,8 @@ class AgentProvisionPlan:
|
||||
files: tuple[AgentProvisionFile, ...] = ()
|
||||
pre_copy: tuple[AgentProvisionCommand, ...] = ()
|
||||
verify: tuple[AgentProvisionCommand, ...] = ()
|
||||
egress_routes: tuple[EgressRoute, ...] = ()
|
||||
hidden_env_names: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -87,8 +103,6 @@ _RUNTIMES = {
|
||||
command="claude",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||
auth_role="claude_code_oauth",
|
||||
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
@@ -99,8 +113,6 @@ _RUNTIMES = {
|
||||
command="codex",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||
auth_role="",
|
||||
placeholder_env="",
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
@@ -120,8 +132,8 @@ def agent_provision_plan(
|
||||
state_dir: Path,
|
||||
guest_home: str = "/home/node",
|
||||
guest_env: dict[str, str] | None = None,
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
has_provider_auth: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
runtime = runtime_for(template)
|
||||
@@ -131,6 +143,8 @@ def agent_provision_plan(
|
||||
files: list[AgentProvisionFile] = []
|
||||
pre_copy: list[AgentProvisionCommand] = []
|
||||
verify: list[AgentProvisionCommand] = []
|
||||
egress_routes: list[EgressRoute] = []
|
||||
hidden_env_names: frozenset[str] = frozenset()
|
||||
|
||||
if template == PROVIDER_CODEX:
|
||||
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt"
|
||||
@@ -147,6 +161,13 @@ def agent_provision_plan(
|
||||
config_file.chmod(0o600)
|
||||
files.append(AgentProvisionFile(config_file, config_path))
|
||||
|
||||
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||
egress_routes.append(EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer" if forward_host_credentials else "",
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
|
||||
tls_passthrough=True,
|
||||
))
|
||||
if forward_host_credentials:
|
||||
auth_file = state_dir / "codex-auth.json"
|
||||
write_codex_dummy_auth_file(auth_file, host_env or dict(os.environ))
|
||||
@@ -172,9 +193,18 @@ def agent_provision_plan(
|
||||
"codex host credentials: dummy auth was copied into the "
|
||||
"guest, but Codex did not accept it"
|
||||
)))
|
||||
if template == PROVIDER_CLAUDE and has_provider_auth:
|
||||
if template == PROVIDER_CLAUDE:
|
||||
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
||||
env_vars["DISABLE_ERROR_REPORTING"] = "1"
|
||||
egress_routes.append(EgressRoute(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
token_ref=auth_token,
|
||||
tls_passthrough=True,
|
||||
))
|
||||
if auth_token:
|
||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||
|
||||
return AgentProvisionPlan(
|
||||
template=template,
|
||||
@@ -188,6 +218,8 @@ def agent_provision_plan(
|
||||
files=tuple(files),
|
||||
pre_copy=tuple(pre_copy),
|
||||
verify=tuple(verify),
|
||||
egress_routes=tuple(egress_routes),
|
||||
hidden_env_names=hidden_env_names,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class DockerBottlePlan(BottlePlan):
|
||||
| set(self.forwarded_env.keys())
|
||||
| set(self.agent_provision.guest_env.keys())
|
||||
),
|
||||
agent_provider_template=self.agent_provider_template,
|
||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||
)
|
||||
|
||||
print(file=sys.stderr)
|
||||
|
||||
@@ -159,17 +159,44 @@ def resolve_plan(
|
||||
prompt_file.write_text("")
|
||||
prompt_file.chmod(0o600)
|
||||
|
||||
pipelock_dir = pipelock_state_dir(slug)
|
||||
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||
proxy_plan = proxy.prepare(bottle, slug, pipelock_dir)
|
||||
|
||||
git_gate_dir = git_gate_state_dir(slug)
|
||||
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
||||
git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir)
|
||||
|
||||
resolved = resolve_env(manifest, spec.agent_name)
|
||||
# Everything that should reach the bottle by-name (so its value
|
||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||
# mutates the host os.environ.
|
||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||
_write_env_file(resolved, env_file)
|
||||
prompt_file.write_text(agent.prompt)
|
||||
|
||||
use_runsc = docker_mod.runsc_available()
|
||||
agent_provision = agent_provision_plan(
|
||||
template=provider.template,
|
||||
dockerfile=dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"),
|
||||
forward_host_credentials=provider.forward_host_credentials,
|
||||
auth_token=provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
)
|
||||
guest_env = dict(agent_provision.guest_env)
|
||||
for key, val in agent_provision.env_vars.items():
|
||||
guest_env.setdefault(key, val)
|
||||
agent_provision = replace(agent_provision, guest_env=guest_env)
|
||||
|
||||
pipelock_dir = pipelock_state_dir(slug)
|
||||
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||
proxy_plan = proxy.prepare(
|
||||
bottle, slug, pipelock_dir, agent_provision.egress_routes,
|
||||
)
|
||||
|
||||
egress_dir = egress_state_dir(slug)
|
||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||
egress_plan = egress.prepare(bottle, slug, egress_dir)
|
||||
egress_plan = egress.prepare(
|
||||
bottle, slug, egress_dir, agent_provision.egress_routes,
|
||||
)
|
||||
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
@@ -197,41 +224,6 @@ def resolve_plan(
|
||||
slug, supervise_dir,
|
||||
dockerfile_content=dockerfile_content,
|
||||
)
|
||||
resolved = resolve_env(manifest, spec.agent_name)
|
||||
# Everything that should reach the bottle by-name (so its value
|
||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||
# mutates the host os.environ.
|
||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||
# Some provider CLIs refuse to start without *some* credential
|
||||
# env var even when egress will strip + re-inject the real
|
||||
# Authorization header. For those providers, auth_role names the
|
||||
# route marker that enables a non-secret placeholder env. Codex is
|
||||
# intentionally absent here: it should use its device/ChatGPT login
|
||||
# state, and an OPENAI_API_KEY placeholder would force API-key auth.
|
||||
has_provider_auth = any(
|
||||
provider_runtime.auth_role
|
||||
and provider_runtime.auth_role in r.roles
|
||||
for r in egress_plan.routes
|
||||
)
|
||||
if has_provider_auth and provider_runtime.placeholder_env:
|
||||
forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||
_write_env_file(resolved, env_file)
|
||||
prompt_file.write_text(agent.prompt)
|
||||
|
||||
use_runsc = docker_mod.runsc_available()
|
||||
agent_provision = agent_provision_plan(
|
||||
template=provider.template,
|
||||
dockerfile=dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"),
|
||||
forward_host_credentials=provider.forward_host_credentials,
|
||||
has_provider_auth=has_provider_auth,
|
||||
host_env=dict(os.environ),
|
||||
)
|
||||
guest_env = dict(agent_provision.guest_env)
|
||||
for key, val in agent_provision.env_vars.items():
|
||||
guest_env.setdefault(key, val)
|
||||
agent_provision = replace(agent_provision, guest_env=guest_env)
|
||||
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from ..agent_provider import runtime_for
|
||||
from ..log import info
|
||||
|
||||
|
||||
@@ -30,16 +29,13 @@ def print_multi(label: str, values: Sequence[str]) -> None:
|
||||
|
||||
|
||||
def visible_agent_env_names(
|
||||
env_names: Sequence[str], *, agent_provider_template: str,
|
||||
env_names: Sequence[str], *, hidden_env_names: frozenset[str],
|
||||
) -> list[str]:
|
||||
"""Env names worth showing in launch summaries.
|
||||
|
||||
Provider auth placeholders (currently `CLAUDE_CODE_OAUTH_TOKEN`)
|
||||
are implementation details: they are non-secret dummy values that
|
||||
satisfy the provider CLI while egress injects the real upstream
|
||||
Authorization header. Showing them in preflight makes the operator
|
||||
think a real key is entering the agent, so hide only the active
|
||||
provider-owned placeholder.
|
||||
Provider-injected placeholder env vars are implementation details:
|
||||
they are non-secret dummy values that satisfy provider CLIs while
|
||||
egress injects the real Authorization header. The plan's
|
||||
`hidden_env_names` carries exactly which names to suppress.
|
||||
"""
|
||||
hidden = {runtime_for(agent_provider_template).placeholder_env}
|
||||
return sorted({name for name in env_names if name and name not in hidden})
|
||||
return sorted({name for name in env_names if name and name not in hidden_env_names})
|
||||
|
||||
@@ -125,7 +125,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
set(bottle.env.keys())
|
||||
| set(self.agent_provision.guest_env.keys())
|
||||
),
|
||||
agent_provider_template=self.agent_provider_template,
|
||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||
)
|
||||
upstreams = [
|
||||
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
||||
|
||||
@@ -95,44 +95,10 @@ def resolve_plan(
|
||||
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
||||
}
|
||||
|
||||
# Inner Plans for the four bundle daemons. The ABCs are
|
||||
# platform-neutral — `.prepare()` writes config files + returns
|
||||
# a Plan dataclass with no backend-specific assumptions. State
|
||||
# dirs are still keyed by slug under the docker backend's
|
||||
# bottle_state layout (shared on-host convention; not a docker
|
||||
# dependency).
|
||||
pipelock_dir = pipelock_state_dir(slug)
|
||||
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||
proxy_plan = PipelockProxy().prepare(bottle, slug, pipelock_dir)
|
||||
|
||||
git_gate_dir = git_gate_state_dir(slug)
|
||||
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
||||
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
|
||||
|
||||
egress_dir = egress_state_dir(slug)
|
||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||
egress_plan = Egress().prepare(bottle, slug, egress_dir)
|
||||
|
||||
# Some provider CLIs refuse to start without *some* credential
|
||||
# env var even when egress will strip + re-inject the real
|
||||
# Authorization header. For those providers, auth_role names the
|
||||
# route marker that enables a non-secret placeholder env. Codex is
|
||||
# intentionally absent here: it should use its device/ChatGPT login
|
||||
# state, and an OPENAI_API_KEY placeholder would force API-key auth.
|
||||
has_provider_auth = any(
|
||||
provider_runtime.auth_role
|
||||
and provider_runtime.auth_role in r.roles
|
||||
for r in egress_plan.routes
|
||||
)
|
||||
if has_provider_auth and provider_runtime.placeholder_env:
|
||||
guest_env[provider_runtime.placeholder_env] = "egress-placeholder"
|
||||
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
supervise_plan = Supervise().prepare(slug, supervise_dir)
|
||||
|
||||
# Prompt file is always written (mode 0o600) so the in-VM
|
||||
# path always exists. Content is the agent's `prompt`
|
||||
# field (markdown body) — empty for agents with no prompt.
|
||||
@@ -167,7 +133,7 @@ def resolve_plan(
|
||||
guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"),
|
||||
guest_env=guest_env,
|
||||
forward_host_credentials=provider.forward_host_credentials,
|
||||
has_provider_auth=has_provider_auth,
|
||||
auth_token=provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
)
|
||||
merged_guest_env = dict(agent_provision.guest_env)
|
||||
@@ -175,6 +141,30 @@ def resolve_plan(
|
||||
merged_guest_env.setdefault(key, val)
|
||||
agent_provision = replace(agent_provision, guest_env=merged_guest_env)
|
||||
|
||||
# Inner Plans for the four bundle daemons. The ABCs are
|
||||
# platform-neutral — `.prepare()` writes config files + returns
|
||||
# a Plan dataclass with no backend-specific assumptions. State
|
||||
# dirs are still keyed by slug under the docker backend's
|
||||
# bottle_state layout (shared on-host convention; not a docker
|
||||
# dependency).
|
||||
pipelock_dir = pipelock_state_dir(slug)
|
||||
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||
proxy_plan = PipelockProxy().prepare(
|
||||
bottle, slug, pipelock_dir, agent_provision.egress_routes,
|
||||
)
|
||||
|
||||
egress_dir = egress_state_dir(slug)
|
||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||
egress_plan = Egress().prepare(
|
||||
bottle, slug, egress_dir, agent_provision.egress_routes,
|
||||
)
|
||||
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
supervise_dir = supervise_state_dir(slug)
|
||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||
supervise_plan = Supervise().prepare(slug, supervise_dir)
|
||||
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage_dir,
|
||||
|
||||
+81
-57
@@ -27,11 +27,13 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .log import die
|
||||
from .manifest import Bottle
|
||||
|
||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import Bottle
|
||||
|
||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||
|
||||
|
||||
@@ -67,9 +69,14 @@ class EgressRoute:
|
||||
under `token_env`. Routes that share a `token_ref` coalesce to
|
||||
one `token_env` slot.
|
||||
|
||||
`roles` carries the manifest route's optional role markers (see
|
||||
`manifest.EGRESS_ROLES`). The launch step reads these for
|
||||
side effects like the claude-code OAuth placeholder env."""
|
||||
`roles` carries the manifest route's role tuple (reserved for
|
||||
future use; always empty today).
|
||||
|
||||
`tls_passthrough` signals that pipelock must not TLS-MITM this
|
||||
host — either because the manifest declared `pipelock.tls_passthrough:
|
||||
true` (lifted in `egress_manifest_routes`) or because a provider
|
||||
route set it (e.g. egress injects its own Bearer on that host
|
||||
after the agent boundary and pipelock's header DLP would block it)."""
|
||||
|
||||
host: str
|
||||
path_allowlist: tuple[str, ...] = ()
|
||||
@@ -77,6 +84,7 @@ class EgressRoute:
|
||||
token_env: str = ""
|
||||
token_ref: str = ""
|
||||
roles: tuple[str, ...] = ()
|
||||
tls_passthrough: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -161,84 +169,94 @@ def egress_manifest_routes(
|
||||
token_env=token_env,
|
||||
token_ref=r.TokenRef,
|
||||
roles=r.Role,
|
||||
tls_passthrough=r.Pipelock.TlsPassthrough,
|
||||
))
|
||||
else:
|
||||
out.append(EgressRoute(
|
||||
host=r.Host,
|
||||
path_allowlist=r.PathAllowlist,
|
||||
roles=r.Role,
|
||||
tls_passthrough=r.Pipelock.TlsPassthrough,
|
||||
))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def egress_routes_for_bottle(
|
||||
bottle: Bottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
"""Effective egress routes. This is what gets rendered into
|
||||
routes.yaml + what the addon enforces.
|
||||
"""Effective egress routes for the agent. This is what gets rendered
|
||||
into routes.yaml and what the addon enforces.
|
||||
|
||||
Operators that want to allow a host usually declare it directly in
|
||||
`bottle.egress.routes` as an authenticated route or bare-pass entry
|
||||
(`- host: <name>`). Codex host-credential forwarding is the
|
||||
provider-owned exception: when explicitly enabled, it adds or
|
||||
upgrades the Codex API hosts to egress-owned authenticated routes. The
|
||||
legacy `bottle.egress.allowlist` folding is gone — egress is the
|
||||
single allowlist surface."""
|
||||
Merges manifest-declared routes with provider-owned routes. The
|
||||
manifest is the primary surface; `provider_routes` are synthesised
|
||||
by `agent_provision_plan` and may add or upgrade manifest entries.
|
||||
Provider routes that conflict with an existing authenticated manifest
|
||||
route (different auth scheme or token ref) raise a hard error."""
|
||||
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 host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||
routes = _ensure_codex_host_credential_route(routes, host)
|
||||
for pr in provider_routes:
|
||||
routes = _merge_provider_route(routes, pr)
|
||||
return tuple(routes)
|
||||
|
||||
|
||||
def _next_token_env(routes: list[EgressRoute]) -> str:
|
||||
def _find_or_alloc_token_env(routes: list[EgressRoute], token_ref: str) -> str:
|
||||
"""Return the existing token_env slot for `token_ref`, or allocate the next one."""
|
||||
for route in routes:
|
||||
if route.token_ref == token_ref and route.token_env:
|
||||
return route.token_env
|
||||
return f"EGRESS_TOKEN_{len({r.token_env for r in routes if r.token_env})}"
|
||||
|
||||
|
||||
def _codex_host_credential_token_env(routes: list[EgressRoute]) -> str:
|
||||
for route in routes:
|
||||
if route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF:
|
||||
return route.token_env
|
||||
return _next_token_env(routes)
|
||||
|
||||
|
||||
def _ensure_codex_host_credential_route(
|
||||
routes: list[EgressRoute], host: str,
|
||||
def _merge_provider_route(
|
||||
routes: list[EgressRoute], pr: EgressRoute,
|
||||
) -> list[EgressRoute]:
|
||||
"""Merge one provider-declared route into the manifest route list.
|
||||
|
||||
Upgrade a bare-pass manifest route to authenticated if the provider
|
||||
declares auth for that host, or append if the host isn't in the manifest.
|
||||
Identical auth (same scheme + token_ref) on an existing route is a
|
||||
no-op, with a tls_passthrough upgrade if the provider route sets it.
|
||||
Conflicting auth (different scheme or token_ref) dies."""
|
||||
for idx, route in enumerate(routes):
|
||||
if route.host.lower() != host:
|
||||
if route.host.lower() != pr.host.lower():
|
||||
continue
|
||||
if route.auth_scheme or route.token_ref:
|
||||
if (
|
||||
route.auth_scheme == "Bearer"
|
||||
and route.token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||
):
|
||||
return routes
|
||||
die(
|
||||
"codex host credential forwarding conflicts with an "
|
||||
f"authenticated egress route for {host}. Remove that "
|
||||
"route auth block or disable agent_provider.forward_host_credentials."
|
||||
)
|
||||
if route.auth_scheme == pr.auth_scheme and route.token_ref == pr.token_ref:
|
||||
if pr.tls_passthrough and not route.tls_passthrough:
|
||||
routes[idx] = EgressRoute(
|
||||
host=route.host,
|
||||
path_allowlist=route.path_allowlist,
|
||||
auth_scheme="Bearer",
|
||||
token_env=_codex_host_credential_token_env(routes),
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
auth_scheme=route.auth_scheme,
|
||||
token_env=route.token_env,
|
||||
token_ref=route.token_ref,
|
||||
roles=route.roles,
|
||||
tls_passthrough=True,
|
||||
)
|
||||
return routes
|
||||
|
||||
die(
|
||||
f"provider egress route for {pr.host!r} conflicts with an "
|
||||
f"authenticated manifest route (different auth scheme or token "
|
||||
f"ref). Remove the manifest route's auth block or disable the "
|
||||
f"feature that adds this provider route."
|
||||
)
|
||||
token_env = _find_or_alloc_token_env(routes, pr.token_ref)
|
||||
routes[idx] = EgressRoute(
|
||||
host=route.host,
|
||||
path_allowlist=route.path_allowlist,
|
||||
auth_scheme=pr.auth_scheme,
|
||||
token_env=token_env,
|
||||
token_ref=pr.token_ref,
|
||||
roles=route.roles,
|
||||
tls_passthrough=pr.tls_passthrough,
|
||||
)
|
||||
return routes
|
||||
token_env = _find_or_alloc_token_env(routes, pr.token_ref)
|
||||
routes.append(EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer",
|
||||
token_env=_codex_host_credential_token_env(routes),
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
host=pr.host,
|
||||
auth_scheme=pr.auth_scheme,
|
||||
token_env=token_env,
|
||||
token_ref=pr.token_ref,
|
||||
tls_passthrough=pr.tls_passthrough,
|
||||
))
|
||||
return routes
|
||||
|
||||
@@ -338,18 +356,23 @@ class Egress(ABC):
|
||||
sidecar's start/stop lifecycle is backend-specific and lives on
|
||||
concrete subclasses."""
|
||||
|
||||
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressPlan:
|
||||
"""Lift `bottle.egress.routes` into resolved routes,
|
||||
render the routes file (mode 600) under `stage_dir`, and
|
||||
def prepare(
|
||||
self,
|
||||
bottle: Bottle,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> EgressPlan:
|
||||
"""Lift `bottle.egress.routes` + `provider_routes` into resolved
|
||||
routes, render the routes file (mode 600) under `stage_dir`, and
|
||||
return the plan. Pure host-side, no docker subprocess. The
|
||||
token-env map records the mapping the launch step uses to
|
||||
forward values from the host's environ into the sidecar's
|
||||
environ.
|
||||
forward values from the host's environ into the sidecar's environ.
|
||||
|
||||
Returned plan is incomplete: the launch step must fill
|
||||
`internal_network` / `egress_network` / `pipelock_proxy_url`
|
||||
via `dataclasses.replace` before passing it to `.start`."""
|
||||
routes = egress_routes_for_bottle(bottle)
|
||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||
routes_path = stage_dir / "egress_routes.yaml"
|
||||
routes_path.write_text(egress_render_routes(routes))
|
||||
routes_path.chmod(0o600)
|
||||
@@ -361,6 +384,7 @@ class Egress(ABC):
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||
"EGRESS_HOSTNAME",
|
||||
"EGRESS_ROUTES_IN_CONTAINER",
|
||||
"Egress",
|
||||
|
||||
+39
-99
@@ -175,47 +175,6 @@ class GitEntry:
|
||||
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
||||
EGRESS_AUTH_SCHEMES = ("Bearer", "token")
|
||||
|
||||
# Optional per-route role markers. A role signals "this route plays
|
||||
# a specific named part in the bottle's auth flow"; the launch step
|
||||
# acts on the marker.
|
||||
#
|
||||
# claude_code_oauth: this route auth-injects on the agent's
|
||||
# claude-code OAuth flow. Triggers prepare.py
|
||||
# to ship a placeholder CLAUDE_CODE_OAUTH_TOKEN
|
||||
# to the agent (so claude-code starts) and
|
||||
# disable nonessential-traffic / error-reporting
|
||||
# env vars. Host doesn't matter to the placeholder
|
||||
# logic — declare the role on whichever route
|
||||
# injects the OAuth header.
|
||||
#
|
||||
# codex_auth: placeholder marker reserved for follow-up Codex
|
||||
# credential-injection work. It is still accepted so
|
||||
# existing manifests and future egress-held auth flows
|
||||
# have a stable role name, but it no longer triggers an
|
||||
# OPENAI_API_KEY placeholder. Codex bottles should prefer
|
||||
# device/ChatGPT login state today.
|
||||
#
|
||||
# Routes without a `role` are pure proxy entries: egress
|
||||
# enforces path_allowlist + injects auth on its own, but nothing
|
||||
# special happens on the agent side.
|
||||
EGRESS_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
"codex_auth",
|
||||
})
|
||||
|
||||
# Singleton roles may appear on at most one route per bottle. Some
|
||||
# roles drive a single provider auth path; two routes claiming one
|
||||
# marker would leave "which one is canonical?" ambiguous.
|
||||
EGRESS_SINGLETON_ROLES = frozenset({
|
||||
"claude_code_oauth",
|
||||
"codex_auth",
|
||||
})
|
||||
|
||||
PROVIDER_EGRESS_ROLES = {
|
||||
"claude": frozenset({"claude_code_oauth"}),
|
||||
"codex": frozenset({"codex_auth"}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentProvider:
|
||||
@@ -224,20 +183,30 @@ class AgentProvider:
|
||||
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||
optionally points at a custom agent-image Dockerfile while leaving
|
||||
bot-bottle's sidecar infrastructure intact.
|
||||
|
||||
`auth_token` names the host env var that holds the provider's OAuth
|
||||
token (Claude only). The provisioner injects a provider-owned egress
|
||||
route for api.anthropic.com that re-injects this token as the Bearer
|
||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||
so the Claude Code CLI starts.
|
||||
|
||||
`forward_host_credentials` forwards the host Codex auth token into
|
||||
the egress sidecar (Codex only).
|
||||
"""
|
||||
|
||||
template: str = "claude"
|
||||
dockerfile: str = ""
|
||||
auth_token: str = ""
|
||||
forward_host_credentials: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
|
||||
for k in d:
|
||||
if k not in {"template", "dockerfile", "forward_host_credentials"}:
|
||||
if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider has unknown key {k!r}; "
|
||||
f"allowed: template, dockerfile, forward_host_credentials"
|
||||
f"allowed: template, dockerfile, auth_token, forward_host_credentials"
|
||||
)
|
||||
template = d.get("template", "claude")
|
||||
if not isinstance(template, str) or not template:
|
||||
@@ -256,6 +225,17 @@ class AgentProvider:
|
||||
f"bottle '{bottle_name}' agent_provider.dockerfile must be a "
|
||||
f"string (was {type(dockerfile).__name__})"
|
||||
)
|
||||
auth_token = d.get("auth_token", "")
|
||||
if not isinstance(auth_token, str):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.auth_token must be a "
|
||||
f"string (was {type(auth_token).__name__})"
|
||||
)
|
||||
if auth_token and template != "claude":
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.auth_token is only "
|
||||
f"supported for template 'claude'"
|
||||
)
|
||||
forward_host_credentials = d.get("forward_host_credentials", False)
|
||||
if not isinstance(forward_host_credentials, bool):
|
||||
raise ManifestError(
|
||||
@@ -270,6 +250,7 @@ class AgentProvider:
|
||||
return cls(
|
||||
template=template,
|
||||
dockerfile=dockerfile,
|
||||
auth_token=auth_token,
|
||||
forward_host_credentials=forward_host_credentials,
|
||||
)
|
||||
|
||||
@@ -428,10 +409,8 @@ class EgressRoute:
|
||||
manifest's `auth` block is omitted both fields are empty strings —
|
||||
no Authorization is written, no token forwarded.
|
||||
|
||||
`Role` is an optional tuple of named markers (see
|
||||
EGRESS_ROLES). The launch step reads these and triggers
|
||||
associated side effects (e.g. the `claude_code_oauth` marker
|
||||
causes prepare.py to set a placeholder OAuth env on the agent).
|
||||
`Role` is reserved for future use; all role strings are currently
|
||||
rejected by the validator.
|
||||
|
||||
Validation rules (enforced in `from_dict`):
|
||||
- `host` required, non-empty.
|
||||
@@ -440,10 +419,7 @@ class EgressRoute:
|
||||
`token_ref` as non-empty strings; an empty `auth: {}` is an
|
||||
error rather than a synonym for "no auth" (omit `auth` for
|
||||
that case).
|
||||
- `role` optional. String or list of strings drawn from
|
||||
EGRESS_ROLES. Singleton roles (see
|
||||
EGRESS_SINGLETON_ROLES) may appear on at most one
|
||||
route per bottle.
|
||||
- `role` optional, reserved — any non-empty value is rejected.
|
||||
"""
|
||||
|
||||
Host: str
|
||||
@@ -541,11 +517,10 @@ class EgressRoute:
|
||||
f"{label} role must be a string or a list of strings "
|
||||
f"(was {type(role_raw).__name__})"
|
||||
)
|
||||
for r in roles:
|
||||
if r not in EGRESS_ROLES:
|
||||
if roles:
|
||||
raise ManifestError(
|
||||
f"{label} role {r!r} is not one of "
|
||||
f"{', '.join(sorted(EGRESS_ROLES))}"
|
||||
f"{label} role {roles[0]!r} is not accepted; "
|
||||
f"the 'role' field is reserved for future use"
|
||||
)
|
||||
|
||||
pipelock = (
|
||||
@@ -581,9 +556,7 @@ class EgressConfig:
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, bottle_name: str, raw: object, *, agent_provider_template: str = "claude",
|
||||
) -> "EgressConfig":
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
||||
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||
routes_raw = d.get("routes")
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
@@ -598,9 +571,7 @@ class EgressConfig:
|
||||
EgressRoute.from_dict(bottle_name, i, entry)
|
||||
for i, entry in enumerate(routes_list)
|
||||
)
|
||||
_validate_egress_routes(
|
||||
bottle_name, routes, agent_provider_template=agent_provider_template,
|
||||
)
|
||||
_validate_egress_routes(bottle_name, routes)
|
||||
for k in d:
|
||||
if k != "routes":
|
||||
raise ManifestError(
|
||||
@@ -691,10 +662,7 @@ class Bottle:
|
||||
)
|
||||
|
||||
egress = (
|
||||
EgressConfig.from_dict(
|
||||
name, d["egress"],
|
||||
agent_provider_template=agent_provider.template,
|
||||
)
|
||||
EgressConfig.from_dict(name, d["egress"])
|
||||
if "egress" in d
|
||||
else EgressConfig()
|
||||
)
|
||||
@@ -1058,21 +1026,15 @@ def _is_ip_literal(value: str) -> bool:
|
||||
def _validate_egress_routes(
|
||||
bottle_name: str,
|
||||
routes: tuple[EgressRoute, ...],
|
||||
*,
|
||||
agent_provider_template: str = "claude",
|
||||
) -> None:
|
||||
"""Cross-validation for `bottle.egress.routes`:
|
||||
"""Cross-validation for `bottle.egress.routes`: hosts must be unique.
|
||||
|
||||
- Hosts must be unique within the bottle. The proxy matches by
|
||||
exact-host (v1, prefix matching is on path_allowlist only);
|
||||
duplicate hosts leave the route choice ambiguous.
|
||||
- Singleton roles (see EGRESS_SINGLETON_ROLES) may appear
|
||||
on at most one route per bottle.
|
||||
The proxy matches by exact-host (v1); duplicate hosts leave the
|
||||
route choice ambiguous so we reject them up front.
|
||||
|
||||
No cross-validation against `bottle.git` is performed. git-gate
|
||||
(SSH push/fetch) and egress (HTTPS) broker different
|
||||
protocols; declaring both for the same host is a legitimate
|
||||
dev setup."""
|
||||
(SSH push/fetch) and egress (HTTPS) broker different protocols;
|
||||
declaring both for the same host is a legitimate dev setup."""
|
||||
seen_hosts: dict[str, None] = {}
|
||||
for r in routes:
|
||||
key = r.Host.lower()
|
||||
@@ -1082,25 +1044,6 @@ def _validate_egress_routes(
|
||||
f"{r.Host!r}; each host must be unique on the proxy."
|
||||
)
|
||||
seen_hosts[key] = None
|
||||
for role in EGRESS_SINGLETON_ROLES:
|
||||
with_role = [r for r in routes if role in r.Role]
|
||||
if len(with_role) > 1:
|
||||
hosts = ", ".join(r.Host for r in with_role)
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' egress.routes has {len(with_role)} "
|
||||
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
|
||||
f"single launch-step side effect — pick one."
|
||||
)
|
||||
allowed_roles = PROVIDER_EGRESS_ROLES[agent_provider_template]
|
||||
for route in routes:
|
||||
for role in route.Role:
|
||||
if role not in allowed_roles:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' egress route for host "
|
||||
f"{route.Host!r} has role {role!r}, but provider "
|
||||
f"{agent_provider_template!r} only accepts roles "
|
||||
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||
@@ -1311,10 +1254,7 @@ def _merge_bottles(
|
||||
merged_supervise = (
|
||||
child.supervise if "supervise" in child_raw else parent.supervise
|
||||
)
|
||||
_validate_egress_routes(
|
||||
name, merged_egress.routes,
|
||||
agent_provider_template=merged_agent_provider.template,
|
||||
)
|
||||
_validate_egress_routes(name, merged_egress.routes)
|
||||
|
||||
return Bottle(
|
||||
env=merged_env,
|
||||
|
||||
+33
-37
@@ -21,11 +21,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from .egress import (
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
EGRESS_HOSTNAME,
|
||||
egress_routes_for_bottle,
|
||||
)
|
||||
from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle
|
||||
from .supervise import SUPERVISE_HOSTNAME
|
||||
from .manifest import Bottle
|
||||
|
||||
@@ -54,14 +50,17 @@ PIPELOCK_HOSTNAME = "pipelock"
|
||||
# --- Allowlist resolution --------------------------------------------------
|
||||
|
||||
|
||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||
def pipelock_effective_allowlist(
|
||||
bottle: Bottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> list[str]:
|
||||
"""Hostnames pipelock allows. Sorted for stability.
|
||||
|
||||
Always mirrors `egress_routes_for_bottle(bottle)` — egress is the
|
||||
single allowlist surface, and pipelock's allowlist is the downstream
|
||||
copy for defense-in-depth + DLP body scanning. For bottles without
|
||||
any `egress.routes[]` declared, this is empty except for supervise
|
||||
sidecar traffic when `supervise: true`.
|
||||
Always mirrors `egress_routes_for_bottle(bottle, provider_routes)` —
|
||||
egress is the single allowlist surface, and pipelock's allowlist is
|
||||
the downstream copy for defense-in-depth + DLP body scanning. For
|
||||
bottles without any `egress.routes[]` declared, this is empty except
|
||||
for supervise sidecar traffic when `supervise: true`.
|
||||
|
||||
The supervise sidecar's hostname is auto-added when supervise
|
||||
is enabled (sibling-sidecar traffic that flows through pipelock
|
||||
@@ -69,7 +68,7 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||
`bottle.git` do NOT contribute here — git traffic flows
|
||||
through git-gate (PRD 0008), not pipelock."""
|
||||
seen: dict[str, None] = {}
|
||||
for r in egress_routes_for_bottle(bottle):
|
||||
for r in egress_routes_for_bottle(bottle, provider_routes):
|
||||
if r.host:
|
||||
seen.setdefault(r.host, None)
|
||||
if bottle.supervise:
|
||||
@@ -102,32 +101,23 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
|
||||
def pipelock_effective_tls_passthrough(
|
||||
bottle: Bottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> list[str]:
|
||||
"""Hostnames pipelock should pass through (no TLS MITM).
|
||||
|
||||
A route opts in with `pipelock.tls_passthrough: true`. This is
|
||||
useful for provider API routes where egress injects the
|
||||
Authorization header after the agent boundary; pipelock still
|
||||
enforces the host allowlist but does not decrypt and scan that
|
||||
provider request.
|
||||
A manifest route opts in with `pipelock.tls_passthrough: true`
|
||||
(lifted into `EgressRoute.tls_passthrough` in `egress_manifest_routes`).
|
||||
Provider routes that set `tls_passthrough=True` (e.g. Codex credential
|
||||
routes where egress injects the host bearer after the agent boundary)
|
||||
are also included. Both arrive via `egress_routes_for_bottle` — no
|
||||
provider-specific branching needed here.
|
||||
"""
|
||||
seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH}
|
||||
for route in bottle.egress.routes:
|
||||
if route.Pipelock.TlsPassthrough:
|
||||
seen.setdefault(route.Host, None)
|
||||
# forward_host_credentials makes egress inject the host ChatGPT bearer
|
||||
# on the Codex API hosts AFTER the agent boundary. Pipelock sits
|
||||
# downstream of egress and DLP-scans request headers; left to MITM
|
||||
# these routes it flags the injected JWT as a leaked secret
|
||||
# ("request header contains secret") and blocks. Pass them through so
|
||||
# pipelock still enforces the host allowlist on CONNECT but does not
|
||||
# decrypt + rescan egress-owned auth. The auto-added routes live in
|
||||
# egress_routes_for_bottle, not bottle.egress.routes, so add the
|
||||
# hosts explicitly here.
|
||||
provider = bottle.agent_provider
|
||||
if provider.forward_host_credentials and provider.template == "codex":
|
||||
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||
seen.setdefault(host, None)
|
||||
for route in egress_routes_for_bottle(bottle, provider_routes):
|
||||
if route.tls_passthrough:
|
||||
seen.setdefault(route.host, None)
|
||||
return sorted(seen.keys())
|
||||
|
||||
|
||||
@@ -159,6 +149,7 @@ def pipelock_build_config(
|
||||
ca_cert_path: str = "",
|
||||
ca_key_path: str = "",
|
||||
ssrf_ip_allowlist: tuple[str, ...] = (),
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> dict[str, object]:
|
||||
"""Build the structured pipelock config dict the sidecar will load.
|
||||
|
||||
@@ -188,7 +179,7 @@ def pipelock_build_config(
|
||||
"version": 1,
|
||||
"mode": "strict",
|
||||
"enforce": True,
|
||||
"api_allowlist": pipelock_effective_allowlist(bottle),
|
||||
"api_allowlist": pipelock_effective_allowlist(bottle, provider_routes),
|
||||
"forward_proxy": {"enabled": True},
|
||||
}
|
||||
if not pipelock_seed_phrase_detection_enabled(bottle):
|
||||
@@ -222,7 +213,7 @@ def pipelock_build_config(
|
||||
"enabled": True,
|
||||
"ca_cert": ca_cert_path,
|
||||
"ca_key": ca_key_path,
|
||||
"passthrough_domains": pipelock_effective_tls_passthrough(bottle),
|
||||
"passthrough_domains": pipelock_effective_tls_passthrough(bottle, provider_routes),
|
||||
}
|
||||
effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist(
|
||||
bottle, ssrf_ip_allowlist,
|
||||
@@ -336,7 +327,11 @@ class PipelockProxy:
|
||||
(`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`)."""
|
||||
|
||||
def prepare(
|
||||
self, bottle: Bottle, slug: str, stage_dir: Path
|
||||
self,
|
||||
bottle: Bottle,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> PipelockProxyPlan:
|
||||
"""Write the pipelock yaml config (mode 600) under `stage_dir`
|
||||
and return the plan for launch. Pure host-side, no docker
|
||||
@@ -359,6 +354,7 @@ class PipelockProxy:
|
||||
bottle,
|
||||
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||
provider_routes=provider_routes,
|
||||
)
|
||||
yaml_path.write_text(pipelock_render_yaml(cfg))
|
||||
yaml_path.chmod(0o600)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PRD 0029: Codex host credentials through egress
|
||||
# PRD 0029: Provider auth credentials through egress
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** didericis-codex
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Allow Codex bottles to use a host-authorized ChatGPT/device-login
|
||||
access token by forwarding it only into the egress sidecar, gated by an
|
||||
explicit `agent_provider.forward_host_credentials` manifest flag.
|
||||
Allow provider bottles to inject host credentials into the egress
|
||||
sidecar without exposing them to the agent. Codex uses
|
||||
`agent_provider.forward_host_credentials` for ChatGPT/device-login
|
||||
access tokens. Claude uses `agent_provider.auth_token` to name the host
|
||||
env var holding its OAuth token, which egress injects on
|
||||
`api.anthropic.com` requests.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -51,8 +54,8 @@ possible, not in the agent.
|
||||
current access token at launch; operators can restart after host Codex
|
||||
refreshes auth.
|
||||
- Copying host `~/.codex/auth.json` credentials into the agent.
|
||||
- Allowing arbitrary host credential forwarding. This PRD covers Codex
|
||||
ChatGPT/device-login credentials only.
|
||||
- Allowing arbitrary host credential forwarding beyond the two providers
|
||||
covered here (Codex ChatGPT/device-login and Claude OAuth).
|
||||
- Hot-applying new authenticated Codex routes to an existing running
|
||||
sidecar. The current hot-apply path cannot safely populate new token
|
||||
env slots in an already-running container.
|
||||
@@ -64,6 +67,15 @@ possible, not in the agent.
|
||||
- Add `agent_provider.forward_host_credentials` to the bottle manifest
|
||||
schema, defaulting to `false`.
|
||||
- Support the flag for `agent_provider.template: codex`.
|
||||
- Add `agent_provider.auth_token` to the bottle manifest schema.
|
||||
- Support the field for `agent_provider.template: claude`: the named
|
||||
host env var is forwarded only into the egress sidecar as the Bearer
|
||||
token for `api.anthropic.com`, and a placeholder
|
||||
`CLAUDE_CODE_OAUTH_TOKEN` is set in the agent so the Claude Code CLI
|
||||
starts without a real credential.
|
||||
- Remove the `claude_code_oauth` egress route role, which previously
|
||||
required operators to declare the OAuth route manually. The provisioner
|
||||
now injects it from `auth_token`.
|
||||
- Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is
|
||||
set, otherwise from `~/.codex/auth.json`.
|
||||
- Extract only `tokens.access_token` for egress injection.
|
||||
|
||||
@@ -8,7 +8,12 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.agent_provider import agent_provision_plan, runtime_for
|
||||
from bot_bottle.agent_provider import (
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
agent_provision_plan,
|
||||
runtime_for,
|
||||
)
|
||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||
|
||||
|
||||
def _jwt(exp: int) -> str:
|
||||
@@ -19,16 +24,6 @@ def _jwt(exp: int) -> str:
|
||||
|
||||
|
||||
class TestAgentProviderRuntime(unittest.TestCase):
|
||||
def test_claude_keeps_oauth_placeholder(self):
|
||||
runtime = runtime_for("claude")
|
||||
self.assertEqual("claude_code_oauth", runtime.auth_role)
|
||||
self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", runtime.placeholder_env)
|
||||
|
||||
def test_codex_does_not_inject_openai_api_key_placeholder(self):
|
||||
runtime = runtime_for("codex")
|
||||
self.assertEqual("", runtime.auth_role)
|
||||
self.assertEqual("", runtime.placeholder_env)
|
||||
|
||||
def test_codex_plan_declares_home_state(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
@@ -76,19 +71,79 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
self.assertEqual(1, len(plan.verify))
|
||||
self.assertIn("CODEX_HOME=/run/codex-home", plan.verify[0].argv)
|
||||
|
||||
def test_claude_with_provider_auth_disables_nonessential_traffic(self):
|
||||
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
template="claude",
|
||||
dockerfile="/tmp/Dockerfile.claude",
|
||||
state_dir=Path(tmp),
|
||||
has_provider_auth=True,
|
||||
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
|
||||
)
|
||||
self.assertEqual(1, len(plan.egress_routes))
|
||||
route = plan.egress_routes[0]
|
||||
self.assertEqual("api.anthropic.com", route.host)
|
||||
self.assertEqual("Bearer", route.auth_scheme)
|
||||
self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", route.token_ref)
|
||||
self.assertTrue(route.tls_passthrough)
|
||||
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
|
||||
self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"])
|
||||
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"])
|
||||
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||
|
||||
def test_codex_forward_host_credentials_populates_egress_routes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
home = Path(tmp) / "host-codex"
|
||||
home.mkdir()
|
||||
(home / "auth.json").write_text(json.dumps({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {"access_token": _jwt(2000000000)},
|
||||
}))
|
||||
plan = agent_provision_plan(
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
forward_host_credentials=True,
|
||||
host_env={"CODEX_HOME": str(home)},
|
||||
)
|
||||
hosts = [r.host for r in plan.egress_routes]
|
||||
self.assertEqual(sorted(CODEX_HOST_CREDENTIAL_HOSTS), sorted(hosts))
|
||||
for r in plan.egress_routes:
|
||||
self.assertEqual("Bearer", r.auth_scheme)
|
||||
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, r.token_ref)
|
||||
self.assertTrue(r.tls_passthrough)
|
||||
|
||||
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
forward_host_credentials=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
"1",
|
||||
plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"],
|
||||
{r.host for r in plan.egress_routes},
|
||||
set(CODEX_HOST_CREDENTIAL_HOSTS),
|
||||
)
|
||||
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"])
|
||||
for r in plan.egress_routes:
|
||||
self.assertEqual("", r.auth_scheme)
|
||||
self.assertEqual("", r.token_ref)
|
||||
self.assertTrue(r.tls_passthrough)
|
||||
|
||||
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = agent_provision_plan(
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
)
|
||||
self.assertEqual(1, len(plan.egress_routes))
|
||||
route = plan.egress_routes[0]
|
||||
self.assertEqual("api.anthropic.com", route.host)
|
||||
self.assertEqual("", route.auth_scheme)
|
||||
self.assertEqual("", route.token_ref)
|
||||
self.assertTrue(route.tls_passthrough)
|
||||
self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", plan.env_vars)
|
||||
self.assertEqual(frozenset(), plan.hidden_env_names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+85
-52
@@ -5,6 +5,7 @@ import unittest
|
||||
|
||||
from bot_bottle.egress import (
|
||||
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
EgressRoute,
|
||||
egress_manifest_routes,
|
||||
egress_render_routes,
|
||||
egress_resolve_token_values,
|
||||
@@ -23,19 +24,13 @@ def _bottle(routes):
|
||||
}).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"]
|
||||
def _provider_route(host: str, token_ref: str, *, tls_passthrough: bool = False) -> EgressRoute:
|
||||
return EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer",
|
||||
token_ref=token_ref,
|
||||
tls_passthrough=tls_passthrough,
|
||||
)
|
||||
|
||||
|
||||
class TestRoutesForBottle(unittest.TestCase):
|
||||
@@ -100,9 +95,8 @@ class TestRoutesForBottle(unittest.TestCase):
|
||||
self.assertEqual("", routes[1].token_env)
|
||||
|
||||
|
||||
class TestRoutesForBottleUsesManifestOnly(unittest.TestCase):
|
||||
"""The effective route table is exactly the manifest-declared
|
||||
routes. Provider defaults are not injected implicitly."""
|
||||
class TestRoutesForBottleManifestOnly(unittest.TestCase):
|
||||
"""Without provider routes the effective table is exactly the manifest."""
|
||||
|
||||
def test_no_manifest_routes_means_no_effective_routes(self):
|
||||
b = _bottle([])
|
||||
@@ -123,58 +117,97 @@ class TestRoutesForBottleUsesManifestOnly(unittest.TestCase):
|
||||
effective = [r.host for r in egress_routes_for_bottle(b)]
|
||||
self.assertEqual(["x.example"], effective)
|
||||
|
||||
def test_codex_forward_host_credentials_adds_codex_routes(self):
|
||||
b = _codex_bottle(forward_host_credentials=True, routes=[])
|
||||
def test_tls_passthrough_lifted_from_manifest(self):
|
||||
b = _bottle([{
|
||||
"host": "api.openai.com",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
"pipelock": {"tls_passthrough": True},
|
||||
}])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes])
|
||||
self.assertTrue(routes[0].tls_passthrough)
|
||||
|
||||
def test_tls_passthrough_false_by_default(self):
|
||||
b = _bottle([{"host": "api.github.com"}])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
self.assertFalse(routes[0].tls_passthrough)
|
||||
|
||||
|
||||
class TestProviderRouteMerge(unittest.TestCase):
|
||||
"""Provider routes are merged into manifest routes generically."""
|
||||
|
||||
def test_provider_route_appended_when_not_in_manifest(self):
|
||||
b = _bottle([])
|
||||
pr = _provider_route("api.openai.com", "TOK")
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
self.assertEqual(1, len(routes))
|
||||
self.assertEqual("api.openai.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("Bearer", routes[1].auth_scheme)
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env)
|
||||
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[1].token_ref)
|
||||
self.assertEqual("TOK", 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(2, len(routes))
|
||||
def test_two_provider_routes_with_same_token_ref_share_slot(self):
|
||||
b = _bottle([])
|
||||
routes = egress_routes_for_bottle(b, (
|
||||
_provider_route("api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF),
|
||||
_provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF),
|
||||
))
|
||||
self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes])
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env)
|
||||
|
||||
def test_provider_route_upgrades_bare_manifest_route(self):
|
||||
b = _bottle([{"host": "chatgpt.com", "path_allowlist": ["/backend-api/"]}])
|
||||
pr = _provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF)
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
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)
|
||||
self.assertEqual("api.openai.com", routes[1].host)
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env)
|
||||
|
||||
def test_codex_forward_host_credentials_accepts_explicit_synthetic_route(self):
|
||||
b = _codex_bottle(
|
||||
forward_host_credentials=True,
|
||||
routes=[{
|
||||
def test_provider_route_noop_when_same_auth_already_in_manifest(self):
|
||||
b = _bottle([{
|
||||
"host": "api.openai.com",
|
||||
"auth": {
|
||||
"scheme": "Bearer",
|
||||
"token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
},
|
||||
}],
|
||||
)
|
||||
routes = egress_routes_for_bottle(b)
|
||||
self.assertEqual(["api.openai.com", "chatgpt.com"], [r.host for r in routes])
|
||||
"auth": {"scheme": "Bearer", "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF},
|
||||
}])
|
||||
pr = _provider_route("api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF)
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
self.assertEqual(1, len(routes))
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[0].token_env)
|
||||
self.assertEqual("EGRESS_TOKEN_0", routes[1].token_env)
|
||||
|
||||
def test_codex_forward_host_credentials_conflicts_with_authed_route(self):
|
||||
b = _codex_bottle(
|
||||
forward_host_credentials=True,
|
||||
routes=[{
|
||||
def test_provider_route_upgrades_tls_passthrough_on_existing_same_auth(self):
|
||||
b = _bottle([{
|
||||
"host": "api.openai.com",
|
||||
"auth": {"scheme": "Bearer", "token_ref": CODEX_HOST_CREDENTIAL_TOKEN_REF},
|
||||
}])
|
||||
pr = _provider_route(
|
||||
"api.openai.com", CODEX_HOST_CREDENTIAL_TOKEN_REF, tls_passthrough=True,
|
||||
)
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
self.assertEqual(1, len(routes))
|
||||
self.assertTrue(routes[0].tls_passthrough)
|
||||
|
||||
def test_provider_route_conflicts_with_different_authed_manifest_route(self):
|
||||
b = _bottle([{
|
||||
"host": "chatgpt.com",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "OTHER"},
|
||||
}],
|
||||
)
|
||||
}])
|
||||
pr = _provider_route("chatgpt.com", CODEX_HOST_CREDENTIAL_TOKEN_REF)
|
||||
with self.assertRaises(Die):
|
||||
egress_routes_for_bottle(b)
|
||||
egress_routes_for_bottle(b, (pr,))
|
||||
|
||||
def test_provider_route_tls_passthrough_set_on_appended_route(self):
|
||||
b = _bottle([])
|
||||
pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True)
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
self.assertTrue(routes[0].tls_passthrough)
|
||||
|
||||
def test_provider_route_tls_passthrough_set_on_upgraded_bare_route(self):
|
||||
b = _bottle([{"host": "api.openai.com"}])
|
||||
pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True)
|
||||
routes = egress_routes_for_bottle(b, (pr,))
|
||||
self.assertTrue(routes[0].tls_passthrough)
|
||||
|
||||
|
||||
class TestTokenEnvMap(unittest.TestCase):
|
||||
|
||||
@@ -85,6 +85,31 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
||||
"forward_host_credentials": True,
|
||||
})
|
||||
|
||||
def test_auth_token_defaults_empty(self):
|
||||
b = _provider_config_bottle({"template": "claude"})
|
||||
self.assertEqual("", b.agent_provider.auth_token)
|
||||
|
||||
def test_auth_token_allowed_for_claude(self):
|
||||
b = _provider_config_bottle({
|
||||
"template": "claude",
|
||||
"auth_token": "BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
|
||||
})
|
||||
self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", b.agent_provider.auth_token)
|
||||
|
||||
def test_auth_token_must_be_string(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "claude",
|
||||
"auth_token": 42,
|
||||
})
|
||||
|
||||
def test_auth_token_rejected_for_codex(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "codex",
|
||||
"auth_token": "SOME_TOKEN",
|
||||
})
|
||||
|
||||
|
||||
class TestPathAllowlist(unittest.TestCase):
|
||||
def test_optional(self):
|
||||
@@ -178,29 +203,12 @@ class TestRole(unittest.TestCase):
|
||||
b = _bottle([{"host": "x.example"}])
|
||||
self.assertEqual((), b.egress.routes[0].Role)
|
||||
|
||||
def test_string_normalizes_to_tuple(self):
|
||||
b = _bottle([{
|
||||
"host": "api.anthropic.com",
|
||||
"role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
self.assertEqual(("claude_code_oauth",),
|
||||
b.egress.routes[0].Role)
|
||||
|
||||
def test_list_supported(self):
|
||||
b = _bottle([{
|
||||
"host": "api.anthropic.com",
|
||||
"role": ["claude_code_oauth"],
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
self.assertEqual(("claude_code_oauth",),
|
||||
b.egress.routes[0].Role)
|
||||
|
||||
def test_unknown_role_rejected(self):
|
||||
# The role enum is locked down — typos shouldn't silently
|
||||
# become no-op markers.
|
||||
def test_any_role_rejected(self):
|
||||
# All former roles removed; the field is reserved for future use.
|
||||
for role in ("claude_code_oauth", "codex_auth", "totally-made-up"):
|
||||
with self.subTest(role=role):
|
||||
with self.assertRaises(ManifestError):
|
||||
_bottle([{"host": "x.example", "role": "totally-made-up"}])
|
||||
_bottle([{"host": "x.example", "role": role}])
|
||||
|
||||
def test_non_string_role_rejected(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
@@ -208,44 +216,7 @@ class TestRole(unittest.TestCase):
|
||||
|
||||
def test_list_with_non_string_item_rejected(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_bottle([{"host": "x.example",
|
||||
"role": ["claude_code_oauth", 42]}])
|
||||
|
||||
def test_singleton_claude_code_oauth_enforced(self):
|
||||
# Two routes both claiming the role would make "which one
|
||||
# drives the placeholder env?" ambiguous.
|
||||
with self.assertRaises(ManifestError):
|
||||
_bottle([
|
||||
{"host": "api.anthropic.com", "role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
|
||||
{"host": "api2.anthropic.example",
|
||||
"role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
||||
])
|
||||
|
||||
def test_codex_auth_role_allowed_for_codex_provider(self):
|
||||
b = _provider_bottle("codex", [{
|
||||
"host": "api.openai.com",
|
||||
"role": "codex_auth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "OPENAI_TOKEN"},
|
||||
}])
|
||||
self.assertEqual(("codex_auth",), b.egress.routes[0].Role)
|
||||
|
||||
def test_claude_role_rejected_for_codex_provider(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_bottle("codex", [{
|
||||
"host": "api.anthropic.com",
|
||||
"role": "claude_code_oauth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
|
||||
def test_codex_role_rejected_for_default_claude_provider(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_bottle([{
|
||||
"host": "api.openai.com",
|
||||
"role": "codex_auth",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "T"},
|
||||
}])
|
||||
_bottle([{"host": "x.example", "role": ["x", 42]}])
|
||||
|
||||
|
||||
class TestPipelockPolicy(unittest.TestCase):
|
||||
|
||||
@@ -5,6 +5,8 @@ git-gate (PRD 0008)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.agent_provider import CODEX_HOST_CREDENTIAL_HOSTS
|
||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import (
|
||||
pipelock_effective_allowlist,
|
||||
@@ -116,17 +118,23 @@ class TestTlsPassthrough(unittest.TestCase):
|
||||
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,
|
||||
},
|
||||
}))
|
||||
# ("request header contains secret"). Provider routes carry
|
||||
# tls_passthrough=True; pipelock reads this via egress_routes_for_bottle.
|
||||
provider_routes = tuple(
|
||||
EgressRoute(
|
||||
host=host,
|
||||
auth_scheme="Bearer",
|
||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
tls_passthrough=True,
|
||||
)
|
||||
for host in CODEX_HOST_CREDENTIAL_HOSTS
|
||||
)
|
||||
passthrough = pipelock_effective_tls_passthrough(
|
||||
_bottle({}), provider_routes,
|
||||
)
|
||||
self.assertEqual(["api.openai.com", "chatgpt.com"], passthrough)
|
||||
|
||||
def test_no_codex_passthrough_without_forward_host_credentials(self):
|
||||
def test_no_codex_passthrough_without_provider_routes(self):
|
||||
passthrough = pipelock_effective_tls_passthrough(_bottle({
|
||||
"agent_provider": {"template": "codex"},
|
||||
}))
|
||||
|
||||
@@ -8,21 +8,21 @@ from bot_bottle.backend.print_util import visible_agent_env_names
|
||||
|
||||
|
||||
class TestVisibleAgentEnvNames(unittest.TestCase):
|
||||
def test_codex_shows_openai_api_key_if_user_declares_it(self):
|
||||
def test_shows_all_when_no_hidden_names(self):
|
||||
self.assertEqual(
|
||||
["CUSTOM", "OPENAI_API_KEY"],
|
||||
visible_agent_env_names(
|
||||
["OPENAI_API_KEY", "CUSTOM"],
|
||||
agent_provider_template="codex",
|
||||
hidden_env_names=frozenset(),
|
||||
),
|
||||
)
|
||||
|
||||
def test_hides_only_active_provider_placeholder(self):
|
||||
def test_hides_provider_placeholder(self):
|
||||
self.assertEqual(
|
||||
["CUSTOM", "OPENAI_API_KEY"],
|
||||
visible_agent_env_names(
|
||||
["CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CUSTOM"],
|
||||
agent_provider_template="claude",
|
||||
hidden_env_names=frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user