diff --git a/anthropic-proxy/Dockerfile b/anthropic-proxy/Dockerfile new file mode 100644 index 0000000..ea9421b --- /dev/null +++ b/anthropic-proxy/Dockerfile @@ -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"] diff --git a/anthropic-proxy/entrypoint.sh b/anthropic-proxy/entrypoint.sh new file mode 100644 index 0000000..a4c552a --- /dev/null +++ b/anthropic-proxy/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 < 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), )