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]
|
allowed_tools: List[str]
|
||||||
max_turns: int
|
max_turns: int
|
||||||
resume_session_id: Optional[str]
|
resume_session_id: Optional[str]
|
||||||
oauth_token: Optional[str]
|
# URL of the credential-injecting proxy the sandbox should route Claude API
|
||||||
api_key: Optional[str]
|
# 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
|
workdir: str
|
||||||
system_prompt_append: Optional[str]
|
system_prompt_append: Optional[str]
|
||||||
|
|
||||||
|
|
@ -264,10 +266,19 @@ _SANDBOX_ENV_NOTE = (
|
||||||
|
|
||||||
def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str:
|
def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str:
|
||||||
env_parts: List[str] = []
|
env_parts: List[str] = []
|
||||||
if cfg.oauth_token:
|
# Route all Anthropic API calls through the credential-injecting proxy.
|
||||||
env_parts.append(f"CLAUDE_CODE_OAUTH_TOKEN={shlex.quote(cfg.oauth_token)}")
|
# Claude Code honours ANTHROPIC_BASE_URL; the proxy adds the real
|
||||||
elif cfg.api_key:
|
# credential before forwarding to api.anthropic.com. No token in env or
|
||||||
env_parts.append(f"ANTHROPIC_API_KEY={shlex.quote(cfg.api_key)}")
|
# 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
|
# `claude --dangerously-skip-permissions` still refuses root unless told
|
||||||
# it's sandboxed. open-terminal runs processes as the per-user UID so this
|
# it's sandboxed. open-terminal runs processes as the per-user UID so this
|
||||||
# rarely matters, but setting it is harmless.
|
# 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."
|
"the end user is then conveyed via the X-User-Id header."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
ANTHROPIC_API_KEY: str = Field(
|
ANTHROPIC_BASE_URL: str = Field(
|
||||||
default="",
|
default="http://anthropic-proxy:8081",
|
||||||
description="Anthropic API key (pay-per-token). Forwarded into the sandbox as an env var.",
|
|
||||||
)
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: str = Field(
|
|
||||||
default="",
|
|
||||||
description=(
|
description=(
|
||||||
"Claude subscription OAuth token. Takes priority over "
|
"Credential-injecting proxy the sandbox routes Anthropic "
|
||||||
"ANTHROPIC_API_KEY — don't share with untrusted users (per "
|
"traffic through. The real ANTHROPIC_API_KEY / "
|
||||||
"Anthropic's subscription terms)."
|
"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")
|
MODEL: str = Field(default="claude-haiku-4-5")
|
||||||
|
|
@ -684,8 +694,7 @@ class Pipe:
|
||||||
allowed_tools=allowed_tools,
|
allowed_tools=allowed_tools,
|
||||||
max_turns=self.valves.MAX_TURNS,
|
max_turns=self.valves.MAX_TURNS,
|
||||||
resume_session_id=_chat_sessions.get(chat_id),
|
resume_session_id=_chat_sessions.get(chat_id),
|
||||||
oauth_token=self.valves.CLAUDE_CODE_OAUTH_TOKEN or None,
|
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
|
||||||
api_key=self.valves.ANTHROPIC_API_KEY or None,
|
|
||||||
workdir=workdir,
|
workdir=workdir,
|
||||||
system_prompt_append=_extract_system_prompt(body),
|
system_prompt_append=_extract_system_prompt(body),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue