refactor(cred_proxy): rename Upstream -> Route, fix tea-login AttributeError
Three leftovers from the manifest refactor: 1. provision/cred_proxy.py:223 referenced u.kind == 'gitea' for the tea login count — kind was removed from the runtime class, so any bottle with a tea-login route raised AttributeError at provision time. Switch to `'tea-login' in r.roles`. 2. The runtime class CredProxyUpstream is renamed to CredProxyRoute (its data is a route on the proxy, not an "upstream"; the field route.upstream is the upstream URL). Module's own naming now aligns with manifest.CredProxyRoute and routes.json. 3. cred_proxy_upstreams_for_bottle -> cred_proxy_routes_for_bottle; CredProxyPlan.upstreams -> CredProxyPlan.routes; local `upstreams` collections become `routes`. Callers in backend.py, launch.py, prepare.py, bottle_plan.py, provision/cred_proxy.py, and tests updated. Also strips lingering `bottle.tokens` references from docstrings (pipelock.py, cred_proxy.py prepare(), manifest._parse_https_host, test_pipelock_allowlist.py module doc) and removes dead helpers from the integration test (the _bottle helper used a tokens field that no longer parses).
This commit is contained in:
@@ -106,11 +106,11 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
info(f" git gate : {'; '.join(git_lines)}")
|
info(f" git gate : {'; '.join(git_lines)}")
|
||||||
else:
|
else:
|
||||||
info(" git remotes : (none)")
|
info(" git remotes : (none)")
|
||||||
if self.cred_proxy_plan.upstreams:
|
if self.cred_proxy_plan.routes:
|
||||||
routes = [f"{u.path}→{u.upstream}" for u in self.cred_proxy_plan.upstreams]
|
lines = [f"{r.path}→{r.upstream}" for r in self.cred_proxy_plan.routes]
|
||||||
refs = sorted({u.token_ref for u in self.cred_proxy_plan.upstreams})
|
refs = sorted({r.token_ref for r in self.cred_proxy_plan.routes})
|
||||||
info(f" cred-proxy : {len(routes)} route(s); tokens: {', '.join(refs)}")
|
info(f" cred-proxy : {len(lines)} route(s); tokens: {', '.join(refs)}")
|
||||||
for line in routes:
|
for line in lines:
|
||||||
info(f" {line}")
|
info(f" {line}")
|
||||||
else:
|
else:
|
||||||
info(" cred-proxy : (none)")
|
info(" cred-proxy : (none)")
|
||||||
@@ -148,13 +148,13 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
],
|
],
|
||||||
"cred_proxy": [
|
"cred_proxy": [
|
||||||
{
|
{
|
||||||
"path": u.path,
|
"path": r.path,
|
||||||
"upstream": u.upstream,
|
"upstream": r.upstream,
|
||||||
"auth_scheme": u.auth_scheme,
|
"auth_scheme": r.auth_scheme,
|
||||||
"token_ref": u.token_ref,
|
"token_ref": r.token_ref,
|
||||||
"roles": list(u.roles),
|
"roles": list(r.roles),
|
||||||
}
|
}
|
||||||
for u in self.cred_proxy_plan.upstreams
|
for r in self.cred_proxy_plan.routes
|
||||||
],
|
],
|
||||||
"egress": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle
|
"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle
|
||||||
cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare
|
cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare
|
||||||
step (upstream lift + routes.json render + token-env-map derivation)
|
step (route lift + routes.json render + token-env-map derivation)
|
||||||
from `CredProxy`."""
|
from `CredProxy`."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -91,8 +91,8 @@ class DockerCredProxy(CredProxy):
|
|||||||
reach the real upstream over HTTPS.
|
reach the real upstream over HTTPS.
|
||||||
6. `docker start`.
|
6. `docker start`.
|
||||||
Returns the container name (the target passed to `.stop`)."""
|
Returns the container name (the target passed to `.stop`)."""
|
||||||
if not plan.upstreams:
|
if not plan.routes:
|
||||||
die("DockerCredProxy.start called with no upstreams; caller should skip")
|
die("DockerCredProxy.start called with no routes; caller should skip")
|
||||||
if not plan.internal_network or not plan.egress_network:
|
if not plan.internal_network or not plan.egress_network:
|
||||||
die(
|
die(
|
||||||
"DockerCredProxy.start: internal_network / egress_network must be "
|
"DockerCredProxy.start: internal_network / egress_network must be "
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def launch(
|
|||||||
stack.callback(git_gate.stop, git_gate_name)
|
stack.callback(git_gate.stop, git_gate_name)
|
||||||
|
|
||||||
# Cred-proxy (PRD 0010). One sidecar per bottle when
|
# Cred-proxy (PRD 0010). One sidecar per bottle when
|
||||||
# bottle.tokens declares any kind. Must come up AFTER pipelock
|
# bottle.cred_proxy.routes is non-empty. Must come up AFTER pipelock
|
||||||
# — cred-proxy routes its outbound HTTPS through pipelock
|
# — cred-proxy routes its outbound HTTPS through pipelock
|
||||||
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
|
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
|
||||||
# store) so the egress allowlist + body scanner sit in the
|
# store) so the egress allowlist + body scanner sit in the
|
||||||
@@ -113,7 +113,7 @@ def launch(
|
|||||||
# resolution for `cred-proxy` succeeds on the agent's first
|
# resolution for `cred-proxy` succeeds on the agent's first
|
||||||
# call; tokens flow from the host env into the sidecar's
|
# call; tokens flow from the host env into the sidecar's
|
||||||
# environ, not the agent's.
|
# environ, not the agent's.
|
||||||
if plan.cred_proxy_plan.upstreams:
|
if plan.cred_proxy_plan.routes:
|
||||||
cred_proxy_plan = dataclasses.replace(
|
cred_proxy_plan = dataclasses.replace(
|
||||||
plan.cred_proxy_plan,
|
plan.cred_proxy_plan,
|
||||||
internal_network=internal_network,
|
internal_network=internal_network,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def resolve_plan(
|
|||||||
# anthropic-base-url role. Manifest validation enforces the
|
# anthropic-base-url role. Manifest validation enforces the
|
||||||
# singleton constraint.
|
# singleton constraint.
|
||||||
anthropic_route = next(
|
anthropic_route = next(
|
||||||
(u for u in cred_proxy_plan.upstreams if "anthropic-base-url" in u.roles),
|
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if spec.forward_oauth_token and anthropic_route is None:
|
if spec.forward_oauth_token and anthropic_route is None:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ....cred_proxy import CredProxyUpstream
|
from ....cred_proxy import CredProxyRoute
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import util as docker_mod
|
from .. import util as docker_mod
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
@@ -31,21 +31,21 @@ from ..cred_proxy import cred_proxy_url
|
|||||||
|
|
||||||
def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
|
def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
|
||||||
"""Drop the agent-side dotfiles for each declared cred-proxy
|
"""Drop the agent-side dotfiles for each declared cred-proxy
|
||||||
route. No-op when the bottle has no tokens."""
|
route. No-op when the bottle has no routes."""
|
||||||
upstreams = plan.cred_proxy_plan.upstreams
|
routes = plan.cred_proxy_plan.routes
|
||||||
if not upstreams:
|
if not routes:
|
||||||
return
|
return
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
git_gate_hosts = {g.UpstreamHost for g in bottle.git}
|
git_gate_hosts = {g.UpstreamHost for g in bottle.git}
|
||||||
_provision_npmrc(plan, target, upstreams)
|
_provision_npmrc(plan, target, routes)
|
||||||
_provision_gitconfig(plan, target, upstreams, git_gate_hosts)
|
_provision_gitconfig(plan, target, routes, git_gate_hosts)
|
||||||
_provision_tea_config(plan, target, upstreams)
|
_provision_tea_config(plan, target, routes)
|
||||||
|
|
||||||
|
|
||||||
# --- npm --------------------------------------------------------------------
|
# --- npm --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
|
def render_npmrc(routes: tuple[CredProxyRoute, ...]) -> str:
|
||||||
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
|
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
|
||||||
finds the (single) route that claims it and writes a registry=
|
finds the (single) route that claims it and writes a registry=
|
||||||
line at the proxy. Empty string when no such route exists, so
|
line at the proxy. Empty string when no such route exists, so
|
||||||
@@ -55,18 +55,18 @@ def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
|
|||||||
npmrc deliberately carries no `_authToken`. The registry alone
|
npmrc deliberately carries no `_authToken`. The registry alone
|
||||||
is enough. Manifest validation enforces that the role is a
|
is enough. Manifest validation enforces that the role is a
|
||||||
singleton, so the first match is the only match."""
|
singleton, so the first match is the only match."""
|
||||||
for u in upstreams:
|
for r in routes:
|
||||||
if "npm-registry" in u.roles:
|
if "npm-registry" in r.roles:
|
||||||
return f"registry={cred_proxy_url()}{u.path}\n"
|
return f"registry={cred_proxy_url()}{r.path}\n"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _provision_npmrc(
|
def _provision_npmrc(
|
||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
target: str,
|
target: str,
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
) -> None:
|
) -> None:
|
||||||
content = render_npmrc(upstreams)
|
content = render_npmrc(routes)
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
@@ -88,7 +88,7 @@ def _provision_npmrc(
|
|||||||
|
|
||||||
|
|
||||||
def render_cred_proxy_gitconfig(
|
def render_cred_proxy_gitconfig(
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
|
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
|
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
|
||||||
@@ -105,23 +105,23 @@ def render_cred_proxy_gitconfig(
|
|||||||
suppressing the rewrite means `git clone https://<host>/...`
|
suppressing the rewrite means `git clone https://<host>/...`
|
||||||
doesn't have a tempting shortcut that just confuses on push.
|
doesn't have a tempting shortcut that just confuses on push.
|
||||||
|
|
||||||
The insteadOf left-hand side comes from `upstream` (with a
|
The insteadOf left-hand side comes from `route.upstream` (with a
|
||||||
trailing `/` so insteadOf matches at the directory boundary),
|
trailing `/` so insteadOf matches at the directory boundary),
|
||||||
so the same renderer handles github.com, gitea.dideric.is, and
|
so the same renderer handles github.com, gitea.dideric.is, and
|
||||||
any future host the user wires up."""
|
any future host the user wires up."""
|
||||||
rules: list[str] = []
|
rules: list[str] = []
|
||||||
for u in upstreams:
|
for r in routes:
|
||||||
if "git-insteadof" not in u.roles:
|
if "git-insteadof" not in r.roles:
|
||||||
continue
|
continue
|
||||||
# Strip scheme to derive the host for the git-gate overlap
|
# Strip scheme to derive the host for the git-gate overlap
|
||||||
# check. urllib.parse-free parse: same shape we accept in
|
# check. urllib.parse-free parse: same shape we accept in
|
||||||
# manifest validation.
|
# manifest validation.
|
||||||
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
||||||
if host in git_gate_hosts:
|
if host in git_gate_hosts:
|
||||||
continue
|
continue
|
||||||
rules.append(
|
rules.append(
|
||||||
f'[url "{cred_proxy_url()}{u.path}"]\n'
|
f'[url "{cred_proxy_url()}{r.path}"]\n'
|
||||||
f"\tinsteadOf = {u.upstream}/\n"
|
f"\tinsteadOf = {r.upstream}/\n"
|
||||||
)
|
)
|
||||||
if not rules:
|
if not rules:
|
||||||
return ""
|
return ""
|
||||||
@@ -136,14 +136,14 @@ def render_cred_proxy_gitconfig(
|
|||||||
def _provision_gitconfig(
|
def _provision_gitconfig(
|
||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
target: str,
|
target: str,
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
git_gate_hosts: set[str],
|
git_gate_hosts: set[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs
|
"""Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs
|
||||||
after `provision_git`, so any git-gate rules already live in the
|
after `provision_git`, so any git-gate rules already live in the
|
||||||
file; we append rather than overwrite. Hosts already brokered by
|
file; we append rather than overwrite. Hosts already brokered by
|
||||||
git-gate are skipped — git-gate is the canonical git path there."""
|
git-gate are skipped — git-gate is the canonical git path there."""
|
||||||
content = render_cred_proxy_gitconfig(upstreams, git_gate_hosts)
|
content = render_cred_proxy_gitconfig(routes, git_gate_hosts)
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
@@ -179,25 +179,25 @@ def _provision_gitconfig(
|
|||||||
# --- tea --------------------------------------------------------------------
|
# --- tea --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
|
def render_tea_config(routes: tuple[CredProxyRoute, ...]) -> str:
|
||||||
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
|
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
|
||||||
role: each route that claims it produces one `logins:` entry
|
role: each route that claims it produces one `logins:` entry
|
||||||
pointing at the cred-proxy. The proxy substitutes the real
|
pointing at the cred-proxy. The proxy substitutes the real
|
||||||
token at request time; the value in `token:` here is a
|
token at request time; the value in `token:` here is a
|
||||||
placeholder. `tea` refuses to make calls without a non-empty
|
placeholder. `tea` refuses to make calls without a non-empty
|
||||||
token field, so the placeholder is necessary."""
|
token field, so the placeholder is necessary."""
|
||||||
tea_routes = [u for u in upstreams if "tea-login" in u.roles]
|
tea_routes = [r for r in routes if "tea-login" in r.roles]
|
||||||
if not tea_routes:
|
if not tea_routes:
|
||||||
return ""
|
return ""
|
||||||
lines = ["logins:"]
|
lines = ["logins:"]
|
||||||
for u in tea_routes:
|
for r in tea_routes:
|
||||||
# Derive a stable login name from the upstream host. The
|
# Derive a stable login name from the upstream host. The
|
||||||
# path may not encode the host (e.g. `/gitea/dideric/` vs
|
# path may not encode the host (e.g. `/gitea/dideric/` vs
|
||||||
# upstream gitea.dideric.is), so we read it off `upstream`.
|
# upstream gitea.dideric.is), so we read it off `upstream`.
|
||||||
host = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
||||||
lines.extend([
|
lines.extend([
|
||||||
f"- name: {host}",
|
f"- name: {host}",
|
||||||
f" url: {cred_proxy_url()}{u.path}",
|
f" url: {cred_proxy_url()}{r.path}",
|
||||||
" token: cred-proxy-placeholder",
|
" token: cred-proxy-placeholder",
|
||||||
" default: false",
|
" default: false",
|
||||||
" ssh_host: \"\"",
|
" ssh_host: \"\"",
|
||||||
@@ -210,9 +210,9 @@ def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
|
|||||||
def _provision_tea_config(
|
def _provision_tea_config(
|
||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
target: str,
|
target: str,
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
) -> None:
|
) -> None:
|
||||||
content = render_tea_config(upstreams)
|
content = render_tea_config(routes)
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
@@ -220,7 +220,10 @@ def _provision_tea_config(
|
|||||||
cfg = plan.stage_dir / "agent_tea_config.yml"
|
cfg = plan.stage_dir / "agent_tea_config.yml"
|
||||||
cfg.write_text(content)
|
cfg.write_text(content)
|
||||||
cfg.chmod(0o600)
|
cfg.chmod(0o600)
|
||||||
info(f"writing {container_tea} ({len([u for u in upstreams if u.kind == 'gitea'])} gitea login(s))")
|
info(
|
||||||
|
f"writing {container_tea} "
|
||||||
|
f"({len([r for r in routes if 'tea-login' in r.roles])} tea login(s))"
|
||||||
|
)
|
||||||
docker_mod.docker_exec_root(
|
docker_mod.docker_exec_root(
|
||||||
target, ["mkdir", "-p", str(Path(container_tea).parent)]
|
target, ["mkdir", "-p", str(Path(container_tea).parent)]
|
||||||
)
|
)
|
||||||
|
|||||||
+58
-51
@@ -14,8 +14,8 @@ escaping the agent container, the same threshold pipelock and
|
|||||||
git-gate already rely on.
|
git-gate already rely on.
|
||||||
|
|
||||||
This module defines the abstract proxy (`CredProxy`), its plan
|
This module defines the abstract proxy (`CredProxy`), its plan
|
||||||
dataclass (`CredProxyPlan`), and the per-route shape
|
dataclass (`CredProxyPlan`), and the resolved per-route shape
|
||||||
(`CredProxyUpstream`). The sidecar's start/stop lifecycle is backend-
|
(`CredProxyRoute`). The sidecar's start/stop lifecycle is backend-
|
||||||
specific and lives on concrete subclasses (see
|
specific and lives on concrete subclasses (see
|
||||||
`claude_bottle/backend/docker/cred_proxy.py`).
|
`claude_bottle/backend/docker/cred_proxy.py`).
|
||||||
"""
|
"""
|
||||||
@@ -32,10 +32,15 @@ from .manifest import Bottle
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CredProxyUpstream:
|
class CredProxyRoute:
|
||||||
"""One route on the cred-proxy sidecar. Maps a path under the
|
"""One resolved route on the cred-proxy sidecar. Maps a path
|
||||||
proxy to a real upstream, an auth scheme, an in-container env-var
|
under the proxy to a real upstream, an auth scheme, an
|
||||||
slot, and optional provisioner roles.
|
in-container env-var slot, and optional provisioner roles.
|
||||||
|
|
||||||
|
Distinct from `manifest.CredProxyRoute` (the declaration shape
|
||||||
|
with Capitalize fields): this is the runtime view after the
|
||||||
|
abstract `CredProxy.prepare` step assigns token slots and
|
||||||
|
normalizes URLs. Modules that need both alias one on import.
|
||||||
|
|
||||||
`path` is the agent-facing prefix (e.g. `/anthropic/`).
|
`path` is the agent-facing prefix (e.g. `/anthropic/`).
|
||||||
`upstream` is the upstream base URL with scheme. `auth_scheme`
|
`upstream` is the upstream base URL with scheme. `auth_scheme`
|
||||||
@@ -46,12 +51,12 @@ class CredProxyUpstream:
|
|||||||
`token_env` is the env-var name inside the cred-proxy container
|
`token_env` is the env-var name inside the cred-proxy container
|
||||||
(e.g. `CRED_PROXY_TOKEN_0`); `token_ref` is the host env var the
|
(e.g. `CRED_PROXY_TOKEN_0`); `token_ref` is the host env var the
|
||||||
CLI reads at launch and forwards into the container's environ
|
CLI reads at launch and forwards into the container's environ
|
||||||
under `token_env`. Routes that share a TokenRef coalesce to one
|
under `token_env`. Routes that share a `token_ref` coalesce to
|
||||||
`token_env` slot.
|
one `token_env` slot.
|
||||||
|
|
||||||
`roles` are the provisioner tags from the manifest route (see
|
`roles` are the provisioner tags from the manifest route (see
|
||||||
`manifest.CRED_PROXY_ROLES`). Each tag drives one agent-side
|
`manifest.CRED_PROXY_ROLES`). Each tag drives one agent-side
|
||||||
rewrite when this upstream's dotfile family is written."""
|
rewrite when this route's dotfile family is written."""
|
||||||
|
|
||||||
path: str
|
path: str
|
||||||
upstream: str
|
upstream: str
|
||||||
@@ -65,16 +70,16 @@ class CredProxyUpstream:
|
|||||||
class CredProxyPlan:
|
class CredProxyPlan:
|
||||||
"""Output of CredProxy.prepare; consumed by .start.
|
"""Output of CredProxy.prepare; consumed by .start.
|
||||||
|
|
||||||
The slug + routes_path + upstreams + token_env_map fields are
|
The slug + routes_path + routes + token_env_map fields are
|
||||||
filled at prepare time (host-side, side-effect-free on docker).
|
filled at prepare time (host-side, side-effect-free on docker).
|
||||||
The network + pipelock fields are populated by the backend's
|
The network + pipelock fields are populated by the backend's
|
||||||
launch step via `dataclasses.replace` once those resources
|
launch step via `dataclasses.replace` once those resources
|
||||||
exist. Empty defaults are sentinels meaning "not yet set";
|
exist. Empty defaults are sentinels meaning "not yet set";
|
||||||
`.start` validates that they are populated.
|
`.start` validates that they are populated.
|
||||||
|
|
||||||
`token_env_map` is `{<token_env in container>: <TokenRef on host>}`.
|
`token_env_map` is `{<token_env in container>: <token_ref on host>}`.
|
||||||
The backend's start step reads `os.environ[TokenRef]` and forwards
|
The backend's start step reads `os.environ[token_ref]` and
|
||||||
the value into the cred-proxy container's environ under
|
forwards the value into the cred-proxy container's environ under
|
||||||
`token_env`. The plan itself never holds token values — secrets
|
`token_env`. The plan itself never holds token values — secrets
|
||||||
never land in a dataclass that might be logged.
|
never land in a dataclass that might be logged.
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ class CredProxyPlan:
|
|||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
routes_path: Path
|
routes_path: Path
|
||||||
upstreams: tuple[CredProxyUpstream, ...]
|
routes: tuple[CredProxyRoute, ...]
|
||||||
token_env_map: dict[str, str]
|
token_env_map: dict[str, str]
|
||||||
internal_network: str = ""
|
internal_network: str = ""
|
||||||
egress_network: str = ""
|
egress_network: str = ""
|
||||||
@@ -96,28 +101,29 @@ class CredProxyPlan:
|
|||||||
pipelock_proxy_url: str = ""
|
pipelock_proxy_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_upstreams_for_bottle(
|
def cred_proxy_routes_for_bottle(
|
||||||
bottle: Bottle,
|
bottle: Bottle,
|
||||||
) -> tuple[CredProxyUpstream, ...]:
|
) -> tuple[CredProxyRoute, ...]:
|
||||||
"""Lift each `bottle.cred_proxy.routes[]` entry into a
|
"""Lift each `bottle.cred_proxy.routes[]` manifest entry into a
|
||||||
CredProxyUpstream. Order is preserved so route lookup is stable.
|
resolved CredProxyRoute. Order is preserved so route lookup at
|
||||||
|
the proxy is stable.
|
||||||
|
|
||||||
Token-env slots are assigned per distinct TokenRef: the first
|
Token-env slots are assigned per distinct `token_ref`: the first
|
||||||
route with TokenRef "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a second
|
route with `token_ref` "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a
|
||||||
route with the same TokenRef shares slot 0. The launch step
|
second route with the same `token_ref` shares slot 0. The launch
|
||||||
forwards each TokenRef's value from the host environ into the
|
step forwards each `token_ref`'s value from the host environ into
|
||||||
sidecar's environ under the matching slot name once.
|
the sidecar's environ under the matching slot name once.
|
||||||
|
|
||||||
Manifest validation already enforced uniqueness rules (no
|
Manifest validation already enforced uniqueness rules (no
|
||||||
duplicate paths, singleton-role enforcement)."""
|
duplicate paths, singleton-role enforcement)."""
|
||||||
out: list[CredProxyUpstream] = []
|
out: list[CredProxyRoute] = []
|
||||||
slot_for_token: dict[str, str] = {}
|
slot_for_token: dict[str, str] = {}
|
||||||
for r in bottle.cred_proxy.routes:
|
for r in bottle.cred_proxy.routes:
|
||||||
token_env = slot_for_token.get(r.TokenRef)
|
token_env = slot_for_token.get(r.TokenRef)
|
||||||
if token_env is None:
|
if token_env is None:
|
||||||
token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}"
|
token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}"
|
||||||
slot_for_token[r.TokenRef] = token_env
|
slot_for_token[r.TokenRef] = token_env
|
||||||
out.append(CredProxyUpstream(
|
out.append(CredProxyRoute(
|
||||||
path=r.Path,
|
path=r.Path,
|
||||||
upstream=r.Upstream.rstrip("/"),
|
upstream=r.Upstream.rstrip("/"),
|
||||||
auth_scheme=r.AuthScheme,
|
auth_scheme=r.AuthScheme,
|
||||||
@@ -129,27 +135,27 @@ def cred_proxy_upstreams_for_bottle(
|
|||||||
|
|
||||||
|
|
||||||
def cred_proxy_token_env_map(
|
def cred_proxy_token_env_map(
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Collapse the upstream list into `{token_env: TokenRef}`. Two
|
"""Collapse the route list into `{token_env: token_ref}`. Two
|
||||||
routes that share a token (gh-api + gh-git) coalesce; the result
|
routes that share a token (gh-api + gh-git) coalesce; the result
|
||||||
is the set of env vars the backend's start step must forward into
|
is the set of env vars the backend's start step must forward into
|
||||||
the sidecar's environ."""
|
the sidecar's environ."""
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
for u in upstreams:
|
for r in routes:
|
||||||
existing = out.get(u.token_env)
|
existing = out.get(r.token_env)
|
||||||
if existing is not None and existing != u.token_ref:
|
if existing is not None and existing != r.token_ref:
|
||||||
die(
|
die(
|
||||||
f"cred-proxy plan conflict: {u.token_env} maps to both "
|
f"cred-proxy plan conflict: {r.token_env} maps to both "
|
||||||
f"{existing!r} and {u.token_ref!r}. Two routes sharing a "
|
f"{existing!r} and {r.token_ref!r}. Two routes sharing a "
|
||||||
f"token slot must reference the same host env var."
|
f"token slot must reference the same host env var."
|
||||||
)
|
)
|
||||||
out[u.token_env] = u.token_ref
|
out[r.token_env] = r.token_ref
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_render_routes(
|
def cred_proxy_render_routes(
|
||||||
upstreams: tuple[CredProxyUpstream, ...],
|
routes: tuple[CredProxyRoute, ...],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Serialize the route table for the cred-proxy server to read.
|
"""Serialize the route table for the cred-proxy server to read.
|
||||||
JSON, no token values, no host env-var names — the only thing
|
JSON, no token values, no host env-var names — the only thing
|
||||||
@@ -159,12 +165,12 @@ def cred_proxy_render_routes(
|
|||||||
payload = {
|
payload = {
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
"path": u.path,
|
"path": r.path,
|
||||||
"upstream": u.upstream,
|
"upstream": r.upstream,
|
||||||
"auth_scheme": u.auth_scheme,
|
"auth_scheme": r.auth_scheme,
|
||||||
"token_env": u.token_env,
|
"token_env": r.token_env,
|
||||||
}
|
}
|
||||||
for u in upstreams
|
for r in routes
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
return json.dumps(payload, indent=2, sort_keys=False) + "\n"
|
return json.dumps(payload, indent=2, sort_keys=False) + "\n"
|
||||||
@@ -201,29 +207,30 @@ def cred_proxy_resolve_token_values(
|
|||||||
|
|
||||||
class CredProxy(ABC):
|
class CredProxy(ABC):
|
||||||
"""The per-bottle credential proxy. Encapsulates the host-side
|
"""The per-bottle credential proxy. Encapsulates the host-side
|
||||||
prepare (upstream lift + routes.json render + token-env-map
|
prepare (route lift + routes.json render + token-env-map
|
||||||
derivation); the sidecar's start/stop lifecycle is backend-
|
derivation); the sidecar's start/stop lifecycle is backend-
|
||||||
specific and lives on concrete subclasses."""
|
specific and lives on concrete subclasses."""
|
||||||
|
|
||||||
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> CredProxyPlan:
|
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> CredProxyPlan:
|
||||||
"""Lift `bottle.tokens` into the upstream table, render the
|
"""Lift `bottle.cred_proxy.routes` into resolved routes,
|
||||||
routes.json (mode 600) under `stage_dir`, and return the plan.
|
render the routes.json (mode 600) under `stage_dir`, and
|
||||||
Pure host-side, no docker subprocess. The token-env map records
|
return the plan. Pure host-side, no docker subprocess. The
|
||||||
the mapping the launch step uses to forward values from the
|
token-env map records the mapping the launch step uses to
|
||||||
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
|
Returned plan is incomplete: the launch step must fill
|
||||||
`internal_network` / `egress_network` via `dataclasses.replace`
|
`internal_network` / `egress_network` via `dataclasses.replace`
|
||||||
before passing it to `.start`."""
|
before passing it to `.start`."""
|
||||||
upstreams = cred_proxy_upstreams_for_bottle(bottle)
|
routes = cred_proxy_routes_for_bottle(bottle)
|
||||||
routes_path = stage_dir / "cred_proxy_routes.json"
|
routes_path = stage_dir / "cred_proxy_routes.json"
|
||||||
routes_path.write_text(cred_proxy_render_routes(upstreams))
|
routes_path.write_text(cred_proxy_render_routes(routes))
|
||||||
routes_path.chmod(0o600)
|
routes_path.chmod(0o600)
|
||||||
return CredProxyPlan(
|
return CredProxyPlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
routes_path=routes_path,
|
routes_path=routes_path,
|
||||||
upstreams=upstreams,
|
routes=routes,
|
||||||
token_env_map=cred_proxy_token_env_map(upstreams),
|
token_env_map=cred_proxy_token_env_map(routes),
|
||||||
)
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -242,9 +249,9 @@ class CredProxy(ABC):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CredProxy",
|
"CredProxy",
|
||||||
"CredProxyPlan",
|
"CredProxyPlan",
|
||||||
"CredProxyUpstream",
|
"CredProxyRoute",
|
||||||
"cred_proxy_render_routes",
|
"cred_proxy_render_routes",
|
||||||
"cred_proxy_resolve_token_values",
|
"cred_proxy_resolve_token_values",
|
||||||
|
"cred_proxy_routes_for_bottle",
|
||||||
"cred_proxy_token_env_map",
|
"cred_proxy_token_env_map",
|
||||||
"cred_proxy_upstreams_for_bottle",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -608,8 +608,9 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
|||||||
def _parse_https_host(url: str, label: str) -> str:
|
def _parse_https_host(url: str, label: str) -> str:
|
||||||
"""Extract the host from an `https://host[:port][/path]` URL.
|
"""Extract the host from an `https://host[:port][/path]` URL.
|
||||||
Dies if `url` is not an https:// URL or the host segment is empty.
|
Dies if `url` is not an https:// URL or the host segment is empty.
|
||||||
Used to derive `TokenEntry.UpstreamHost` from a gitea Url so the
|
Used to derive `CredProxyRoute.UpstreamHost` from a route's
|
||||||
cross-validator can spot collisions with `bottle.git` hosts."""
|
`upstream` so pipelock's allowlist (and the provisioner's git-gate
|
||||||
|
overlap check) can match on host alone."""
|
||||||
if not url.startswith("https://"):
|
if not url.startswith("https://"):
|
||||||
die(f"{label} must be an https:// URL (was {url!r})")
|
die(f"{label} must be an https:// URL (was {url!r})")
|
||||||
rest = url[len("https://"):]
|
rest = url[len("https://"):]
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def pipelock_token_hosts(bottle: Bottle) -> list[str]:
|
|||||||
|
|
||||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
||||||
and the cred-proxy upstream hosts derived from bottle.tokens.
|
and the cred-proxy upstream hosts derived from bottle.cred_proxy.routes.
|
||||||
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
||||||
contribute here — git traffic flows through the per-agent git-gate
|
contribute here — git traffic flows through the per-agent git-gate
|
||||||
sidecar (PRD 0008), not pipelock."""
|
sidecar (PRD 0008), not pipelock."""
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ egress net. cred-proxy straddles both.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -32,8 +31,6 @@ from claude_bottle.backend.docker.network import (
|
|||||||
network_create_internal,
|
network_create_internal,
|
||||||
network_remove,
|
network_remove,
|
||||||
)
|
)
|
||||||
from claude_bottle.cred_proxy import CredProxy
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -43,24 +40,6 @@ FAKE_UPSTREAM_HOST = "fake-upstream"
|
|||||||
FAKE_UPSTREAM_PORT = "8080"
|
FAKE_UPSTREAM_PORT = "8080"
|
||||||
|
|
||||||
|
|
||||||
def _bottle(tokens):
|
|
||||||
return Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {"tokens": tokens}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}).bottles["dev"]
|
|
||||||
|
|
||||||
|
|
||||||
class _StubCredProxy(CredProxy):
|
|
||||||
"""CredProxy.prepare's render uses the Kind defaults, but the
|
|
||||||
integration test needs the cred-proxy to forward to the fake
|
|
||||||
upstream — not api.anthropic.com / github.com / npmjs.org. We
|
|
||||||
pass a one-route plan in directly via DockerCredProxy.start
|
|
||||||
rather than going through the manifest path."""
|
|
||||||
|
|
||||||
def start(self, plan): raise NotImplementedError
|
|
||||||
def stop(self, target): return None
|
|
||||||
|
|
||||||
|
|
||||||
def _make_routes_json(upstream_host: str, upstream_port: str) -> str:
|
def _make_routes_json(upstream_host: str, upstream_port: str) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"routes": [
|
"routes": [
|
||||||
@@ -140,12 +119,12 @@ class TestCredProxySidecar(unittest.TestCase):
|
|||||||
|
|
||||||
def _start_cred_proxy_via_production_code(self) -> str:
|
def _start_cred_proxy_via_production_code(self) -> str:
|
||||||
"""Run DockerCredProxy.start with a plan that points at the
|
"""Run DockerCredProxy.start with a plan that points at the
|
||||||
fake upstream. We bypass the manifest path (which fixes
|
fake upstream. We bypass the manifest path so we can route
|
||||||
upstreams by Kind) by handing .start an already-rendered
|
the proxy at a test-only upstream (the fake-upstream
|
||||||
routes.json."""
|
container) without going through the parser."""
|
||||||
from claude_bottle.cred_proxy import (
|
from claude_bottle.cred_proxy import (
|
||||||
CredProxyPlan,
|
CredProxyPlan,
|
||||||
CredProxyUpstream,
|
CredProxyRoute,
|
||||||
)
|
)
|
||||||
routes_path = self.work_dir / "routes.json"
|
routes_path = self.work_dir / "routes.json"
|
||||||
routes_path.write_text(_make_routes_json(FAKE_UPSTREAM_HOST, FAKE_UPSTREAM_PORT))
|
routes_path.write_text(_make_routes_json(FAKE_UPSTREAM_HOST, FAKE_UPSTREAM_PORT))
|
||||||
@@ -153,7 +132,7 @@ class TestCredProxySidecar(unittest.TestCase):
|
|||||||
plan = CredProxyPlan(
|
plan = CredProxyPlan(
|
||||||
slug=self.slug,
|
slug=self.slug,
|
||||||
routes_path=routes_path,
|
routes_path=routes_path,
|
||||||
upstreams=(CredProxyUpstream(
|
routes=(CredProxyRoute(
|
||||||
path="/fake/",
|
path="/fake/",
|
||||||
upstream=f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}",
|
upstream=f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}",
|
||||||
auth_scheme="Bearer",
|
auth_scheme="Bearer",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit: CredProxy upstream lift + routes.json render + token resolution
|
"""Unit: CredProxy route lift + routes.json render + token resolution
|
||||||
(PRD 0010)."""
|
(PRD 0010)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -8,7 +8,7 @@ from claude_bottle.cred_proxy import (
|
|||||||
cred_proxy_render_routes,
|
cred_proxy_render_routes,
|
||||||
cred_proxy_resolve_token_values,
|
cred_proxy_resolve_token_values,
|
||||||
cred_proxy_token_env_map,
|
cred_proxy_token_env_map,
|
||||||
cred_proxy_upstreams_for_bottle,
|
cred_proxy_routes_for_bottle,
|
||||||
)
|
)
|
||||||
from claude_bottle.log import Die
|
from claude_bottle.log import Die
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
@@ -28,7 +28,7 @@ class TestUpstreamLift(unittest.TestCase):
|
|||||||
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
||||||
"role": "anthropic-base-url"},
|
"role": "anthropic-base-url"},
|
||||||
])
|
])
|
||||||
upstreams = cred_proxy_upstreams_for_bottle(b)
|
upstreams = cred_proxy_routes_for_bottle(b)
|
||||||
self.assertEqual(1, len(upstreams))
|
self.assertEqual(1, len(upstreams))
|
||||||
u = upstreams[0]
|
u = upstreams[0]
|
||||||
self.assertEqual("/anthropic/", u.path)
|
self.assertEqual("/anthropic/", u.path)
|
||||||
@@ -47,7 +47,7 @@ class TestUpstreamLift(unittest.TestCase):
|
|||||||
"auth_scheme": "Bearer", "token_ref": "GH_PAT",
|
"auth_scheme": "Bearer", "token_ref": "GH_PAT",
|
||||||
"role": "git-insteadof"},
|
"role": "git-insteadof"},
|
||||||
])
|
])
|
||||||
upstreams = cred_proxy_upstreams_for_bottle(b)
|
upstreams = cred_proxy_routes_for_bottle(b)
|
||||||
self.assertEqual(2, len(upstreams))
|
self.assertEqual(2, len(upstreams))
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0"},
|
self.assertEqual({"CRED_PROXY_TOKEN_0"},
|
||||||
{u.token_env for u in upstreams})
|
{u.token_env for u in upstreams})
|
||||||
@@ -61,7 +61,7 @@ class TestUpstreamLift(unittest.TestCase):
|
|||||||
{"path": "/c/", "upstream": "https://c.example",
|
{"path": "/c/", "upstream": "https://c.example",
|
||||||
"auth_scheme": "Bearer", "token_ref": "T1"},
|
"auth_scheme": "Bearer", "token_ref": "T1"},
|
||||||
])
|
])
|
||||||
upstreams = cred_proxy_upstreams_for_bottle(b)
|
upstreams = cred_proxy_routes_for_bottle(b)
|
||||||
# T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0.
|
# T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0.
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env)
|
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env)
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env)
|
self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env)
|
||||||
@@ -73,7 +73,7 @@ class TestUpstreamLift(unittest.TestCase):
|
|||||||
"auth_scheme": "token", "token_ref": "T"},
|
"auth_scheme": "token", "token_ref": "T"},
|
||||||
])
|
])
|
||||||
self.assertEqual("https://gitea.dideric.is",
|
self.assertEqual("https://gitea.dideric.is",
|
||||||
cred_proxy_upstreams_for_bottle(b)[0].upstream)
|
cred_proxy_routes_for_bottle(b)[0].upstream)
|
||||||
|
|
||||||
def test_roles_list_passes_through(self):
|
def test_roles_list_passes_through(self):
|
||||||
b = _bottle([
|
b = _bottle([
|
||||||
@@ -82,11 +82,11 @@ class TestUpstreamLift(unittest.TestCase):
|
|||||||
"role": ["git-insteadof", "tea-login"]},
|
"role": ["git-insteadof", "tea-login"]},
|
||||||
])
|
])
|
||||||
self.assertEqual(("git-insteadof", "tea-login"),
|
self.assertEqual(("git-insteadof", "tea-login"),
|
||||||
cred_proxy_upstreams_for_bottle(b)[0].roles)
|
cred_proxy_routes_for_bottle(b)[0].roles)
|
||||||
|
|
||||||
def test_empty_routes_yields_empty_upstreams(self):
|
def test_empty_routes_yields_empty_upstreams(self):
|
||||||
b = _bottle([])
|
b = _bottle([])
|
||||||
self.assertEqual((), cred_proxy_upstreams_for_bottle(b))
|
self.assertEqual((), cred_proxy_routes_for_bottle(b))
|
||||||
|
|
||||||
|
|
||||||
class TestTokenEnvMap(unittest.TestCase):
|
class TestTokenEnvMap(unittest.TestCase):
|
||||||
@@ -97,7 +97,7 @@ class TestTokenEnvMap(unittest.TestCase):
|
|||||||
{"path": "/b/", "upstream": "https://b.example",
|
{"path": "/b/", "upstream": "https://b.example",
|
||||||
"auth_scheme": "Bearer", "token_ref": "B"},
|
"auth_scheme": "Bearer", "token_ref": "B"},
|
||||||
])
|
])
|
||||||
m = cred_proxy_token_env_map(cred_proxy_upstreams_for_bottle(b))
|
m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b))
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "A",
|
self.assertEqual({"CRED_PROXY_TOKEN_0": "A",
|
||||||
"CRED_PROXY_TOKEN_1": "B"}, m)
|
"CRED_PROXY_TOKEN_1": "B"}, m)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class TestTokenEnvMap(unittest.TestCase):
|
|||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
{"path": "/gh-git/", "upstream": "https://github.com",
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH"},
|
"auth_scheme": "Bearer", "token_ref": "GH"},
|
||||||
])
|
])
|
||||||
m = cred_proxy_token_env_map(cred_proxy_upstreams_for_bottle(b))
|
m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b))
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m)
|
self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class TestRoutesRender(unittest.TestCase):
|
|||||||
{"path": "/gitea/x/", "upstream": "https://gitea.dideric.is",
|
{"path": "/gitea/x/", "upstream": "https://gitea.dideric.is",
|
||||||
"auth_scheme": "token", "token_ref": "GITEA_TOKEN"},
|
"auth_scheme": "token", "token_ref": "GITEA_TOKEN"},
|
||||||
])
|
])
|
||||||
rendered = cred_proxy_render_routes(cred_proxy_upstreams_for_bottle(b))
|
rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b))
|
||||||
payload = json.loads(rendered)
|
payload = json.loads(rendered)
|
||||||
self.assertEqual(["routes"], list(payload.keys()))
|
self.assertEqual(["routes"], list(payload.keys()))
|
||||||
self.assertEqual(2, len(payload["routes"]))
|
self.assertEqual(2, len(payload["routes"]))
|
||||||
@@ -134,7 +134,7 @@ class TestRoutesRender(unittest.TestCase):
|
|||||||
# or the host-side TokenRef name.
|
# or the host-side TokenRef name.
|
||||||
b = _bottle([{"path": "/x/", "upstream": "https://x.example",
|
b = _bottle([{"path": "/x/", "upstream": "https://x.example",
|
||||||
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"}])
|
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"}])
|
||||||
rendered = cred_proxy_render_routes(cred_proxy_upstreams_for_bottle(b))
|
rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b))
|
||||||
self.assertNotIn("GITHUB_TOKEN", rendered)
|
self.assertNotIn("GITHUB_TOKEN", rendered)
|
||||||
|
|
||||||
def test_empty_upstreams_renders_empty_routes_array(self):
|
def test_empty_upstreams_renders_empty_routes_array(self):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from claude_bottle.backend.docker.cred_proxy import (
|
|||||||
cred_proxy_container_name,
|
cred_proxy_container_name,
|
||||||
cred_proxy_url,
|
cred_proxy_url,
|
||||||
)
|
)
|
||||||
from claude_bottle.cred_proxy import CredProxyPlan, CredProxyUpstream
|
from claude_bottle.cred_proxy import CredProxyPlan, CredProxyRoute
|
||||||
from claude_bottle.log import Die
|
from claude_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ def _empty_plan(**overrides):
|
|||||||
base = {
|
base = {
|
||||||
"slug": "demo",
|
"slug": "demo",
|
||||||
"routes_path": Path("/nonexistent"),
|
"routes_path": Path("/nonexistent"),
|
||||||
"upstreams": (),
|
"routes": (),
|
||||||
"token_env_map": {},
|
"token_env_map": {},
|
||||||
"internal_network": "",
|
"internal_network": "",
|
||||||
"egress_network": "",
|
"egress_network": "",
|
||||||
@@ -56,17 +56,17 @@ class TestStartGuards(unittest.TestCase):
|
|||||||
self.proxy.start(_empty_plan())
|
self.proxy.start(_empty_plan())
|
||||||
|
|
||||||
def test_missing_internal_network_dies(self):
|
def test_missing_internal_network_dies(self):
|
||||||
upstream = CredProxyUpstream(
|
upstream = CredProxyRoute(
|
||||||
path="/anthropic/",
|
path="/anthropic/",
|
||||||
upstream="https://api.anthropic.com",
|
upstream="https://api.anthropic.com",
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
||||||
token_ref="T",
|
token_ref="T",
|
||||||
)
|
)
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
self.proxy.start(_empty_plan(upstreams=(upstream,)))
|
self.proxy.start(_empty_plan(routes=(upstream,)))
|
||||||
|
|
||||||
def test_missing_routes_file_dies(self):
|
def test_missing_routes_file_dies(self):
|
||||||
upstream = CredProxyUpstream(
|
upstream = CredProxyRoute(
|
||||||
path="/anthropic/",
|
path="/anthropic/",
|
||||||
upstream="https://api.anthropic.com",
|
upstream="https://api.anthropic.com",
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
||||||
@@ -74,7 +74,7 @@ class TestStartGuards(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
self.proxy.start(_empty_plan(
|
self.proxy.start(_empty_plan(
|
||||||
upstreams=(upstream,),
|
routes=(upstream,),
|
||||||
internal_network="net-x",
|
internal_network="net-x",
|
||||||
egress_network="egress-x",
|
egress_network="egress-x",
|
||||||
routes_path=Path("/tmp/cred-proxy-test-does-not-exist.json"),
|
routes_path=Path("/tmp/cred-proxy-test-does-not-exist.json"),
|
||||||
@@ -83,7 +83,7 @@ class TestStartGuards(unittest.TestCase):
|
|||||||
def test_pipelock_url_without_ca_dies(self):
|
def test_pipelock_url_without_ca_dies(self):
|
||||||
# URL set + CA path empty/missing is a wiring bug: either both
|
# URL set + CA path empty/missing is a wiring bug: either both
|
||||||
# populated (production) or both empty (test escape hatch).
|
# populated (production) or both empty (test escape hatch).
|
||||||
upstream = CredProxyUpstream(
|
upstream = CredProxyRoute(
|
||||||
path="/anthropic/",
|
path="/anthropic/",
|
||||||
upstream="https://api.anthropic.com",
|
upstream="https://api.anthropic.com",
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
||||||
@@ -92,7 +92,7 @@ class TestStartGuards(unittest.TestCase):
|
|||||||
with tempfile.NamedTemporaryFile() as routes:
|
with tempfile.NamedTemporaryFile() as routes:
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
self.proxy.start(_empty_plan(
|
self.proxy.start(_empty_plan(
|
||||||
upstreams=(upstream,),
|
routes=(upstream,),
|
||||||
internal_network="net-x",
|
internal_network="net-x",
|
||||||
egress_network="egress-x",
|
egress_network="egress-x",
|
||||||
routes_path=Path(routes.name),
|
routes_path=Path(routes.name),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults,
|
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults,
|
||||||
bottle.egress.allowlist, and cred-proxy upstream hosts derived from
|
bottle.egress.allowlist, and cred-proxy upstream hosts derived from
|
||||||
bottle.tokens (PRD 0010). Git upstreams declared in bottle.git do not
|
bottle.cred_proxy.routes (PRD 0010). Git upstreams declared in bottle.git
|
||||||
contribute here; they flow through the per-agent git-gate (PRD 0008)."""
|
do not contribute here; they flow through the per-agent git-gate (PRD 0008)."""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from claude_bottle.backend.docker.provision.cred_proxy import (
|
|||||||
render_npmrc,
|
render_npmrc,
|
||||||
render_tea_config,
|
render_tea_config,
|
||||||
)
|
)
|
||||||
from claude_bottle.cred_proxy import cred_proxy_upstreams_for_bottle
|
from claude_bottle.cred_proxy import cred_proxy_routes_for_bottle
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ def _bottle(routes):
|
|||||||
|
|
||||||
|
|
||||||
def _upstreams(routes):
|
def _upstreams(routes):
|
||||||
return cred_proxy_upstreams_for_bottle(_bottle(routes))
|
return cred_proxy_routes_for_bottle(_bottle(routes))
|
||||||
|
|
||||||
|
|
||||||
class TestRenderNpmrc(unittest.TestCase):
|
class TestRenderNpmrc(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user