PRD 0010: Credential proxy for agent-bound API tokens #14

Merged
didericis merged 24 commits from cred-proxy into main 2026-05-24 14:24:52 -04:00
11 changed files with 42 additions and 34 deletions
Showing only changes of commit 32b62cbacc - Show all commits
+29 -13
View File
@@ -284,20 +284,36 @@ as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
```
By default `cli.py` forwards the token into the agent container as
`CLAUDE_CODE_OAUTH_TOKEN`. Declare a `bottle.cred_proxy.routes` entry
with `role: "anthropic-base-url"` and `token_ref:
"CLAUDE_BOTTLE_OAUTH_TOKEN"` to route via cred-proxy instead: the
token then lives only in the cred-proxy sidecar's environ, the agent's
`ANTHROPIC_BASE_URL` points at the proxy, and `printenv` inside the
agent does not surface the real token. Either way the value is never
written to disk or placed on argv on the host.
The bottle reaches the Anthropic API only through the cred-proxy
sidecar. To let `claude` authenticate, declare a route in
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
Inside the container, `claude` picks up `CLAUDE_CODE_OAUTH_TOKEN` and
authenticates against your subscription. Caveats: the token is bound
to your subscription tier (Pro/Max/Team/Enterprise), it does not work
with `claude --bare` (which only reads `ANTHROPIC_API_KEY`), and if it
leaks, regenerate via `claude setup-token` again. Reference:
```jsonc
{
"path": "/anthropic/",
"upstream": "https://api.anthropic.com",
"auth_scheme": "Bearer",
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
"role": "anthropic-base-url"
}
```
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
env and forwards it into the cred-proxy container's environ — never
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start without one;
the proxy strips and replaces the header on every request). `printenv`
inside the agent does not surface the real token, and the value is
never written to disk or placed on argv on the host.
A bottle without an `anthropic-base-url` route has no path to the
Anthropic API — there is no fallback that forwards the token directly
to the agent. Caveats: the token is bound to your subscription tier
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
(which only reads `ANTHROPIC_API_KEY`), and if it leaks, regenerate
via `claude setup-token` again. Reference:
<https://code.claude.com/docs/en/authentication>.
## Trademarks
-1
View File
@@ -53,7 +53,6 @@ class BottleSpec:
agent_name: str
copy_cwd: bool
user_cwd: str
forward_oauth_token: bool
@dataclass(frozen=True)
+5 -7
View File
@@ -118,17 +118,15 @@ def resolve_plan(
forwarded_env: dict[str, str] = dict(resolved.forwarded)
# Find the (at most one) cred-proxy route claiming the
# anthropic-base-url role. Manifest validation enforces the
# singleton constraint.
# singleton constraint. cred-proxy is the only path the Anthropic
# OAuth token reaches the bottle — there is no fallback that
# forwards it into the agent's environ directly. Bottles that
# need claude-code to authenticate must declare an
# anthropic-base-url route.
anthropic_route = next(
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles),
None,
)
if spec.forward_oauth_token and anthropic_route is None:
# Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN
# directly. Still the path when no cred_proxy.routes entry
# is tagged anthropic-base-url; otherwise the sidecar holds
# the token.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
if anthropic_route is not None:
# Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not. Strip the
-1
View File
@@ -42,7 +42,6 @@ def cmd_start(argv: list[str]) -> int:
agent_name=args.name,
copy_cwd=args.cwd,
user_cwd=USER_CWD,
forward_oauth_token=bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN")),
)
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
+8 -6
View File
@@ -306,14 +306,16 @@ Why the agent can't reach the sidecar's environ:
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton-
role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — switch the
agent's OAuth handling: when a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL` (pointing
at the proxy) plus a non-secret placeholder for
- **`claude_bottle/backend/docker/prepare.py`** — drop the
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
forward entirely. cred-proxy is the only path the Anthropic
OAuth token reaches the bottle. When a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
(pointing at the proxy) plus a non-secret placeholder for
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start
otherwise; the proxy strips & replaces on every request).
When no such route exists, fall back to the pre-PRD-0010 path
(forward `CLAUDE_BOTTLE_OAUTH_TOKEN` as `CLAUDE_CODE_OAUTH_TOKEN`).
Bottles that need claude-code to authenticate must declare
the route; there is no fallback.
- **`claude_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
@@ -79,7 +79,6 @@ class TestPipelockAllowsNode(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle:
@@ -44,7 +44,6 @@ class TestPipelockAllowsNormalHttps(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle:
@@ -75,7 +75,6 @@ class TestPipelockBlocksNode(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle:
@@ -63,7 +63,6 @@ class TestPipelockBlocksSecretHttpsPost(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle:
@@ -99,7 +99,6 @@ class TestPipelockBlocksSecretPost(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle:
@@ -60,7 +60,6 @@ class TestPipelockLlmPassthrough(unittest.TestCase):
agent_name="demo",
copy_cwd=False,
user_cwd=str(stage_dir),
forward_oauth_token=False,
)
plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle: