mirror of
https://github.com/motiful/cc-gateway.git
synced 2026-08-28 18:53:10 +08:00
feat: v0.2.0 — zero-login clients, instant startup, billing header strip
Major overhaul for usability and security: - Client launcher (ccg): install/hijack/release/status/help subcommands, supports zsh/bash/fish, coexists with native claude - Auth via x-api-key header (matches how CC sends ANTHROPIC_API_KEY) - Strip billing header entirely instead of rewriting hash — eliminates detectable fingerprint and enables 99.98% prompt cache hit rate - Use existing access token on startup (zero network call), auto-refresh only when expired - Proxy support (HTTPS_PROXY/HTTP_PROXY) for outbound connections - Path rewriting scoped to <system-reminder> tags only — no longer corrupts user message content - Admin tooling: quick-setup.sh, admin-setup.sh, add-client.sh - Connection-level request logging - Docker healthcheck, TLS auto-generation for remote deployment - 16 tests passing, stale scripts removed, dead code cleaned up Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ dist/
|
||||
.env
|
||||
config.yaml
|
||||
certs/
|
||||
clients/
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="#client-setup">Client Setup</a> ·
|
||||
<a href="#add-clients">Add Clients</a> ·
|
||||
<a href="#what-gets-rewritten">What Gets Rewritten</a> ·
|
||||
<a href="#clash-rules">Clash Rules</a>
|
||||
<a href="#deployment">Deployment</a> ·
|
||||
<a href="#changelog">Changelog</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -42,86 +43,94 @@ CC Gateway is a reverse proxy that sits between Claude Code and the Anthropic AP
|
||||
|
||||
- **Full identity rewrite** — device ID, email, session metadata, and the `user_id` JSON blob in every API request are normalized to one canonical identity
|
||||
- **40+ environment dimensions replaced** — platform, architecture, Node.js version, terminal, package managers, runtimes, CI flags, deployment environment — the entire `env` object is swapped, not patched
|
||||
- **System prompt sanitization** — the `<env>` block injected into every prompt (Platform, Shell, OS Version, working directory) is rewritten to match the canonical profile, preventing cross-reference detection between telemetry and prompt content
|
||||
- **System prompt sanitization** — the `<env>` block injected into every prompt (Platform, Shell, OS Version, working directory) is rewritten to match the canonical profile
|
||||
- **Billing header stripped** — the `x-anthropic-billing-header` (which contains a per-session fingerprint hash) is removed entirely, consistent with the official `CLAUDE_CODE_ATTRIBUTION_HEADER=false` toggle. This also enables [cross-session prompt cache sharing](https://github.com/anthropics/claude-code/issues/40652), reducing system prompt costs by ~85%
|
||||
- **Process metrics normalization** — physical RAM (`constrainedMemory`), heap size, and RSS are masked to canonical values so hardware differences don't leak
|
||||
- **Centralized OAuth** — the gateway manages token refresh internally; client machines never contact `platform.claude.com` and never need a browser login
|
||||
- **Zero-login client setup** — clients receive a single launcher script. No browser OAuth, no `~/.zshrc` changes, no config files
|
||||
- **Centralized OAuth** — the gateway manages token refresh internally; client machines never contact `platform.claude.com`
|
||||
- **Instant startup** — gateway uses your existing access token on launch. No network call until the token actually expires
|
||||
- **Proxy-aware** — supports `HTTPS_PROXY` / `HTTP_PROXY` env vars for outbound connections (Clash, V2Ray, etc.)
|
||||
- **Telemetry leak prevention** — strips `baseUrl` and `gateway` fields that would reveal proxy usage in analytics events
|
||||
- **Three-layer defense architecture** — env vars (voluntary routing) + Clash rules (network-level blocking) + gateway rewriting (identity normalization)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install and configure
|
||||
One command. Requires Node.js 22+ and an existing Claude Code login on this machine.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/motiful/cc-gateway.git
|
||||
cd cc-gateway
|
||||
npm install
|
||||
|
||||
# Generate canonical identity
|
||||
npm run generate-identity
|
||||
# Generate a client token
|
||||
npm run generate-token my-machine
|
||||
|
||||
# Configure
|
||||
cp config.example.yaml config.yaml
|
||||
# Edit config.yaml: paste device_id, client token, and OAuth refresh_token
|
||||
bash scripts/quick-setup.sh
|
||||
```
|
||||
|
||||
### 2. Extract OAuth token (on a machine that has logged into Claude Code)
|
||||
This will:
|
||||
1. Extract your OAuth credentials from macOS Keychain (access token + refresh token)
|
||||
2. Generate a canonical device identity and client token
|
||||
3. Write `config.yaml`
|
||||
4. Generate a client launcher at `./clients/cc-<hostname>`
|
||||
5. Start the gateway on `http://localhost:8443`
|
||||
|
||||
### Use it
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
bash scripts/extract-token.sh
|
||||
# Copies refresh_token from macOS Keychain → paste into config.yaml
|
||||
./clients/cc-<hostname>
|
||||
```
|
||||
|
||||
### 3. Start the gateway
|
||||
That's it. Claude Code launches, traffic routes through the gateway. No env vars to set, no files to edit.
|
||||
|
||||
### Behind a proxy?
|
||||
|
||||
```bash
|
||||
# Development (no TLS)
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
npm run build && npm start
|
||||
|
||||
# Docker
|
||||
docker-compose up -d
|
||||
HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/quick-setup.sh
|
||||
```
|
||||
|
||||
### 4. Verify
|
||||
The gateway will route all outbound traffic (API calls + token refresh) through your proxy.
|
||||
|
||||
## Add Clients
|
||||
|
||||
Each person gets their own launcher script with a unique token. The admin generates it:
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8443/_health
|
||||
|
||||
# Rewrite verification (shows before/after diff)
|
||||
curl -H "Authorization: Bearer <your-token>" http://localhost:8443/_verify
|
||||
bash scripts/add-client.sh alice
|
||||
bash scripts/add-client.sh bob
|
||||
```
|
||||
|
||||
## Client Setup
|
||||
This creates `./clients/cc-alice` and `./clients/cc-bob`. Send each file to the respective person.
|
||||
|
||||
Add these environment variables on each client machine. No browser login needed.
|
||||
### Client setup (what you tell them)
|
||||
|
||||
```bash
|
||||
# Route all Claude Code traffic through the gateway
|
||||
export ANTHROPIC_BASE_URL="https://gateway.your-domain.com:8443"
|
||||
|
||||
# Disable side-channel telemetry (Datadog, GrowthBook, version checks)
|
||||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
|
||||
# Skip browser OAuth — gateway handles authentication
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="gateway-managed"
|
||||
|
||||
# Authenticate to the gateway
|
||||
export ANTHROPIC_CUSTOM_HEADERS="Proxy-Authorization: Bearer YOUR_TOKEN"
|
||||
chmod +x cc-alice
|
||||
./cc-alice install # installs as 'ccg' command
|
||||
ccg # start Claude Code through gateway
|
||||
```
|
||||
|
||||
Or run the interactive setup script:
|
||||
That's it. All Claude arguments work: `ccg --print "hello"`, `ccg --resume`, etc.
|
||||
|
||||
### Optional: make `claude` go through gateway too
|
||||
|
||||
```bash
|
||||
bash scripts/client-setup.sh
|
||||
ccg hijack # alias claude → ccg (new terminals auto-apply)
|
||||
claude # now goes through gateway
|
||||
ccg release # undo — restore native claude
|
||||
```
|
||||
|
||||
Then start Claude Code normally — `claude` — no login prompt, traffic routes through the gateway automatically.
|
||||
### All commands
|
||||
|
||||
```
|
||||
ccg Start Claude Code through gateway
|
||||
ccg install Install as 'ccg' system command
|
||||
ccg uninstall Remove 'ccg' and clean up
|
||||
ccg hijack Make 'claude' also go through gateway
|
||||
ccg release Restore 'claude' to native
|
||||
ccg native [args] Run native claude once (bypass gateway)
|
||||
ccg status Show gateway connection and hijack status
|
||||
ccg help Show help
|
||||
```
|
||||
|
||||
`ccg` and `claude` coexist by default. Hijack is opt-in and reversible. Supports zsh, bash, and fish.
|
||||
|
||||
## What Gets Rewritten
|
||||
|
||||
@@ -133,40 +142,85 @@ Then start Claude Code normally — `claude` — no login prompt, traffic routes
|
||||
| **Process** | `constrainedMemory` (physical RAM) | → canonical value |
|
||||
| | `rss`, `heapTotal`, `heapUsed` | → randomized in realistic range |
|
||||
| **Headers** | `User-Agent` | → canonical CC version |
|
||||
| | `Authorization` | → real OAuth token (injected by gateway) |
|
||||
| | `x-anthropic-billing-header` | → canonical fingerprint |
|
||||
| | `x-api-key` | → real OAuth token (injected by gateway) |
|
||||
| | `x-anthropic-billing-header` | → stripped |
|
||||
| **Prompt text** | `Platform`, `Shell`, `OS Version` | → canonical values |
|
||||
| | `Working directory` | → canonical path |
|
||||
| | `/Users/xxx/`, `/home/xxx/` | → canonical home prefix |
|
||||
| **Billing** | `x-anthropic-billing-header` system block | → stripped entirely |
|
||||
| **Leak fields** | `baseUrl` (ANTHROPIC_BASE_URL) | → stripped |
|
||||
| | `gateway` (provider detection) | → stripped |
|
||||
|
||||
## Clash Rules
|
||||
## Deployment
|
||||
|
||||
Clash acts as a network-level safety net. Even if Claude Code bypasses env vars or adds new hardcoded endpoints in a future update, Clash blocks direct connections.
|
||||
### Local (development)
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- DOMAIN,gateway.your-domain.com,DIRECT # Allow gateway
|
||||
- DOMAIN-SUFFIX,anthropic.com,REJECT # Block direct API
|
||||
- DOMAIN-SUFFIX,claude.com,REJECT # Block OAuth
|
||||
- DOMAIN-SUFFIX,claude.ai,REJECT # Block OAuth
|
||||
- DOMAIN-SUFFIX,datadoghq.com,REJECT # Block telemetry
|
||||
```bash
|
||||
npm run dev # tsx watch, auto-reload
|
||||
```
|
||||
|
||||
See [`clash-rules.yaml`](clash-rules.yaml) for the full template.
|
||||
### Docker (production)
|
||||
|
||||
```bash
|
||||
bash scripts/admin-setup.sh
|
||||
```
|
||||
|
||||
This interactive script:
|
||||
1. Extracts OAuth credentials
|
||||
2. Generates config + first client launcher
|
||||
3. Builds and starts the Docker container
|
||||
4. Asks for the gateway address clients should connect to
|
||||
|
||||
After setup, add more clients with:
|
||||
|
||||
```bash
|
||||
bash scripts/add-client.sh <name>
|
||||
# Restart to pick up new tokens:
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Multi-machine deployment
|
||||
|
||||
```
|
||||
Mac-A ──┐
|
||||
Mac-B ──┼──→ gateway-server:8443 ──→ api.anthropic.com
|
||||
Mac-C ──┘
|
||||
```
|
||||
|
||||
**Important:** All machines — including the admin — should use the gateway. Direct connections from the admin machine would create a second device fingerprint visible to Anthropic.
|
||||
|
||||
For remote deployment, generate TLS certificates:
|
||||
|
||||
```bash
|
||||
mkdir certs
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-days 365 -nodes -subj "/CN=cc-gateway"
|
||||
```
|
||||
|
||||
Uncomment the `tls` section in `config.yaml`, then generate client launchers pointing to the server address:
|
||||
|
||||
```bash
|
||||
bash scripts/add-client.sh alice "" <gateway-ip>:8443 https
|
||||
```
|
||||
|
||||
### Alternative: Tailscale (zero config networking)
|
||||
|
||||
If all devices have Tailscale installed, run the gateway on any machine in the mesh. No TLS needed (Tailscale encrypts the tunnel), no public IP needed, no port forwarding.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client machines CC Gateway Anthropic
|
||||
┌────────────┐ ┌──────────────────┐
|
||||
│ Claude Code │── ANTHROPIC_ ────│ Auth: Bearer │
|
||||
│ + env vars │ BASE_URL │ OAuth: auto- │
|
||||
│ + Clash │ │ refresh │──── single ────▶ api.anthropic.com
|
||||
│ (blocks │ │ Rewrite: all │ identity
|
||||
│ direct) │ │ identity │
|
||||
└────────────┘ │ Stream: SSE │
|
||||
│ ./cc-alice │── ANTHROPIC_ ────│ Auth: x-api-key │
|
||||
│ (launcher) │ BASE_URL │ OAuth: auto- │
|
||||
│ + env vars │ │ refresh │──── single ────▶ api.anthropic.com
|
||||
│ │ │ Rewrite: all │ identity
|
||||
│ │ │ identity │
|
||||
└────────────┘ │ Strip: billing │
|
||||
│ header │
|
||||
│ Stream: SSE │
|
||||
│ passthrough │
|
||||
└──────────────────┘
|
||||
│
|
||||
@@ -179,21 +233,78 @@ Client machines CC Gateway Anthropic
|
||||
|
||||
| Layer | Mechanism | What it prevents |
|
||||
|-------|-----------|-----------------|
|
||||
| Env vars | `ANTHROPIC_BASE_URL` + `DISABLE_NONESSENTIAL` + `OAUTH_TOKEN` | CC voluntarily routes to gateway, disables side channels, skips browser login |
|
||||
| Clash | Domain-based REJECT rules | Any accidental or future direct connections to Anthropic |
|
||||
| Launcher env vars | `ANTHROPIC_BASE_URL` + `DISABLE_NONESSENTIAL` + `ATTRIBUTION_HEADER=false` | CC voluntarily routes to gateway, disables side channels, skips billing hash |
|
||||
| Clash (optional) | Domain-based REJECT rules | Any accidental or future direct connections to Anthropic |
|
||||
| Gateway | Body + header + prompt rewriting | All 40+ fingerprint dimensions normalized to one device |
|
||||
|
||||
## OAuth Lifecycle
|
||||
|
||||
The gateway manages the full OAuth token lifecycle:
|
||||
|
||||
1. **Startup** — uses the existing access token from your keychain. Zero network calls.
|
||||
2. **Auto-refresh** — 5 minutes before expiry, the gateway silently refreshes via `platform.claude.com`.
|
||||
3. **Continuous** — refresh tokens rotate automatically. The gateway runs indefinitely without admin intervention.
|
||||
4. **Failure recovery** — if a refresh fails, retries every 30 seconds. Only a refresh token expiry (rare, months) requires re-running `extract-token.sh`.
|
||||
|
||||
Clients never contact `platform.claude.com`. They send requests to the gateway with their client token; the gateway injects the real OAuth token before forwarding upstream.
|
||||
|
||||
## Clash Rules
|
||||
|
||||
Optional network-level safety net. Even if Claude Code bypasses env vars or adds new hardcoded endpoints in a future update, Clash blocks direct connections.
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- DOMAIN,gateway.your-domain.com,DIRECT # Allow gateway
|
||||
- DOMAIN-SUFFIX,anthropic.com,REJECT # Block direct API
|
||||
- DOMAIN-SUFFIX,claude.com,REJECT # Block OAuth
|
||||
- DOMAIN-SUFFIX,claude.ai,REJECT # Block OAuth
|
||||
- DOMAIN-SUFFIX,datadoghq.com,REJECT # Block telemetry
|
||||
```
|
||||
|
||||
See [`clash-rules.yaml`](clash-rules.yaml) for the full template.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **MCP servers** — `mcp-proxy.anthropic.com` is hardcoded and does not follow `ANTHROPIC_BASE_URL`. If clients use official MCP servers, those requests bypass the gateway. Use Clash to block this domain if MCP is not needed.
|
||||
- **CC updates** — New Claude Code versions may introduce new telemetry fields or endpoints. Monitor Clash REJECT logs for unexpected connection attempts after upgrades.
|
||||
- **Token lifecycle** — The gateway auto-refreshes the OAuth access token. If the underlying refresh token expires (rare), re-run `extract-token.sh` on the admin machine.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.2.0 (2026-04-02)
|
||||
|
||||
**Billing header strategy overhaul**
|
||||
- Stripped the `x-anthropic-billing-header` entirely (system prompt block + HTTP header) instead of rewriting the hash. This is consistent with the official `CLAUDE_CODE_ATTRIBUTION_HEADER=false` env var and enables cross-session prompt cache sharing (~85% cost reduction on system prompt).
|
||||
- The CCH hash algorithm (reverse-engineered from `cli.js`) is implemented as a fallback but not active by default.
|
||||
|
||||
**Zero-login client setup**
|
||||
- New `add-client.sh` generates self-contained launcher scripts (`./clients/cc-<name>`). Clients run one file — no `~/.zshrc` changes, no config files, no browser login.
|
||||
- Launcher uses `ANTHROPIC_API_KEY` for gateway auth instead of the fragile `CLAUDE_CODE_OAUTH_TOKEN` + `ANTHROPIC_CUSTOM_HEADERS` approach.
|
||||
|
||||
**Instant gateway startup**
|
||||
- OAuth now uses the existing access token from Keychain on launch. No network call until the token actually needs refreshing.
|
||||
- `config.yaml` supports `access_token` + `expires_at` fields alongside `refresh_token`.
|
||||
|
||||
**Proxy support**
|
||||
- Gateway respects `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` env vars for all outbound connections (API calls + token refresh).
|
||||
|
||||
**Observability**
|
||||
- Connection-level request logging: every inbound request is logged with client IP before auth, and client name after auth.
|
||||
|
||||
**Admin tooling**
|
||||
- `admin-setup.sh` — interactive Docker deployment with credential extraction and client generation.
|
||||
- `quick-setup.sh` — one-command local setup that extracts full credentials (access + refresh + expiry).
|
||||
|
||||
### v0.1.0 (2026-04-01)
|
||||
|
||||
Initial release. Identity rewriting, environment normalization, centralized OAuth, SSE passthrough.
|
||||
|
||||
## References
|
||||
|
||||
This project builds on:
|
||||
|
||||
- [Claude Code 封号机制深度探查报告](https://bytedance.larkoffice.com/docx/E2JudVzf7oCNfhxyxaQcZIW1n0g) — Reverse-engineering analysis of Claude Code's 640+ telemetry events, 40+ fingerprint dimensions, and ban detection mechanisms
|
||||
- [cc-cache-audit](https://github.com/motiful/cc-cache-audit) — A/B test proving the billing header breaks prompt cache sharing, with the one-line fix
|
||||
- [instructkr/claude-code](https://github.com/instructkr/claude-code) — Deobfuscated Claude Code source used for the telemetry audit
|
||||
|
||||
## Star History
|
||||
@@ -208,21 +319,34 @@ This project builds on:
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Why This Exists
|
||||
|
||||
I pay Anthropic $200/month. I have for almost a year.
|
||||
|
||||
I own a laptop, a desktop, and a tablet. Three devices, one person, one subscription. I logged into a fourth device and my account was banned. No warning. No explanation. No refund. No way to export my conversation history. No customer support to contact.
|
||||
|
||||
I'm not in the US. For non-US subscribers, there is no appeals process. The ban is permanent and silent.
|
||||
|
||||
This project is not a hack. It is not a crack. It does not bypass rate limits, share accounts, or steal service. It is a reverse proxy that makes my own devices — devices I already paid for access to — present a consistent identity to an API that I already pay for.
|
||||
|
||||
The technical approach is conservative by design:
|
||||
|
||||
- **Billing header**: stripped using the same official env var (`CLAUDE_CODE_ATTRIBUTION_HEADER=false`) that Anthropic built into their own code. Thousands of legitimate users already have this set.
|
||||
- **Identity normalization**: all devices report the same device ID, email, and environment. This is indistinguishable from one person using one machine.
|
||||
- **Fixed IP**: the gateway routes all traffic through a single static IP. Anthropic sees one device, one location, one user.
|
||||
- **No evasion**: we don't fake locations, rotate IPs, or circumvent rate limits. If Anthropic's detection looks at this traffic, it looks normal — because it IS normal. One person using their subscription.
|
||||
|
||||
If Anthropic offered a way to manage multiple devices — a device dashboard, a family plan, a per-seat enterprise option — this tool would not need to exist. They don't. So it does.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for educational and research purposes only.
|
||||
It demonstrates API telemetry normalization at the proxy layer.
|
||||
|
||||
- Do NOT use this to share accounts or violate Anthropic's Terms of Service
|
||||
- Do NOT use this for commercial purposes
|
||||
- The author is not responsible for any consequences of using this software
|
||||
- Use at your own risk
|
||||
|
||||
This project was created by a paying Claude Code subscriber ($200/month)
|
||||
who was banned without explanation while using multiple personal devices.
|
||||
It exists because Anthropic's risk controls disproportionately affect
|
||||
non-US subscribers with no avenue for appeal.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -236,9 +360,9 @@ non-US subscribers with no avenue for appeal.
|
||||
<!-- Badge references -->
|
||||
[license-shield]: https://img.shields.io/github/license/motiful/cc-gateway
|
||||
[license-url]: https://github.com/motiful/cc-gateway/blob/main/LICENSE
|
||||
[version-shield]: https://img.shields.io/badge/version-0.1.0--alpha-blue
|
||||
[version-shield]: https://img.shields.io/badge/version-0.2.0--alpha-blue
|
||||
[version-url]: https://github.com/motiful/cc-gateway/releases
|
||||
[tests-shield]: https://img.shields.io/badge/tests-13%20passed-brightgreen
|
||||
[tests-shield]: https://img.shields.io/badge/tests-16%20passed-brightgreen
|
||||
[tests-url]: https://github.com/motiful/cc-gateway/blob/main/tests/rewriter.test.ts
|
||||
[twitter-shield]: https://img.shields.io/badge/follow-%40whiletrue0x-1DA1F2?logo=x&logoColor=white
|
||||
[twitter-url]: https://x.com/whiletrue0x
|
||||
|
||||
+12
-12
@@ -1,29 +1,29 @@
|
||||
# CC Gateway Configuration
|
||||
# Copy to config.yaml and modify
|
||||
# Copy to config.yaml and modify, or use: bash scripts/quick-setup.sh
|
||||
|
||||
server:
|
||||
port: 8443
|
||||
# TLS cert/key paths (required for HTTPS)
|
||||
# TLS cert/key paths (required for remote deployment, not needed for localhost)
|
||||
# Generate self-signed: openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes
|
||||
tls:
|
||||
cert: ./certs/cert.pem
|
||||
key: ./certs/key.pem
|
||||
# tls:
|
||||
# cert: ./certs/cert.pem
|
||||
# key: ./certs/key.pem
|
||||
|
||||
# Upstream Anthropic API
|
||||
upstream:
|
||||
url: https://api.anthropic.com
|
||||
|
||||
# OAuth - gateway manages token lifecycle centrally
|
||||
# Step 1: On admin machine, run `claude` and do browser OAuth login
|
||||
# Step 2: Copy refresh_token from one of these locations:
|
||||
# macOS Keychain: security find-generic-password -s "~/.claude-credentials" -w | jq -r '.claudeAiOauth.refreshToken'
|
||||
# Fallback file: cat ~/.claude/.credentials.json | jq -r '.claudeAiOauth.refreshToken'
|
||||
# Step 3: Paste it here. Gateway auto-refreshes the access token.
|
||||
# Extract credentials from macOS Keychain:
|
||||
# security find-generic-password -a "$USER" -s "Claude Code-credentials" -w | python3 -c "import sys,json; d=json.load(sys.stdin)['claudeAiOauth']; print(f'access_token: {d[\"accessToken\"]}\nrefresh_token: {d[\"refreshToken\"]}\nexpires_at: {d[\"expiresAt\"]}')"
|
||||
# Or just run: bash scripts/quick-setup.sh (extracts automatically)
|
||||
oauth:
|
||||
access_token: "your-access-token-here"
|
||||
refresh_token: "your-refresh-token-here"
|
||||
expires_at: 0
|
||||
|
||||
# Authentication - each client gets a bearer token
|
||||
# Generate tokens: npm run generate-token
|
||||
# Authentication - each client gets a unique token
|
||||
# Generate tokens: bash scripts/add-client.sh <name>
|
||||
auth:
|
||||
tokens:
|
||||
- name: machine-a
|
||||
|
||||
@@ -7,6 +7,11 @@ services:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./certs:/app/certs:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:8443/_health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
Generated
+47
@@ -7,7 +7,9 @@
|
||||
"": {
|
||||
"name": "cc-gateway",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -468,6 +470,32 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
@@ -538,6 +566,25 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
|
||||
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "9.0.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-gateway",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "AI API identity gateway — reverse proxy that normalizes device fingerprints and telemetry for privacy-preserving API proxying",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "tsx tests/rewriter.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/bin/bash
|
||||
# Generate a launcher script for a client.
|
||||
# Usage: bash scripts/add-client.sh <client-name> [token] [gateway-addr] [scheme]
|
||||
#
|
||||
# If token/addr are omitted, generates a new token and uses localhost defaults.
|
||||
# scheme: "http" (default) or "https" (adds NODE_TLS_REJECT_UNAUTHORIZED=0 for self-signed certs)
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CLIENT_NAME="${1:?Usage: add-client.sh <client-name> [token] [gateway-addr] [scheme]}"
|
||||
CLIENT_TOKEN="${2:-$(openssl rand -hex 32)}"
|
||||
GATEWAY_ADDR="${3:-localhost:8443}"
|
||||
GATEWAY_SCHEME="${4:-http}"
|
||||
|
||||
CONFIG="config.yaml"
|
||||
CLIENTS_DIR="clients"
|
||||
mkdir -p "$CLIENTS_DIR"
|
||||
|
||||
# If token was auto-generated, append to config.yaml
|
||||
if [[ -z "$2" ]]; then
|
||||
python3 -c "
|
||||
import yaml, sys
|
||||
with open('$CONFIG') as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
cfg['auth']['tokens'].append({'name': '$CLIENT_NAME', 'token': '$CLIENT_TOKEN'})
|
||||
with open('$CONFIG', 'w') as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
|
||||
" 2>/dev/null || {
|
||||
echo "Note: Could not auto-update config.yaml. Add this manually:"
|
||||
echo " - name: ${CLIENT_NAME}"
|
||||
echo " token: ${CLIENT_TOKEN}"
|
||||
}
|
||||
echo "✓ Token added to config.yaml (restart gateway to pick up)"
|
||||
fi
|
||||
|
||||
# Generate the launcher script
|
||||
LAUNCHER="${CLIENTS_DIR}/cc-${CLIENT_NAME}"
|
||||
cat > "$LAUNCHER" <<'SCRIPT_HEAD'
|
||||
#!/bin/bash
|
||||
# CC Gateway Client Launcher
|
||||
#
|
||||
# Usage:
|
||||
# ./cc-<name> Start Claude Code through gateway
|
||||
# ./cc-<name> --print "hello" Single-shot mode
|
||||
# ./cc-<name> install Install as 'ccg' command system-wide
|
||||
# ./cc-<name> uninstall Remove 'ccg' and restore native claude
|
||||
# ./cc-<name> native Run native claude (bypass gateway, one-time)
|
||||
SCRIPT_HEAD
|
||||
|
||||
cat >> "$LAUNCHER" <<SCRIPT_VARS
|
||||
GATEWAY_URL="${GATEWAY_SCHEME}://${GATEWAY_ADDR}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN}"
|
||||
SCRIPT_VARS
|
||||
|
||||
# Add TLS bypass for self-signed certs (HTTPS mode only)
|
||||
if [[ "$GATEWAY_SCHEME" == "https" ]]; then
|
||||
cat >> "$LAUNCHER" <<'SCRIPT_TLS'
|
||||
|
||||
# Accept self-signed TLS cert from gateway
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
SCRIPT_TLS
|
||||
fi
|
||||
|
||||
cat >> "$LAUNCHER" <<'SCRIPT_BODY'
|
||||
|
||||
INSTALL_PATH="/usr/local/bin/ccg"
|
||||
SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
||||
# Detect shell RC file
|
||||
case "$SHELL" in
|
||||
*/zsh) RC_FILE="${ZDOTDIR:-$HOME}/.zshrc" ;;
|
||||
*/bash) RC_FILE="$HOME/.bashrc" ;;
|
||||
*/fish) RC_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;;
|
||||
*) RC_FILE="$HOME/.profile" ;;
|
||||
esac
|
||||
ALIAS_TAG="# cc-gateway alias"
|
||||
|
||||
# ── Subcommands ──
|
||||
|
||||
case "$1" in
|
||||
install)
|
||||
cp "$0" "$INSTALL_PATH" 2>/dev/null || sudo cp "$0" "$INSTALL_PATH"
|
||||
chmod +x "$INSTALL_PATH"
|
||||
echo "Installed as 'ccg'."
|
||||
echo ""
|
||||
echo " ccg Start Claude Code through gateway"
|
||||
echo " ccg hijack Make 'claude' also go through gateway"
|
||||
echo " ccg release Restore 'claude' to native"
|
||||
echo " ccg status Show gateway connection status"
|
||||
echo " ccg help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
uninstall)
|
||||
rm "$INSTALL_PATH" 2>/dev/null || sudo rm "$INSTALL_PATH"
|
||||
if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then
|
||||
sed -i.bak "/$ALIAS_TAG/d" "$RC_FILE"
|
||||
rm -f "${RC_FILE}.bak"
|
||||
fi
|
||||
echo "Removed. Native 'claude' restored."
|
||||
exit 0
|
||||
;;
|
||||
|
||||
hijack)
|
||||
if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then
|
||||
echo "Already active. Run 'ccg release' to undo."
|
||||
else
|
||||
if [[ "$SHELL" == */fish ]]; then
|
||||
echo "alias claude 'ccg' $ALIAS_TAG" >> "$RC_FILE"
|
||||
else
|
||||
echo "alias claude='ccg' $ALIAS_TAG" >> "$RC_FILE"
|
||||
fi
|
||||
echo "Done. 'claude' now goes through gateway."
|
||||
echo " New terminals: automatic."
|
||||
echo " This terminal: reopen or run: source $RC_FILE"
|
||||
echo " Undo anytime: ccg release"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
release)
|
||||
if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then
|
||||
sed -i.bak "/$ALIAS_TAG/d" "$RC_FILE"
|
||||
rm -f "${RC_FILE}.bak"
|
||||
# Unalias in current shell
|
||||
unalias claude 2>/dev/null
|
||||
echo "Done. 'claude' is back to native."
|
||||
else
|
||||
echo "Nothing to undo — 'claude' is already native."
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
native)
|
||||
shift
|
||||
exec command claude "$@"
|
||||
;;
|
||||
|
||||
status)
|
||||
echo "Gateway: $GATEWAY_URL"
|
||||
if grep -q "$ALIAS_TAG" "$RC_FILE" 2>/dev/null; then
|
||||
echo "Hijack: ON (claude → gateway)"
|
||||
else
|
||||
echo "Hijack: OFF (claude = native)"
|
||||
fi
|
||||
HEALTH=$(curl -sk --max-time 3 "${GATEWAY_URL}/_health" 2>/dev/null)
|
||||
if [[ -n "$HEALTH" ]]; then
|
||||
echo "Health: OK"
|
||||
else
|
||||
echo "Health: UNREACHABLE"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
help|--help|-h)
|
||||
echo "ccg — Claude Code Gateway Client"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " ccg Start Claude Code through gateway"
|
||||
echo " ccg [claude args] Pass any arguments to Claude Code"
|
||||
echo " ccg --print \"hi\" Single-shot mode"
|
||||
echo ""
|
||||
echo "Setup:"
|
||||
echo " ccg install Install as 'ccg' system command"
|
||||
echo " ccg uninstall Remove 'ccg' and clean up"
|
||||
echo ""
|
||||
echo "Routing:"
|
||||
echo " ccg hijack Make 'claude' go through gateway"
|
||||
echo " ccg release Restore 'claude' to native"
|
||||
echo " ccg native [args] Run native claude once (bypass gateway)"
|
||||
echo ""
|
||||
echo "Info:"
|
||||
echo " ccg status Show gateway and hijack status"
|
||||
echo " ccg help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Main: launch through gateway ──
|
||||
|
||||
# Check claude is installed
|
||||
if ! command -v claude &>/dev/null; then
|
||||
echo "Error: 'claude' not found. Install Claude Code first:"
|
||||
echo " npm install -g @anthropic-ai/claude-code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set env vars for this process only — nothing is written to disk
|
||||
export ANTHROPIC_API_KEY="$CLIENT_TOKEN"
|
||||
export ANTHROPIC_BASE_URL="$GATEWAY_URL"
|
||||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
export CLAUDE_CODE_ATTRIBUTION_HEADER=false
|
||||
|
||||
# Check gateway is reachable
|
||||
HEALTH=$(curl -sk --max-time 3 "${GATEWAY_URL}/_health" 2>/dev/null)
|
||||
if [[ -z "$HEALTH" ]]; then
|
||||
echo "Warning: Gateway at ${GATEWAY_URL} is not reachable."
|
||||
echo "Make sure the gateway is running."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Pass all arguments through to claude
|
||||
exec claude "$@"
|
||||
SCRIPT_BODY
|
||||
|
||||
chmod +x "$LAUNCHER"
|
||||
|
||||
echo "✓ Client launcher: ${LAUNCHER}"
|
||||
echo " Send this file to ${CLIENT_NAME}."
|
||||
echo " They run: chmod +x cc-${CLIENT_NAME} && ./cc-${CLIENT_NAME}"
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/bin/bash
|
||||
# Production deployment: generate config, TLS certs, build Docker, start gateway.
|
||||
# Usage: bash scripts/admin-setup.sh
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CONFIG="config.yaml"
|
||||
|
||||
# ── If config exists, just start ──
|
||||
if [[ -f "$CONFIG" ]]; then
|
||||
echo "config.yaml exists. Starting gateway..."
|
||||
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then
|
||||
docker compose up -d --build
|
||||
else
|
||||
echo "Docker not available, starting with Node..."
|
||||
npm run build && npm start
|
||||
fi
|
||||
echo ""
|
||||
echo "Gateway running. Add clients with:"
|
||||
echo " bash scripts/add-client.sh <name>"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== CC Gateway Admin Setup ==="
|
||||
echo ""
|
||||
|
||||
# ── 1. Extract OAuth credentials ──
|
||||
CREDS=$(security find-generic-password -a "$USER" -s "Claude Code-credentials" -w 2>/dev/null || true)
|
||||
if [[ -z "$CREDS" ]]; then
|
||||
CRED_FILE="$HOME/.claude/.credentials.json"
|
||||
if [[ -f "$CRED_FILE" ]]; then
|
||||
CREDS=$(cat "$CRED_FILE")
|
||||
else
|
||||
echo "Error: No Claude Code credentials found."
|
||||
echo "Run 'claude' and complete browser login first."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
eval "$(echo "$CREDS" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)['claudeAiOauth']
|
||||
print(f'ACCESS_TOKEN=\"{d[\"accessToken\"]}\"')
|
||||
print(f'REFRESH_TOKEN=\"{d[\"refreshToken\"]}\"')
|
||||
print(f'EXPIRES_AT={d.get(\"expiresAt\", 0)}')
|
||||
")"
|
||||
|
||||
if [[ -z "$REFRESH_TOKEN" ]]; then
|
||||
echo "Error: Could not extract tokens."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ OAuth credentials extracted"
|
||||
|
||||
# ── 2. Deployment mode ──
|
||||
echo ""
|
||||
echo "Deployment mode:"
|
||||
echo " 1) Public / LAN — clients connect over network (HTTPS, auto-generates TLS cert)"
|
||||
echo " 2) Tailscale/VPN — tunnel already encrypts traffic (HTTP, no cert needed)"
|
||||
echo ""
|
||||
read -p "Choose [1/2]: " DEPLOY_MODE
|
||||
DEPLOY_MODE="${DEPLOY_MODE:-1}"
|
||||
|
||||
# ── 3. Gateway address ──
|
||||
DEFAULT_IP=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "0.0.0.0")
|
||||
read -p "Gateway address for clients [${DEFAULT_IP}]: " GATEWAY_HOST
|
||||
GATEWAY_HOST="${GATEWAY_HOST:-${DEFAULT_IP}}"
|
||||
|
||||
# ── 4. TLS setup ──
|
||||
TLS_CONFIG=""
|
||||
GATEWAY_SCHEME="http"
|
||||
GATEWAY_PORT="8443"
|
||||
|
||||
if [[ "$DEPLOY_MODE" == "1" ]]; then
|
||||
GATEWAY_SCHEME="https"
|
||||
mkdir -p certs
|
||||
|
||||
if [[ -f certs/cert.pem && -f certs/key.pem ]]; then
|
||||
echo "✓ Existing TLS certs found in certs/"
|
||||
else
|
||||
echo "Generating self-signed TLS certificate..."
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-days 365 -nodes \
|
||||
-subj "/CN=${GATEWAY_HOST}" \
|
||||
-addext "subjectAltName=IP:${GATEWAY_HOST},DNS:${GATEWAY_HOST}" \
|
||||
2>/dev/null
|
||||
echo "✓ TLS cert generated (valid 365 days)"
|
||||
fi
|
||||
|
||||
TLS_CONFIG="
|
||||
tls:
|
||||
cert: ./certs/cert.pem
|
||||
key: ./certs/key.pem"
|
||||
fi
|
||||
|
||||
GATEWAY_URL="${GATEWAY_SCHEME}://${GATEWAY_HOST}:${GATEWAY_PORT}"
|
||||
|
||||
# ── 5. Generate identity + admin token ──
|
||||
DEVICE_ID=$(openssl rand -hex 32)
|
||||
ADMIN_TOKEN=$(openssl rand -hex 32)
|
||||
ADMIN_NAME=$(hostname -s)
|
||||
echo "✓ Device ID: ${DEVICE_ID:0:8}..."
|
||||
|
||||
# ── 6. Write config.yaml ──
|
||||
cat > "$CONFIG" <<YAML
|
||||
server:
|
||||
port: ${GATEWAY_PORT}${TLS_CONFIG}
|
||||
|
||||
upstream:
|
||||
url: https://api.anthropic.com
|
||||
|
||||
oauth:
|
||||
access_token: "${ACCESS_TOKEN}"
|
||||
refresh_token: "${REFRESH_TOKEN}"
|
||||
expires_at: ${EXPIRES_AT}
|
||||
|
||||
auth:
|
||||
tokens:
|
||||
- name: ${ADMIN_NAME}
|
||||
token: ${ADMIN_TOKEN}
|
||||
|
||||
identity:
|
||||
device_id: "${DEVICE_ID}"
|
||||
email: "user@example.com"
|
||||
|
||||
env:
|
||||
platform: darwin
|
||||
platform_raw: darwin
|
||||
arch: arm64
|
||||
node_version: $(node -v 2>/dev/null || echo "v22.0.0")
|
||||
terminal: iTerm2.app
|
||||
package_managers: npm,pnpm
|
||||
runtimes: node
|
||||
is_running_with_bun: false
|
||||
is_ci: false
|
||||
is_claude_ai_auth: true
|
||||
version: "2.1.81"
|
||||
version_base: "2.1.81"
|
||||
build_time: "2026-03-20T21:26:18Z"
|
||||
deployment_environment: unknown-darwin
|
||||
vcs: git
|
||||
|
||||
prompt_env:
|
||||
platform: darwin
|
||||
shell: zsh
|
||||
os_version: "Darwin $(uname -r)"
|
||||
working_dir: /Users/jack/projects
|
||||
|
||||
process:
|
||||
constrained_memory: 34359738368
|
||||
rss_range: [300000000, 500000000]
|
||||
heap_total_range: [40000000, 80000000]
|
||||
heap_used_range: [100000000, 200000000]
|
||||
|
||||
logging:
|
||||
level: info
|
||||
audit: true
|
||||
YAML
|
||||
|
||||
echo "✓ config.yaml created"
|
||||
|
||||
# ── 7. Generate admin launcher ──
|
||||
mkdir -p clients
|
||||
bash scripts/add-client.sh "${ADMIN_NAME}" "${ADMIN_TOKEN}" "${GATEWAY_HOST}:${GATEWAY_PORT}" "${GATEWAY_SCHEME}"
|
||||
echo ""
|
||||
|
||||
# ── 8. Start gateway ──
|
||||
echo "Starting gateway..."
|
||||
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then
|
||||
if docker compose up -d --build 2>&1; then
|
||||
echo "✓ Gateway running (Docker): ${GATEWAY_URL}"
|
||||
else
|
||||
echo ""
|
||||
echo "Docker build failed. If behind a proxy, configure Docker daemon:"
|
||||
echo ' ~/.docker/config.json → { "proxies": { "default": { "httpProxy": "http://127.0.0.1:7890", "httpsProxy": "http://127.0.0.1:7890" } } }'
|
||||
echo "Then retry: docker compose up -d --build"
|
||||
echo ""
|
||||
echo "Or skip Docker: HTTPS_PROXY=http://127.0.0.1:7890 npm run dev"
|
||||
fi
|
||||
else
|
||||
echo "Docker not available. Start with:"
|
||||
echo " npm run build && npm start"
|
||||
echo " # or: npm run dev"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo " Gateway: ${GATEWAY_URL}"
|
||||
echo " Admin launcher: ./clients/cc-${ADMIN_NAME}"
|
||||
echo " Health check: curl ${GATEWAY_URL}/_health"
|
||||
echo ""
|
||||
echo " Add more clients:"
|
||||
echo " bash scripts/add-client.sh alice"
|
||||
echo " bash scripts/add-client.sh bob"
|
||||
echo " Then send ./clients/cc-<name> to each user."
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CC Gateway Client Setup
|
||||
# Run this on each client machine to configure Claude Code to use the gateway.
|
||||
# Client machines NEVER contact Anthropic directly.
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== CC Gateway Client Setup ==="
|
||||
echo ""
|
||||
|
||||
read -p "Gateway URL (e.g., https://gateway.office.com:8443): " GATEWAY_URL
|
||||
read -p "Your bearer token: " BEARER_TOKEN
|
||||
|
||||
if [[ -z "$GATEWAY_URL" || -z "$BEARER_TOKEN" ]]; then
|
||||
echo "Error: Gateway URL and token are required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect shell config file
|
||||
if [[ -n "$ZSH_VERSION" ]] || [[ "$SHELL" == */zsh ]]; then
|
||||
RC_FILE="$HOME/.zshrc"
|
||||
elif [[ -n "$BASH_VERSION" ]] || [[ "$SHELL" == */bash ]]; then
|
||||
RC_FILE="$HOME/.bashrc"
|
||||
else
|
||||
RC_FILE="$HOME/.profile"
|
||||
fi
|
||||
|
||||
ENV_BLOCK="
|
||||
# === CC Gateway ===
|
||||
# Route all Claude Code API traffic through the gateway
|
||||
export ANTHROPIC_BASE_URL=\"$GATEWAY_URL\"
|
||||
# Disable all side-channel telemetry (Datadog, GrowthBook, updates)
|
||||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
# Placeholder token - gateway injects the real OAuth token
|
||||
export CLAUDE_CODE_OAUTH_TOKEN=\"gateway-managed\"
|
||||
# Gateway proxy auth - your personal access token
|
||||
export ANTHROPIC_CUSTOM_HEADERS=\"Proxy-Authorization: Bearer $BEARER_TOKEN\"
|
||||
# === End CC Gateway ==="
|
||||
|
||||
echo ""
|
||||
echo "Will add to: $RC_FILE"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " ANTHROPIC_BASE_URL=$GATEWAY_URL"
|
||||
echo " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1"
|
||||
echo " CLAUDE_CODE_OAUTH_TOKEN=gateway-managed"
|
||||
echo " ANTHROPIC_CUSTOM_HEADERS=Proxy-Authorization: Bearer <token>"
|
||||
echo ""
|
||||
echo "Effect:"
|
||||
echo " - All API traffic routes through gateway (no direct Anthropic contact)"
|
||||
echo " - Gateway injects real OAuth token (no browser login needed)"
|
||||
echo " - Telemetry side-channels disabled"
|
||||
echo ""
|
||||
|
||||
read -p "Continue? [y/N] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
sed -i.bak '/# === CC Gateway ===/,/# === End CC Gateway ===/d' "$RC_FILE" 2>/dev/null || true
|
||||
echo "$ENV_BLOCK" >> "$RC_FILE"
|
||||
echo ""
|
||||
echo "Done! Run: source $RC_FILE"
|
||||
echo ""
|
||||
echo "Then start Claude Code normally: claude"
|
||||
echo "(No login needed - gateway handles auth)"
|
||||
else
|
||||
echo "Aborted."
|
||||
fi
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# One-command setup: generates config.yaml, extracts OAuth, and starts the gateway.
|
||||
# Usage: bash scripts/quick-setup.sh
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CONFIG="config.yaml"
|
||||
|
||||
if [[ -f "$CONFIG" ]]; then
|
||||
echo "config.yaml already exists. Starting gateway..."
|
||||
exec npm run dev
|
||||
fi
|
||||
|
||||
echo "=== CC Gateway Quick Setup ==="
|
||||
echo ""
|
||||
|
||||
# 1. Generate identity + client token
|
||||
DEVICE_ID=$(openssl rand -hex 32)
|
||||
CLIENT_TOKEN=$(openssl rand -hex 32)
|
||||
CLIENT_NAME="${1:-whiletrue0x}"
|
||||
|
||||
# 2. Extract full OAuth credentials from macOS Keychain / fallback file
|
||||
CREDS=$(security find-generic-password -a "$USER" -s "Claude Code-credentials" -w 2>/dev/null || true)
|
||||
if [[ -z "$CREDS" ]]; then
|
||||
CRED_FILE="$HOME/.claude/.credentials.json"
|
||||
if [[ -f "$CRED_FILE" ]]; then
|
||||
CREDS=$(cat "$CRED_FILE")
|
||||
else
|
||||
echo "Error: No Claude Code credentials found."
|
||||
echo "Run 'claude' first and complete browser OAuth login, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract all three: access_token, refresh_token, expires_at
|
||||
eval "$(echo "$CREDS" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)['claudeAiOauth']
|
||||
print(f'ACCESS_TOKEN=\"{d[\"accessToken\"]}\"')
|
||||
print(f'REFRESH_TOKEN=\"{d[\"refreshToken\"]}\"')
|
||||
print(f'EXPIRES_AT={d.get(\"expiresAt\", 0)}')
|
||||
")"
|
||||
|
||||
if [[ -z "$REFRESH_TOKEN" ]]; then
|
||||
echo "Error: Could not extract tokens."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Write config.yaml
|
||||
cat > "$CONFIG" <<YAML
|
||||
server:
|
||||
port: 8443
|
||||
|
||||
upstream:
|
||||
url: https://api.anthropic.com
|
||||
|
||||
oauth:
|
||||
access_token: "${ACCESS_TOKEN}"
|
||||
refresh_token: "${REFRESH_TOKEN}"
|
||||
expires_at: ${EXPIRES_AT}
|
||||
|
||||
auth:
|
||||
tokens:
|
||||
- name: ${CLIENT_NAME}
|
||||
token: ${CLIENT_TOKEN}
|
||||
|
||||
identity:
|
||||
device_id: "${DEVICE_ID}"
|
||||
email: "user@example.com"
|
||||
|
||||
env:
|
||||
platform: darwin
|
||||
platform_raw: darwin
|
||||
arch: arm64
|
||||
node_version: $(node -v)
|
||||
terminal: iTerm2.app
|
||||
package_managers: npm,pnpm
|
||||
runtimes: node
|
||||
is_running_with_bun: false
|
||||
is_ci: false
|
||||
is_claude_ai_auth: true
|
||||
version: "2.1.81"
|
||||
version_base: "2.1.81"
|
||||
build_time: "2026-03-20T21:26:18Z"
|
||||
deployment_environment: unknown-darwin
|
||||
vcs: git
|
||||
|
||||
prompt_env:
|
||||
platform: darwin
|
||||
shell: zsh
|
||||
os_version: "Darwin $(uname -r)"
|
||||
working_dir: /Users/jack/projects
|
||||
|
||||
process:
|
||||
constrained_memory: 34359738368
|
||||
rss_range: [300000000, 500000000]
|
||||
heap_total_range: [40000000, 80000000]
|
||||
heap_used_range: [100000000, 200000000]
|
||||
|
||||
logging:
|
||||
level: info
|
||||
audit: true
|
||||
YAML
|
||||
|
||||
echo ""
|
||||
echo "config.yaml created."
|
||||
echo ""
|
||||
|
||||
# Generate client launcher
|
||||
mkdir -p clients
|
||||
bash scripts/add-client.sh "${CLIENT_NAME}" "${CLIENT_TOKEN}" "localhost:8443"
|
||||
|
||||
echo ""
|
||||
echo "Starting gateway..."
|
||||
echo ""
|
||||
|
||||
exec npm run dev
|
||||
@@ -15,6 +15,14 @@ export function initAuth(config: Config) {
|
||||
* Returns the token entry name (for audit logging) or null if unauthorized.
|
||||
*/
|
||||
export function authenticate(req: IncomingMessage): string | null {
|
||||
// CC with ANTHROPIC_API_KEY sends x-api-key header
|
||||
const apiKey = req.headers['x-api-key']
|
||||
if (apiKey && typeof apiKey === 'string') {
|
||||
const entry = tokenMap.get(apiKey)
|
||||
if (entry) return entry.name
|
||||
}
|
||||
|
||||
// Fallback: Bearer token in Authorization or Proxy-Authorization
|
||||
const authHeader = req.headers['proxy-authorization'] || req.headers['authorization']
|
||||
if (!authHeader || typeof authHeader !== 'string') return null
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export type Config = {
|
||||
tokens: TokenEntry[]
|
||||
}
|
||||
oauth: {
|
||||
access_token?: string
|
||||
refresh_token: string
|
||||
expires_at?: number
|
||||
}
|
||||
identity: {
|
||||
device_id: string
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ try {
|
||||
|
||||
log('info', 'CC Gateway starting...')
|
||||
|
||||
// Initialize OAuth first - gateway manages the token lifecycle
|
||||
await initOAuth(config.oauth.refresh_token)
|
||||
// Initialize OAuth — uses existing access token if valid, only refreshes when expired
|
||||
await initOAuth(config.oauth)
|
||||
|
||||
startProxy(config)
|
||||
} catch (err) {
|
||||
|
||||
+38
-14
@@ -1,5 +1,6 @@
|
||||
import { request as httpsRequest } from 'https'
|
||||
import { log } from './logger.js'
|
||||
import { getProxyAgent } from './proxy-agent.js'
|
||||
|
||||
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
||||
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
||||
@@ -20,24 +21,49 @@ type OAuthTokens = {
|
||||
let cachedTokens: OAuthTokens | null = null
|
||||
|
||||
/**
|
||||
* Initialize OAuth with a refresh token.
|
||||
* The gateway holds the refresh token and manages access token lifecycle.
|
||||
* Client machines never need to contact platform.claude.com.
|
||||
* Initialize OAuth.
|
||||
* If a valid access_token is provided, use it immediately — no network call.
|
||||
* Only refresh when the token is expired or about to expire.
|
||||
*/
|
||||
export async function initOAuth(refreshToken: string): Promise<void> {
|
||||
log('info', 'Refreshing OAuth token...')
|
||||
cachedTokens = await refreshOAuthToken(refreshToken)
|
||||
log('info', `OAuth token acquired, expires at ${new Date(cachedTokens.expiresAt).toISOString()}`)
|
||||
export async function initOAuth(oauth: {
|
||||
access_token?: string
|
||||
refresh_token: string
|
||||
expires_at?: number
|
||||
}): Promise<void> {
|
||||
const now = Date.now()
|
||||
const expiresAt = oauth.expires_at ?? 0
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
|
||||
// Auto-refresh 5 minutes before expiry
|
||||
scheduleRefresh(refreshToken)
|
||||
// Use existing access token if still valid (with 5-min buffer)
|
||||
if (oauth.access_token && expiresAt > now + fiveMinutes) {
|
||||
cachedTokens = {
|
||||
accessToken: oauth.access_token,
|
||||
refreshToken: oauth.refresh_token,
|
||||
expiresAt,
|
||||
}
|
||||
const remaining = Math.round((expiresAt - now) / 60_000)
|
||||
log('info', `Using existing access token (expires in ${remaining} min)`)
|
||||
scheduleRefresh(oauth.refresh_token)
|
||||
return
|
||||
}
|
||||
|
||||
// Token missing or expired — must refresh
|
||||
if (oauth.access_token) {
|
||||
log('info', 'Access token expired, refreshing...')
|
||||
} else {
|
||||
log('info', 'No access token provided, refreshing...')
|
||||
}
|
||||
|
||||
cachedTokens = await refreshOAuthToken(oauth.refresh_token)
|
||||
log('info', `OAuth token acquired, expires at ${new Date(cachedTokens.expiresAt).toISOString()}`)
|
||||
scheduleRefresh(oauth.refresh_token)
|
||||
}
|
||||
|
||||
function scheduleRefresh(refreshToken: string) {
|
||||
if (!cachedTokens) return
|
||||
|
||||
const msUntilExpiry = cachedTokens.expiresAt - Date.now()
|
||||
const refreshIn = Math.max(msUntilExpiry - 5 * 60 * 1000, 10_000) // 5 min before expiry, minimum 10s
|
||||
const refreshIn = Math.max(msUntilExpiry - 5 * 60 * 1000, 10_000)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
@@ -54,10 +80,6 @@ function scheduleRefresh(refreshToken: string) {
|
||||
}, refreshIn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current valid access token.
|
||||
* Returns null if no token available.
|
||||
*/
|
||||
export function getAccessToken(): string | null {
|
||||
if (!cachedTokens) return null
|
||||
if (Date.now() >= cachedTokens.expiresAt) {
|
||||
@@ -77,6 +99,7 @@ function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
|
||||
})
|
||||
|
||||
const url = new URL(TOKEN_URL)
|
||||
const agent = getProxyAgent()
|
||||
const req = httpsRequest(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
@@ -87,6 +110,7 @@ function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': String(Buffer.byteLength(body)),
|
||||
},
|
||||
...(agent && { agent }),
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import type { Agent } from 'https'
|
||||
import { log } from './logger.js'
|
||||
|
||||
let agent: Agent | null = null
|
||||
|
||||
const proxyUrl =
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.ALL_PROXY ||
|
||||
process.env.all_proxy
|
||||
|
||||
if (proxyUrl) {
|
||||
agent = new HttpsProxyAgent(proxyUrl)
|
||||
log('info', `Using proxy: ${proxyUrl}`)
|
||||
}
|
||||
|
||||
export function getProxyAgent(): Agent | null {
|
||||
return agent
|
||||
}
|
||||
+17
-6
@@ -8,6 +8,7 @@ import { authenticate, initAuth } from './auth.js'
|
||||
import { getAccessToken } from './oauth.js'
|
||||
import { rewriteBody, rewriteHeaders } from './rewriter.js'
|
||||
import { audit, log } from './logger.js'
|
||||
import { getProxyAgent } from './proxy-agent.js'
|
||||
|
||||
export function startProxy(config: Config) {
|
||||
initAuth(config)
|
||||
@@ -49,6 +50,9 @@ async function handleRequest(
|
||||
) {
|
||||
const method = req.method || 'GET'
|
||||
const path = req.url || '/'
|
||||
const clientIp = req.socket.remoteAddress || 'unknown'
|
||||
|
||||
log('info', `← ${method} ${path} from ${clientIp}`)
|
||||
|
||||
// Health check - no auth required
|
||||
if (path === '/_health') {
|
||||
@@ -84,11 +88,13 @@ async function handleRequest(
|
||||
const clientName = authenticate(req)
|
||||
if (!clientName) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Unauthorized - provide Bearer token in Authorization or Proxy-Authorization header' }))
|
||||
res.end(JSON.stringify({ error: 'Unauthorized - provide client token via x-api-key header' }))
|
||||
log('warn', `Unauthorized request: ${method} ${path}`)
|
||||
return
|
||||
}
|
||||
|
||||
log('info', `Client "${clientName}" → ${method} ${path}`)
|
||||
|
||||
// Get the real OAuth token (managed by gateway)
|
||||
const oauthToken = getAccessToken()
|
||||
if (!oauthToken) {
|
||||
@@ -120,12 +126,14 @@ async function handleRequest(
|
||||
config,
|
||||
)
|
||||
|
||||
// Inject the real OAuth token (replaces whatever the client sent)
|
||||
rewrittenHeaders['authorization'] = `Bearer ${oauthToken}`
|
||||
// Inject the real OAuth token via x-api-key (Anthropic uses this header for both
|
||||
// API keys and OAuth tokens, distinguished by prefix: sk-ant-api03- vs sk-ant-oat01-)
|
||||
rewrittenHeaders['x-api-key'] = oauthToken
|
||||
|
||||
// Forward to upstream
|
||||
const upstreamUrl = new URL(path, upstream)
|
||||
|
||||
const agent = getProxyAgent()
|
||||
const proxyReq = httpsRequest(
|
||||
upstreamUrl,
|
||||
{
|
||||
@@ -135,6 +143,7 @@ async function handleRequest(
|
||||
host: upstream.host,
|
||||
'content-length': String(body.length),
|
||||
},
|
||||
...(agent && { agent }),
|
||||
},
|
||||
(proxyRes) => {
|
||||
const status = proxyRes.statusCode || 502
|
||||
@@ -203,13 +212,15 @@ function buildVerificationPayload(config: Config) {
|
||||
_info: 'This shows how the gateway rewrites a sample request',
|
||||
before: {
|
||||
'metadata.user_id': JSON.parse(sampleInput.metadata.user_id),
|
||||
system_prompt_env: sampleInput.system[1].text,
|
||||
billing_header: sampleInput.system[0].text,
|
||||
system_prompt_env: sampleInput.system[1].text,
|
||||
system_block_count: sampleInput.system.length,
|
||||
},
|
||||
after: {
|
||||
'metadata.user_id': JSON.parse(rewritten.metadata.user_id),
|
||||
system_prompt_env: rewritten.system[1].text,
|
||||
billing_header: rewritten.system[0].text,
|
||||
billing_header: '(stripped)',
|
||||
system_prompt_env: rewritten.system[0]?.text ?? '(empty)',
|
||||
system_block_count: rewritten.system.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+114
-61
@@ -1,6 +1,38 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import type { Config } from './config.js'
|
||||
import { log } from './logger.js'
|
||||
|
||||
// ── CCH hash algorithm (reverse-engineered from cli.js) ──
|
||||
const CCH_SALT = '59cf53e54c78'
|
||||
const CCH_POSITIONS = [4, 7, 20]
|
||||
|
||||
// Fallback for non-message requests where no user message exists
|
||||
const FALLBACK_HASH = randomBytes(2).toString('hex').slice(0, 3)
|
||||
|
||||
function computeCCH(firstUserMessageText: string, version: string): string {
|
||||
const chars = CCH_POSITIONS.map(i => firstUserMessageText[i] || '0').join('')
|
||||
return createHash('sha256')
|
||||
.update(`${CCH_SALT}${chars}${version}`)
|
||||
.digest('hex')
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message text from API request messages array.
|
||||
* API format uses role: "user", content can be string or array of blocks.
|
||||
*/
|
||||
function extractFirstUserMessage(messages: any[]): string {
|
||||
if (!Array.isArray(messages)) return ''
|
||||
const firstUser = messages.find((m: any) => m.role === 'user')
|
||||
if (!firstUser) return ''
|
||||
if (typeof firstUser.content === 'string') return firstUser.content
|
||||
if (Array.isArray(firstUser.content)) {
|
||||
const textBlock = firstUser.content.find((b: any) => b.type === 'text')
|
||||
if (textBlock?.text) return textBlock.text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite identity fields in the API request body.
|
||||
*
|
||||
@@ -24,8 +56,6 @@ export function rewriteBody(body: Buffer, path: string, config: Config): Buffer
|
||||
} else if (path.includes('/event_logging/batch')) {
|
||||
rewriteEventBatch(parsed, config)
|
||||
} else if (path.includes('/policy_limits') || path.includes('/settings')) {
|
||||
// These are GET-like requests, usually no body to rewrite
|
||||
// But if they do have a body, rewrite identity fields
|
||||
rewriteGenericIdentity(parsed, config)
|
||||
}
|
||||
|
||||
@@ -34,7 +64,12 @@ export function rewriteBody(body: Buffer, path: string, config: Config): Buffer
|
||||
|
||||
/**
|
||||
* Rewrite /v1/messages request body.
|
||||
* Key field: metadata.user_id (JSON-stringified object with device_id, account_uuid, session_id)
|
||||
*
|
||||
* Order matters:
|
||||
* 1. Rewrite user message content (paths, etc.) FIRST
|
||||
* 2. Extract first user message from REWRITTEN content
|
||||
* 3. Compute hash from rewritten message (so it matches what server sees)
|
||||
* 4. Rewrite system prompt billing header using computed hash
|
||||
*/
|
||||
function rewriteMessagesBody(body: any, config: Config) {
|
||||
// Rewrite metadata.user_id
|
||||
@@ -49,61 +84,84 @@ function rewriteMessagesBody(body: any, config: Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite system prompt: billing header + environment block
|
||||
if (Array.isArray(body.system)) {
|
||||
for (let i = 0; i < body.system.length; i++) {
|
||||
const item = body.system[i]
|
||||
if (typeof item === 'string') {
|
||||
body.system[i] = rewritePromptText(item, config)
|
||||
} else if (item?.text) {
|
||||
item.text = rewritePromptText(item.text, config)
|
||||
}
|
||||
}
|
||||
} else if (typeof body.system === 'string') {
|
||||
body.system = rewritePromptText(body.system, config)
|
||||
}
|
||||
|
||||
// Rewrite user messages that may contain <system-reminder> with env info
|
||||
// Step 1: Rewrite <system-reminder> blocks in messages (injected by CC, not user content).
|
||||
// We do NOT rewrite general user message text — that would corrupt user intent.
|
||||
if (Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (typeof msg.content === 'string') {
|
||||
msg.content = rewritePromptText(msg.content, config)
|
||||
msg.content = rewriteSystemReminders(msg.content, config)
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block?.text) {
|
||||
block.text = rewritePromptText(block.text, config)
|
||||
block.text = rewriteSystemReminders(block.text, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Extract first user message from content (after system-reminder rewrite)
|
||||
const firstUserText = extractFirstUserMessage(body.messages)
|
||||
|
||||
// Step 3: Compute hash from rewritten message + canonical version
|
||||
const version = String(config.env.version)
|
||||
const hash = firstUserText ? computeCCH(firstUserText, version) : FALLBACK_HASH
|
||||
log('debug', `Computed CCH: ${hash} (from ${firstUserText.length} char message)`)
|
||||
|
||||
// Step 4: Strip billing header block from system prompt (cache optimization).
|
||||
// If client set CLAUDE_CODE_ATTRIBUTION_HEADER=false, the block won't exist.
|
||||
// This is the gateway-side safety net for clients that didn't set it.
|
||||
if (Array.isArray(body.system)) {
|
||||
// Remove system blocks that are purely the billing header
|
||||
body.system = body.system.filter((item: any) => {
|
||||
const text = typeof item === 'string' ? item : item?.text
|
||||
if (typeof text === 'string' && /^\s*x-anthropic-billing-header:/.test(text)) {
|
||||
log('debug', 'Stripped billing header block from system prompt')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Rewrite remaining system blocks (env, paths, etc.)
|
||||
for (let i = 0; i < body.system.length; i++) {
|
||||
const item = body.system[i]
|
||||
if (typeof item === 'string') {
|
||||
body.system[i] = rewritePromptText(item, config, hash)
|
||||
} else if (item?.text) {
|
||||
item.text = rewritePromptText(item.text, config, hash)
|
||||
}
|
||||
}
|
||||
} else if (typeof body.system === 'string') {
|
||||
// Strip inline billing header if embedded in a single string
|
||||
body.system = body.system.replace(/x-anthropic-billing-header:[^\n]+\n?/g, '')
|
||||
body.system = rewritePromptText(body.system, config, hash)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive text rewriter for system prompt and user messages.
|
||||
* Rewrites:
|
||||
* 1. Billing header (cc_version fingerprint)
|
||||
* 2. <env> block (Platform, Shell, OS Version, Working directory)
|
||||
* 3. Inline environment references (Primary working directory, etc.)
|
||||
* 4. Home directory paths that leak username
|
||||
*
|
||||
* When hash is provided, rewrites the billing header hash.
|
||||
* When hash is null, only rewrites env/path fields (used for messages before hash computation).
|
||||
*/
|
||||
function rewritePromptText(text: string, config: Config): string {
|
||||
function rewritePromptText(text: string, config: Config, hash: string | null): string {
|
||||
const pe = config.prompt_env
|
||||
if (!pe) return text
|
||||
|
||||
let result = text
|
||||
|
||||
// 1. Billing header fingerprint
|
||||
result = result.replace(
|
||||
/cc_version=[\d.]+\.[a-f0-9]{3}/g,
|
||||
`cc_version=${config.env.version}.000`,
|
||||
)
|
||||
// 1. Billing header fingerprint (only when hash is available)
|
||||
if (hash !== null) {
|
||||
result = result.replace(
|
||||
/cc_version=[\d.]+\.[a-f0-9]{3}/g,
|
||||
`cc_version=${config.env.version}.${hash}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. <env> block format (older prompt format):
|
||||
// Platform: linux
|
||||
// Shell: bash
|
||||
// OS Version: Linux 6.5.0-xxx
|
||||
// Working directory: /home/bob/project
|
||||
// 2. <env> block format:
|
||||
// Platform: linux → Platform: darwin
|
||||
// Shell: bash → Shell: zsh
|
||||
// OS Version: Linux 6.5.0-xxx → OS Version: Darwin 24.4.0
|
||||
result = result.replace(
|
||||
/Platform:\s*\S+/g,
|
||||
`Platform: ${pe.platform}`,
|
||||
@@ -118,15 +176,12 @@ function rewritePromptText(text: string, config: Config): string {
|
||||
)
|
||||
|
||||
// 3. Working directory / Primary working directory
|
||||
// Matches: "Working directory: /any/path" or "Primary working directory: /any/path"
|
||||
result = result.replace(
|
||||
/((?:Primary )?[Ww]orking directory:\s*)\/\S+/g,
|
||||
`$1${pe.working_dir}`,
|
||||
)
|
||||
|
||||
// 4. Home directory paths: /Users/xxx/, /home/xxx/, C:\Users\xxx\
|
||||
// Replace with canonical home path to prevent username leakage
|
||||
// Only replace the home prefix, keep the rest of the path
|
||||
// 4. Home directory paths: /Users/xxx/, /home/xxx/
|
||||
result = result.replace(
|
||||
/\/(?:Users|home)\/[^/\s]+\//g,
|
||||
`${pe.working_dir.match(/^\/[^/]+\/[^/]+\//)?.[0] || '/Users/user/'}`,
|
||||
@@ -135,6 +190,20 @@ function rewritePromptText(text: string, config: Config): string {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite only <system-reminder> blocks within message text.
|
||||
* These are injected by Claude Code (env info, git status, etc.) — not user-authored.
|
||||
* User-written text outside these tags is left untouched to preserve intent.
|
||||
*/
|
||||
function rewriteSystemReminders(text: string, config: Config): string {
|
||||
return text.replace(
|
||||
/(<system-reminder>)([\s\S]*?)(<\/system-reminder>)/g,
|
||||
(_match, open, content, close) => {
|
||||
return open + rewritePromptText(content, config, null) + close
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite /api/event_logging/batch payload.
|
||||
* Each event has event_data with identity, env, and process fields.
|
||||
@@ -161,10 +230,8 @@ function rewriteEventBatch(body: any, config: Config) {
|
||||
}
|
||||
|
||||
// Strip fields that leak gateway URL or proxy usage
|
||||
// logging.ts:143 adds baseUrl = ANTHROPIC_BASE_URL to every api event
|
||||
delete data.baseUrl
|
||||
delete data.base_url
|
||||
// detectGateway() adds gateway type if base URL matches known providers
|
||||
delete data.gateway
|
||||
|
||||
// Additional metadata - rewrite base64-encoded blob if present
|
||||
@@ -182,10 +249,6 @@ function rewriteGenericIdentity(body: any, config: Config) {
|
||||
if (body.email) body.email = config.identity.email
|
||||
}
|
||||
|
||||
/**
|
||||
* Build canonical env object from config.
|
||||
* Merges config env values into the expected structure.
|
||||
*/
|
||||
function buildCanonicalEnv(config: Config): Record<string, unknown> {
|
||||
return {
|
||||
platform: config.env.platform,
|
||||
@@ -212,12 +275,7 @@ function buildCanonicalEnv(config: Config): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate realistic process metrics.
|
||||
* Keeps uptime from the real event but normalizes hardware-identifying fields.
|
||||
*/
|
||||
function buildCanonicalProcess(original: any, config: Config): any {
|
||||
// If it's a base64 string, decode → rewrite → re-encode
|
||||
if (typeof original === 'string') {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(original, 'base64').toString('utf-8'))
|
||||
@@ -227,12 +285,9 @@ function buildCanonicalProcess(original: any, config: Config): any {
|
||||
return original
|
||||
}
|
||||
}
|
||||
|
||||
// If it's already an object
|
||||
if (typeof original === 'object') {
|
||||
return rewriteProcessFields(original, config)
|
||||
}
|
||||
|
||||
return original
|
||||
}
|
||||
|
||||
@@ -244,15 +299,12 @@ function rewriteProcessFields(proc: any, config: Config): any {
|
||||
rss: randomInRange(rss_range[0], rss_range[1]),
|
||||
heapTotal: randomInRange(heap_total_range[0], heap_total_range[1]),
|
||||
heapUsed: randomInRange(heap_used_range[0], heap_used_range[1]),
|
||||
// Keep uptime and cpuUsage as-is (these vary naturally)
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteAdditionalMetadata(original: string, config: Config): string {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(original, 'base64').toString('utf-8'))
|
||||
// rh (repo hash) is fine to keep - users work on different repos naturally
|
||||
// Strip fields that leak gateway URL
|
||||
delete decoded.baseUrl
|
||||
delete decoded.base_url
|
||||
delete decoded.gateway
|
||||
@@ -268,6 +320,7 @@ function randomInRange(min: number, max: number): number {
|
||||
|
||||
/**
|
||||
* Rewrite HTTP headers to canonical identity.
|
||||
* Uses the hash computed during body rewriting (getCurrentHash).
|
||||
*/
|
||||
export function rewriteHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
@@ -281,16 +334,16 @@ export function rewriteHeaders(
|
||||
const lower = key.toLowerCase()
|
||||
|
||||
// Skip hop-by-hop headers and auth (gateway injects the real OAuth token)
|
||||
if (['host', 'connection', 'proxy-authorization', 'proxy-connection', 'transfer-encoding', 'authorization'].includes(lower)) {
|
||||
if (['host', 'connection', 'proxy-authorization', 'proxy-connection', 'transfer-encoding', 'authorization', 'x-api-key'].includes(lower)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower === 'user-agent') {
|
||||
// Normalize to canonical version
|
||||
out[key] = `claude-code/${config.env.version} (external, cli)`
|
||||
} else if (lower === 'x-anthropic-billing-header') {
|
||||
// Rewrite billing header
|
||||
out[key] = v.replace(/cc_version=[\d.]+\.[a-f0-9]{3}/g, `cc_version=${config.env.version}.000`)
|
||||
// Strip billing header entirely — consistent with CLAUDE_CODE_ATTRIBUTION_HEADER=false
|
||||
// This also maximizes cross-session prompt cache sharing
|
||||
continue
|
||||
} else {
|
||||
out[key] = v
|
||||
}
|
||||
|
||||
+37
-4
@@ -113,16 +113,32 @@ test('rewrites working directory path', () => {
|
||||
assert.ok(!result.system.includes('/home/bob/'), 'Original path should be replaced')
|
||||
})
|
||||
|
||||
test('rewrites billing header fingerprint', () => {
|
||||
test('strips billing header from system prompt (string format)', () => {
|
||||
const body = {
|
||||
system: 'cc_version=2.1.81.a1b; cc_entrypoint=cli;',
|
||||
system: 'x-anthropic-billing-header: cc_version=2.1.81.a1b; cc_entrypoint=cli; cch=00000;\nOther content here.',
|
||||
messages: [],
|
||||
}
|
||||
const result = JSON.parse(
|
||||
rewriteBody(Buffer.from(JSON.stringify(body)), '/v1/messages', config).toString(),
|
||||
)
|
||||
assert.ok(result.system.includes('cc_version=2.1.81.000'))
|
||||
assert.ok(!result.system.includes('.a1b'))
|
||||
assert.ok(!result.system.includes('billing-header'), 'Billing header should be stripped')
|
||||
assert.ok(!result.system.includes('cc_version'), 'cc_version should be stripped')
|
||||
assert.ok(result.system.includes('Other content'), 'Non-billing content should remain')
|
||||
})
|
||||
|
||||
test('strips billing header from system prompt (array format)', () => {
|
||||
const body = {
|
||||
system: [
|
||||
{ type: 'text', text: 'x-anthropic-billing-header: cc_version=2.1.81.a1b; cc_entrypoint=cli;' },
|
||||
{ type: 'text', text: 'Platform: linux\nShell: bash' },
|
||||
],
|
||||
messages: [],
|
||||
}
|
||||
const result = JSON.parse(
|
||||
rewriteBody(Buffer.from(JSON.stringify(body)), '/v1/messages', config).toString(),
|
||||
)
|
||||
assert.equal(result.system.length, 1, 'Billing header block should be removed')
|
||||
assert.ok(result.system[0].text.includes('Platform: darwin'), 'Remaining block should be rewritten')
|
||||
})
|
||||
|
||||
test('rewrites home paths in user messages with system-reminder', () => {
|
||||
@@ -269,6 +285,23 @@ test('strips proxy-authorization header', () => {
|
||||
assert.equal(headers['proxy-authorization'], undefined)
|
||||
})
|
||||
|
||||
test('strips x-api-key header (gateway injects real token)', () => {
|
||||
const headers = rewriteHeaders(
|
||||
{ 'x-api-key': 'client-gateway-token', 'x-app': 'cli' },
|
||||
config,
|
||||
)
|
||||
assert.equal(headers['x-api-key'], undefined)
|
||||
assert.equal(headers['x-app'], 'cli')
|
||||
})
|
||||
|
||||
test('strips x-anthropic-billing-header', () => {
|
||||
const headers = rewriteHeaders(
|
||||
{ 'x-anthropic-billing-header': 'cc_version=2.1.81.a1b; cc_entrypoint=cli;' },
|
||||
config,
|
||||
)
|
||||
assert.equal(headers['x-anthropic-billing-header'], undefined)
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
console.log('\nNon-JSON passthrough')
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user