feat(sandbox): route Anthropic traffic through credential-injecting proxy
Adds a Caddy-based reverse proxy (`anthropic-proxy/`) that forwards /v1/* to api.anthropic.com with the real credential injected at the proxy, not in the sandbox. The sandbox now only sees ANTHROPIC_BASE_URL=http://anthropic-proxy:8081 and a placeholder ANTHROPIC_AUTH_TOKEN, so `env` / `cat /proc/self/environ` yield nothing. Proxy auth mode: - CLAUDE_CODE_OAUTH_TOKEN → `Authorization: Bearer` + appends `oauth-2025-04-20` to anthropic-beta (required for subscription OAuth on /v1/messages). - ANTHROPIC_API_KEY → `x-api-key`. Pipe changes: - Drop ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN valves. - Add ANTHROPIC_BASE_URL valve (defaults to the compose service DNS). - Replace credential env injection with ANTHROPIC_AUTH_TOKEN=proxied. Closes acceptance criteria for issue #6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e7fa4cb77
commit
4158af9ebe
|
|
@ -0,0 +1,7 @@
|
|||
FROM caddy:2-alpine
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 8081 8082
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
#!/bin/sh
|
||||
# Render a Caddyfile picking either OAuth Bearer or x-api-key auth based on
|
||||
# which env var is set. OAuth wins if both are set (matches the pipe's prior
|
||||
# precedence). The rendered file lives under /tmp and is never written into
|
||||
# the image layer.
|
||||
#
|
||||
# We use Caddy's {env.X} placeholder for the actual secret so it's loaded at
|
||||
# runtime, never baked into the config file on disk and never printed. The
|
||||
# admin API is disabled (admin off) so the config can't be read back over HTTP.
|
||||
set -eu
|
||||
|
||||
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
||||
# Subscription OAuth token (from `claude setup-token`). Must be sent
|
||||
# as Authorization: Bearer, plus the oauth-* anthropic-beta header that
|
||||
# tells Anthropic to resolve the Bearer as a subscription token rather
|
||||
# than demanding x-api-key.
|
||||
#
|
||||
# NOTE: we substitute the token into the Caddyfile at render time (shell
|
||||
# heredoc) instead of using Caddy's {env.X} placeholder. Empirically the
|
||||
# {env.X} form inside a `header_up` directive silently drops the header
|
||||
# when the value contains an `=` or other characters in some Caddy
|
||||
# versions. Literal substitution is unambiguous. The file lives on
|
||||
# tmpfs and isn't readable from outside the container (admin off).
|
||||
# `+anthropic-beta` APPENDS rather than replaces. The CLI sends its own
|
||||
# beta flags (interleaved-thinking, context-management, etc.) that must
|
||||
# be preserved; we only need to add `oauth-2025-04-20` so Anthropic
|
||||
# resolves the Bearer as a subscription token.
|
||||
AUTH_DIRECTIVE="header_up Authorization \"Bearer ${CLAUDE_CODE_OAUTH_TOKEN}\"
|
||||
header_up +anthropic-beta \"oauth-2025-04-20\""
|
||||
echo "anthropic-proxy: injecting Authorization (OAuth bearer) + anthropic-beta oauth-2025-04-20"
|
||||
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
AUTH_DIRECTIVE="header_up x-api-key \"${ANTHROPIC_API_KEY}\""
|
||||
echo "anthropic-proxy: injecting x-api-key (API key)"
|
||||
else
|
||||
echo "anthropic-proxy: FATAL — set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > /tmp/Caddyfile <<EOF
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
persist_config off
|
||||
}
|
||||
|
||||
:8081 {
|
||||
# Tiny allowlist: only Anthropic API paths. Anything else gets 404'd
|
||||
# before a request is forwarded, so the proxy can't be abused as a
|
||||
# generic egress relay if the sandbox is compromised.
|
||||
@allowed path /v1/*
|
||||
handle @allowed {
|
||||
reverse_proxy https://api.anthropic.com {
|
||||
header_up Host api.anthropic.com
|
||||
# Strip any auth the sandbox tries to set so it can't override us.
|
||||
# NOTE: in Caddy, `header_up -X` followed by `header_up X val` in
|
||||
# the same reverse_proxy block can cancel out (both operations
|
||||
# are collapsed per-name). Use replace form `header_up X val`
|
||||
# alone — it unconditionally overwrites whatever the client sent.
|
||||
header_up -x-api-key
|
||||
${AUTH_DIRECTIVE}
|
||||
# flush_interval -1 disables response buffering so Anthropic's
|
||||
# SSE stream reaches Claude Code immediately instead of being
|
||||
# held in Caddy's default 2s buffer.
|
||||
flush_interval -1
|
||||
transport http {
|
||||
versions 1.1 2
|
||||
# Keep upstream connections warm — Anthropic benefits from
|
||||
# keep-alive, and new TLS handshakes add measurable latency.
|
||||
keepalive 90s
|
||||
}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
respond "not allowed" 404
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
# Caddy's access log fields include URI/method/status but never
|
||||
# request headers, so the injected credential never hits the log.
|
||||
}
|
||||
}
|
||||
|
||||
:8082 {
|
||||
# Separate port for health checks — doesn't traverse the auth path.
|
||||
handle /healthz {
|
||||
respond "ok" 200
|
||||
}
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exec caddy run --config /tmp/Caddyfile --adapter caddyfile
|
||||
|
|
@ -241,8 +241,10 @@ class _ClaudeRunConfig:
|
|||
allowed_tools: List[str]
|
||||
max_turns: int
|
||||
resume_session_id: Optional[str]
|
||||
oauth_token: Optional[str]
|
||||
api_key: Optional[str]
|
||||
# URL of the credential-injecting proxy the sandbox should route Claude API
|
||||
# traffic through. Real ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN live on
|
||||
# the proxy container only; the sandbox never sees them.
|
||||
anthropic_base_url: str
|
||||
workdir: str
|
||||
system_prompt_append: Optional[str]
|
||||
|
||||
|
|
@ -264,10 +266,19 @@ _SANDBOX_ENV_NOTE = (
|
|||
|
||||
def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str:
|
||||
env_parts: List[str] = []
|
||||
if cfg.oauth_token:
|
||||
env_parts.append(f"CLAUDE_CODE_OAUTH_TOKEN={shlex.quote(cfg.oauth_token)}")
|
||||
elif cfg.api_key:
|
||||
env_parts.append(f"ANTHROPIC_API_KEY={shlex.quote(cfg.api_key)}")
|
||||
# Route all Anthropic API calls through the credential-injecting proxy.
|
||||
# Claude Code honours ANTHROPIC_BASE_URL; the proxy adds the real
|
||||
# credential before forwarding to api.anthropic.com. No token in env or
|
||||
# argv → `env`, `printenv`, `cat /proc/self/environ` reveal nothing
|
||||
# Anthropic-shaped from inside the sandbox.
|
||||
env_parts.append(f"ANTHROPIC_BASE_URL={shlex.quote(cfg.anthropic_base_url)}")
|
||||
# The CLI refuses to talk to a non-anthropic.com base URL unless it also
|
||||
# sees a non-empty auth variable. ANTHROPIC_AUTH_TOKEN is the docs'
|
||||
# recommended variable for gateway/proxy setups — the CLI sends it as
|
||||
# `Authorization: Bearer ...`, which the proxy then unconditionally
|
||||
# overwrites with the real credential. The placeholder value is never
|
||||
# seen by Anthropic.
|
||||
env_parts.append("ANTHROPIC_AUTH_TOKEN=proxied")
|
||||
# `claude --dangerously-skip-permissions` still refuses root unless told
|
||||
# it's sandboxed. open-terminal runs processes as the per-user UID so this
|
||||
# rarely matters, but setting it is harmless.
|
||||
|
|
@ -613,16 +624,15 @@ class Pipe:
|
|||
"the end user is then conveyed via the X-User-Id header."
|
||||
),
|
||||
)
|
||||
ANTHROPIC_API_KEY: str = Field(
|
||||
default="",
|
||||
description="Anthropic API key (pay-per-token). Forwarded into the sandbox as an env var.",
|
||||
)
|
||||
CLAUDE_CODE_OAUTH_TOKEN: str = Field(
|
||||
default="",
|
||||
ANTHROPIC_BASE_URL: str = Field(
|
||||
default="http://anthropic-proxy:8081",
|
||||
description=(
|
||||
"Claude subscription OAuth token. Takes priority over "
|
||||
"ANTHROPIC_API_KEY — don't share with untrusted users (per "
|
||||
"Anthropic's subscription terms)."
|
||||
"Credential-injecting proxy the sandbox routes Anthropic "
|
||||
"traffic through. The real ANTHROPIC_API_KEY / "
|
||||
"CLAUDE_CODE_OAUTH_TOKEN live on the proxy container only — "
|
||||
"the sandbox never sees them, so `env` inside the agent "
|
||||
"reveals nothing Anthropic-shaped. Use the docker-compose "
|
||||
"service DNS name when both run in the same network."
|
||||
),
|
||||
)
|
||||
MODEL: str = Field(default="claude-haiku-4-5")
|
||||
|
|
@ -684,8 +694,7 @@ class Pipe:
|
|||
allowed_tools=allowed_tools,
|
||||
max_turns=self.valves.MAX_TURNS,
|
||||
resume_session_id=_chat_sessions.get(chat_id),
|
||||
oauth_token=self.valves.CLAUDE_CODE_OAUTH_TOKEN or None,
|
||||
api_key=self.valves.ANTHROPIC_API_KEY or None,
|
||||
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
|
||||
workdir=workdir,
|
||||
system_prompt_append=_extract_system_prompt(body),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue