feat(cred_proxy)!: cred-proxy is the only Anthropic auth path
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 23s

Removes the legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` -> `CLAUDE_CODE_OAUTH_TOKEN`
forward in prepare.py. Bottles that need claude-code to authenticate
must declare a cred_proxy route with role: "anthropic-base-url" — there
is no fallback that hands the token to the agent directly.

Drops the now-dead BottleSpec.forward_oauth_token field, the CLI
setter that read CLAUDE_BOTTLE_OAUTH_TOKEN from the host env at
prepare time, and the forward_oauth_token=False arg in the six
pipelock integration tests.

PRD 0010 and README updated; the dev ~/claude-bottle.json gains an
anthropic-base-url route so the implementer/researcher agents keep
working.

BREAKING: bottles previously relying on the implicit OAuth forward
will now produce an agent environ without any Anthropic credential.
Verified with --dry-run: a bottle with no anthropic-base-url route
yields env_names: [] (no token at all); a bottle that declares the
route yields ANTHROPIC_BASE_URL plus a non-secret placeholder for
CLAUDE_CODE_OAUTH_TOKEN.
This commit is contained in:
2026-05-24 12:56:09 -04:00
parent 0eb482daf0
commit 32b62cbacc
11 changed files with 42 additions and 34 deletions
+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: