PRD 0010: Credential proxy for agent-bound API tokens #14
@@ -284,20 +284,36 @@ as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
|||||||
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
By default `cli.py` forwards the token into the agent container as
|
The bottle reaches the Anthropic API only through the cred-proxy
|
||||||
`CLAUDE_CODE_OAUTH_TOKEN`. Declare a `bottle.cred_proxy.routes` entry
|
sidecar. To let `claude` authenticate, declare a route in
|
||||||
with `role: "anthropic-base-url"` and `token_ref:
|
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
|
||||||
"CLAUDE_BOTTLE_OAUTH_TOKEN"` to route via cred-proxy instead: the
|
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
|
||||||
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.
|
|
||||||
|
|
||||||
Inside the container, `claude` picks up `CLAUDE_CODE_OAUTH_TOKEN` and
|
```jsonc
|
||||||
authenticates against your subscription. Caveats: the token is bound
|
{
|
||||||
to your subscription tier (Pro/Max/Team/Enterprise), it does not work
|
"path": "/anthropic/",
|
||||||
with `claude --bare` (which only reads `ANTHROPIC_API_KEY`), and if it
|
"upstream": "https://api.anthropic.com",
|
||||||
leaks, regenerate via `claude setup-token` again. Reference:
|
"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>.
|
<https://code.claude.com/docs/en/authentication>.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class BottleSpec:
|
|||||||
agent_name: str
|
agent_name: str
|
||||||
copy_cwd: bool
|
copy_cwd: bool
|
||||||
user_cwd: str
|
user_cwd: str
|
||||||
forward_oauth_token: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -118,17 +118,15 @@ def resolve_plan(
|
|||||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||||
# Find the (at most one) cred-proxy route claiming the
|
# Find the (at most one) cred-proxy route claiming the
|
||||||
# anthropic-base-url role. Manifest validation enforces 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(
|
anthropic_route = next(
|
||||||
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.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:
|
|
||||||
# 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:
|
if anthropic_route is not None:
|
||||||
# Point claude-code at the cred-proxy. The sidecar holds the
|
# Point claude-code at the cred-proxy. The sidecar holds the
|
||||||
# OAuth token; the agent's environ does not. Strip the
|
# OAuth token; the agent's environ does not. Strip the
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
agent_name=args.name,
|
agent_name=args.name,
|
||||||
copy_cwd=args.cwd,
|
copy_cwd=args.cwd,
|
||||||
user_cwd=USER_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."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
||||||
|
|||||||
@@ -306,14 +306,16 @@ Why the agent can't reach the sidecar's environ:
|
|||||||
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
||||||
+ validate route shape, role enum, path uniqueness, singleton-
|
+ validate route shape, role enum, path uniqueness, singleton-
|
||||||
role constraints.
|
role constraints.
|
||||||
- **`claude_bottle/backend/docker/prepare.py`** — switch the
|
- **`claude_bottle/backend/docker/prepare.py`** — drop the
|
||||||
agent's OAuth handling: when a route claims the
|
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL` (pointing
|
forward entirely. cred-proxy is the only path the Anthropic
|
||||||
at the proxy) plus a non-secret placeholder for
|
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
|
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start
|
||||||
otherwise; the proxy strips & replaces on every request).
|
otherwise; the proxy strips & replaces on every request).
|
||||||
When no such route exists, fall back to the pre-PRD-0010 path
|
Bottles that need claude-code to authenticate must declare
|
||||||
(forward `CLAUDE_BOTTLE_OAUTH_TOKEN` as `CLAUDE_CODE_OAUTH_TOKEN`).
|
the route; there is no fallback.
|
||||||
- **`claude_bottle/backend/docker/backend.py`** — instantiate
|
- **`claude_bottle/backend/docker/backend.py`** — instantiate
|
||||||
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
||||||
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class TestPipelockAllowsNode(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class TestPipelockAllowsNormalHttps(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ class TestPipelockBlocksNode(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ class TestPipelockBlocksSecretHttpsPost(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class TestPipelockBlocksSecretPost(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class TestPipelockLlmPassthrough(unittest.TestCase):
|
|||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(stage_dir),
|
user_cwd=str(stage_dir),
|
||||||
forward_oauth_token=False,
|
|
||||||
)
|
)
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
|
|||||||
Reference in New Issue
Block a user