From caf9450e2188c4ead66c5eb6fd2806a1a5a96a6f Mon Sep 17 00:00:00 2001 From: rayan-mazouz Date: Thu, 8 Oct 2026 03:15:12 +0200 Subject: [PATCH 1/4] fix(me): stop exposing pooled accounts from /me/usage /me/usage is authenticated by any proxy user's API key, but its pool block listed every active account's id, label, and login email. That told every key holder which Anthropic logins back the pool. No client reads the per-account list (the status line uses only the pool aggregates, and the dashboard uses the admin accounts endpoints), so remove it. --- backend/app/routes/me.py | 17 ++--------------- backend/app/utils/models/api/__init__.py | 3 +-- backend/app/utils/models/api/me.py | 17 +---------------- backend/tests/test_api.py | 3 ++- 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 1568ebc..c7f4cba 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -10,8 +10,8 @@ from sqlalchemy import func from sqlalchemy.orm import Session -from app.utils import rotation, security, usage -from app.utils.models.api import MeUsageResponse, PoolAccountStatus, PoolStatus, PoolWindow +from app.utils import security, usage +from app.utils.models.api import MeUsageResponse, PoolStatus, PoolWindow from app.utils.postgres import AccountDb, ApiKeyDb, UsageRecordDb, UserDb, get_db router = APIRouter(tags=["Me"], prefix="/me") @@ -82,19 +82,6 @@ def build_pool_status(db: Session, now: datetime | None = None) -> PoolStatus | account_count=len(active_accounts), five_hour=five_hour, weekly=weekly, - accounts=[ - PoolAccountStatus( - id=account.id, - label=account.label, - email=account.account_email, - priority=account.priority, - five_hour_used_pct=account.session_used_pct, - five_hour_reset_at=account.session_reset_at, - weekly_used_pct=account.weekly_used_pct, - weekly_reset_at=account.weekly_reset_at, - ) - for account in sorted(active_accounts, key=rotation.account_selection_key) - ], ) diff --git a/backend/app/utils/models/api/__init__.py b/backend/app/utils/models/api/__init__.py index 9967c0c..6fc06d7 100644 --- a/backend/app/utils/models/api/__init__.py +++ b/backend/app/utils/models/api/__init__.py @@ -26,7 +26,7 @@ ListAnthropicFallbacksResponse, UpdateAnthropicFallbackRequest, ) -from .me import MeUsageResponse, PoolAccountStatus, PoolStatus, PoolWindow +from .me import MeUsageResponse, PoolStatus, PoolWindow from .notifications import ( NotificationRule, NotificationSettingsResponse, @@ -106,7 +106,6 @@ "UpdateAccountRequest", "MeUsageResponse", "PoolWindow", - "PoolAccountStatus", "PoolStatus", "User", "BulkUserPriorityRequest", diff --git a/backend/app/utils/models/api/me.py b/backend/app/utils/models/api/me.py index 1af9ece..8aa4c2b 100644 --- a/backend/app/utils/models/api/me.py +++ b/backend/app/utils/models/api/me.py @@ -5,7 +5,6 @@ from datetime import datetime from typing import Optional -from uuid import UUID from pydantic import BaseModel @@ -20,26 +19,12 @@ class PoolWindow(BaseModel): next_reset_at: Optional[datetime] -class PoolAccountStatus(BaseModel): - """Per-account quota data included with the pool aggregate.""" - - id: UUID - label: str - email: Optional[str] - priority: int - five_hour_used_pct: Optional[float] - five_hour_reset_at: Optional[datetime] - weekly_used_pct: Optional[float] - weekly_reset_at: Optional[datetime] - - class PoolStatus(BaseModel): - """Average rate-limit headroom plus per-account details for the available pool.""" + """Average rate-limit headroom for the available pool.""" account_count: int five_hour: PoolWindow weekly: PoolWindow - accounts: list[PoolAccountStatus] class MeUsageResponse(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a789b7c..74d079f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -550,7 +550,8 @@ def test_me_usage_aggregates_active_pool_and_earliest_reset(client, seed_account weekly_reset_values = [datetime.fromisoformat(value.replace("Z", "+00:00")) for value in pool["weekly"]["reset_at"]] assert weekly_reset_values == [weekly_exhausted_reset, weekly_second_reset, weekly_first_reset] assert datetime.fromisoformat(pool["weekly"]["next_reset_at"].replace("Z", "+00:00")) == weekly_exhausted_reset - assert len(pool["accounts"]) == 4 + # API-key holders only see pool aggregates, never which Anthropic logins back the pool. + assert "accounts" not in pool def test_me_usage_requires_key(client): From 370b0ad7ecdd824099d86597ecd9d06ccfbddb68 Mon Sep 17 00:00:00 2001 From: rayan-mazouz Date: Thu, 8 Oct 2026 03:15:25 +0200 Subject: [PATCH 2/4] fix(clients): serve the maintained status line script The backend served backend/clients/claude-code-statusline.sh, the copy the agent installation guide downloads, but fixes had only been applied to the unreferenced duplicate under clients/: it lacked the atomic cache write (so an outage erased the last good pool value) and the base-URL handling for /api and /api/v1 bases. Keep one copy, the maintained version, where the backend serves it. --- backend/clients/claude-code-statusline.sh | 15 +- backend/tests/test_clients.py | 8 + clients/claude-code-statusline.sh | 240 ---------------------- 3 files changed, 22 insertions(+), 241 deletions(-) delete mode 100755 clients/claude-code-statusline.sh diff --git a/backend/clients/claude-code-statusline.sh b/backend/clients/claude-code-statusline.sh index d33e876..61fa161 100755 --- a/backend/clients/claude-code-statusline.sh +++ b/backend/clients/claude-code-statusline.sh @@ -97,7 +97,20 @@ if [ -n "$base" ] && [ -n "$token" ]; then usage_cache="${cachedir}/ccproxy-usage-$(id -u).json" umtime="$(stat -c %Y "$usage_cache" 2>/dev/null || stat -f %m "$usage_cache" 2>/dev/null || echo 0)" if [ ! -s "$usage_cache" ] || [ "$(( now - umtime ))" -ge 30 ]; then - curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "${base%/}/v1/me/usage" -o "$usage_cache" 2>/dev/null || true + # Never write directly to the cache: curl truncates its output file before + # connecting, so a transient outage would erase the last good pool value. + usage_tmp="${usage_cache}.tmp.$$" + usage_url="${base%/}" + case "$usage_url" in + */api/v1|*/v1) usage_url+="/me/usage" ;; + */api) usage_url+="/v1/me/usage" ;; + *) usage_url+="/v1/me/usage" ;; + esac + if curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "$usage_url" -o "$usage_tmp" 2>/dev/null; then + mv -f "$usage_tmp" "$usage_cache" + else + rm -f "$usage_tmp" + fi fi if [ -s "$usage_cache" ]; then s_pct="$(jq -r '.pool.five_hour.used_pct // empty' "$usage_cache" 2>/dev/null)" diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py index 9524c69..25bf8f2 100644 --- a/backend/tests/test_clients.py +++ b/backend/tests/test_clients.py @@ -38,3 +38,11 @@ def test_dashboard_generated_commands_do_not_interpolate_revealed_secret(): assert "${secret}" not in commands assert "CC_PROXY_KEY" not in commands assert "input hidden" in users_page + + +def test_served_statusline_is_the_repository_copy(client): + response = client.get("/api/v1/clients/claude-code-statusline.sh") + + assert response.status_code == 200 + assert response.text == (REPOSITORY_ROOT / "backend/clients/claude-code-statusline.sh").read_text() + assert 'usage_tmp="${usage_cache}.tmp.$$"' in response.text diff --git a/clients/claude-code-statusline.sh b/clients/claude-code-statusline.sh deleted file mode 100755 index 61fa161..0000000 --- a/clients/claude-code-statusline.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash -# Claude Code status line for the Claude Code Proxy. -# -# Reproduces the look of https://github.com/leeguooooo/claude-code-usage-bar, but sourced for a relay: -# the proxy is not api.anthropic.com, so Anthropic's official 5h/7d rate-limit headers never reach Claude -# Code's stdin. Instead the two rate-limit bars come from the proxy's own pool telemetry via GET /v1/me/usage -# (pool.five_hour.used_pct / pool.weekly.used_pct + reset timestamps), while the model, context window, and the -# prompt-cache countdown come from the session JSON Claude Code pipes in on stdin. So `5h`/`7d` here are the -# AVAILABLE pool's average usage, not a personal Anthropic window. -# -# 5h[██░░░░░░] 38% ⏰3h12m │ 7d[███████░] 93% ⏰2d3h │ Opus 4.8 (64.6k/1.0M) │ cache 4m23s -# ⤷ claude-code-proxy ⎇ main● · +182 -47 · ⏱ 12m -# -# Data sources: -# - 5h / 7d % + reset timers ......... GET /v1/me/usage (disk-cached 30s; degrades quietly if unreachable) -# - model name + context window ...... Claude Code stdin (model.display_name, context_window.*) -# - cache 4m23s / COLD ............... tail-read of the active transcript JSONL (TTL auto-detected 1h vs 5m) -# - project / branch / lines / ⏱ ..... stdin workspace+cost, plus a 5s-cached `git` for branch & dirty dot -# -# Requirements: bash, curl, jq, coreutils (GNU date). Honors NO_COLOR. -# -# Install: copy somewhere, `chmod +x`, and point Claude Code at it in ~/.claude/settings.json. For the cache -# countdown to tick every second, also set refreshInterval to 1: -# { "statusLine": { "type": "command", "command": "/abs/path/claude-code-statusline.sh", "refreshInterval": 1 } } - -set -uo pipefail # deliberately NOT -e: every segment degrades to empty rather than aborting the line. - -# Claude Code pipes a session JSON object on stdin. Capture it (may be empty when run by hand). -stdin_json="$(cat 2>/dev/null || true)" - -cachedir="${TMPDIR:-/tmp}" -now="$(date +%s)" - -# ---- colors ------------------------------------------------------------------ -if [ -n "${NO_COLOR:-}" ]; then color=0; else color=1; fi -ESC=$'\033' -DIM=90; GREEN=32; YELLOW=33; RED=31; CYAN=36; GREY=90 -paint() { # paint - local code="$1"; shift - if [ "$color" = 1 ]; then printf '%s' "${ESC}[${code}m$*${ESC}[0m"; else printf '%s' "$*"; fi -} -sev_color() { # echo an ANSI code for a "% used" value (repo defaults: 30 / 70) - local p="${1%.*}"; p="${p:-0}" - if [ "$p" -ge 70 ] 2>/dev/null; then echo "$RED" - elif [ "$p" -ge 30 ] 2>/dev/null; then echo "$YELLOW" - else echo "$GREEN"; fi -} - -# ---- small helpers ----------------------------------------------------------- -sj() { printf '%s' "$stdin_json" | jq -r "$1" 2>/dev/null || true; } # query the stdin JSON - -human() { # 64586 -> 64.6k ; 1000000 -> 1.0M ; 0 -> 0 - awk -v n="${1:-0}" 'BEGIN{ - n=n+0; - if (n>=1000000) printf "%.1fM", n/1000000; - else if (n>=1000) printf "%.1fk", n/1000; - else printf "%d", n; - }' -} - -fmt_eta() { # seconds-until -> "2d3h" / "3h12m" / "12m" / "now" - local s="${1:-0}"; [ "$s" -le 0 ] 2>/dev/null && { printf 'now'; return; } - local d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60)) - if [ "$d" -gt 0 ]; then printf '%dd%dh' "$d" "$h" - elif [ "$h" -gt 0 ]; then printf '%dh%dm' "$h" "$m" - else printf '%dm' "$m"; fi -} - -bar() { # bar -> 8-cell battery, colored by severity - local pct="${1:-0}"; pct="${pct%.*}"; [ -z "$pct" ] && pct=0 - [ "$pct" -lt 0 ] 2>/dev/null && pct=0; [ "$pct" -gt 100 ] 2>/dev/null && pct=100 - local cells=8 filled - filled=$(( (pct * cells + 50) / 100 )) - [ "$filled" -gt "$cells" ] && filled=$cells - local i out="" - for ((i=0; i seconds from now (empty if unparseable / absent) - local iso="$1" ep - [ -n "$iso" ] && [ "$iso" != "null" ] || return 0 - ep="$(date -d "$iso" +%s 2>/dev/null)" || return 0 - echo $(( ep - now )) -} - -# ---- rate-limit segments (5h / 7d) from the proxy ---------------------------- -base="${ANTHROPIC_BASE_URL:-}"; token="${ANTHROPIC_AUTH_TOKEN:-}" -settings="${HOME}/.claude/settings.json" -if { [ -z "$base" ] || [ -z "$token" ]; } && [ -f "$settings" ]; then - [ -n "$base" ] || base="$(jq -r '.env.ANTHROPIC_BASE_URL // empty' "$settings" 2>/dev/null)" - [ -n "$token" ] || token="$(jq -r '.env.ANTHROPIC_AUTH_TOKEN // empty' "$settings" 2>/dev/null)" -fi - -ratelimit_seg="" -if [ -n "$base" ] && [ -n "$token" ]; then - usage_cache="${cachedir}/ccproxy-usage-$(id -u).json" - umtime="$(stat -c %Y "$usage_cache" 2>/dev/null || stat -f %m "$usage_cache" 2>/dev/null || echo 0)" - if [ ! -s "$usage_cache" ] || [ "$(( now - umtime ))" -ge 30 ]; then - # Never write directly to the cache: curl truncates its output file before - # connecting, so a transient outage would erase the last good pool value. - usage_tmp="${usage_cache}.tmp.$$" - usage_url="${base%/}" - case "$usage_url" in - */api/v1|*/v1) usage_url+="/me/usage" ;; - */api) usage_url+="/v1/me/usage" ;; - *) usage_url+="/v1/me/usage" ;; - esac - if curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "$usage_url" -o "$usage_tmp" 2>/dev/null; then - mv -f "$usage_tmp" "$usage_cache" - else - rm -f "$usage_tmp" - fi - fi - if [ -s "$usage_cache" ]; then - s_pct="$(jq -r '.pool.five_hour.used_pct // empty' "$usage_cache" 2>/dev/null)" - w_pct="$(jq -r '.pool.weekly.used_pct // empty' "$usage_cache" 2>/dev/null)" - s_reset="$(jq -r '.pool.five_hour.next_reset_at // empty' "$usage_cache" 2>/dev/null)" - w_reset="$(jq -r '.pool.weekly.next_reset_at // empty' "$usage_cache" 2>/dev/null)" - if [ -n "$s_pct" ]; then - sp=$(awk -v x="$s_pct" 'BEGIN{printf "%d", x*100+0.5}') - seg="$(paint "$DIM" 5h)[$(bar "$sp")] $(paint "$(sev_color "$sp")" "${sp}%")" - eta="$(eta_until "$s_reset")"; [ -n "$eta" ] && seg+=" $(paint "$DIM" "⏰$(fmt_eta "$eta")")" - ratelimit_seg="$seg" - fi - if [ -n "$w_pct" ]; then - wp=$(awk -v x="$w_pct" 'BEGIN{printf "%d", x*100+0.5}') - seg="$(paint "$DIM" 7d)[$(bar "$wp")] $(paint "$(sev_color "$wp")" "${wp}%")" - eta="$(eta_until "$w_reset")"; [ -n "$eta" ] && seg+=" $(paint "$DIM" "⏰$(fmt_eta "$eta")")" - [ -n "$ratelimit_seg" ] && ratelimit_seg+="$(paint "$DIM" ' │ ')" - ratelimit_seg+="$seg" - fi - fi -fi - -# ---- model + context window from stdin --------------------------------------- -model_seg="" -model="$(sj '.model.display_name // empty')" -# Drop a "(... context)" suffix — the context readout below already conveys size. -model="$(printf '%s' "$model" | sed -E 's/[[:space:]]*\([^)]*[Cc]ontext[^)]*\)//')" -if [ -n "$model" ]; then - ctx_size="$(sj '.context_window.context_window_size // empty')" - ctx_in="$(sj '.context_window.total_input_tokens // 0')" - ctx_out="$(sj '.context_window.total_output_tokens // 0')" - ctx_pct="$(sj '.context_window.used_percentage // empty')" - model_seg="$(paint "$CYAN" "$model")" - if [ -n "$ctx_size" ] && [ "$ctx_size" != "0" ]; then - used=$(( ${ctx_in:-0} + ${ctx_out:-0} )) - cc="$DIM"; [ -n "$ctx_pct" ] && cc="$(sev_color "${ctx_pct%.*}")" - model_seg+=" $(paint "$cc" "($(human "$used")/$(human "$ctx_size"))")" - fi -fi - -# ---- prompt-cache countdown from the transcript ------------------------------ -cache_seg="" -tp="$(sj '.transcript_path // empty')" -if [ -n "$tp" ] && [ -f "$tp" ]; then - read -r c_ts c_ttl <<<"$(tail -n 400 "$tp" 2>/dev/null | jq -rs ' - (map(select(.type=="assistant"))) as $a - | (($a | map(select(.timestamp)) | last | .timestamp) // "") as $ts - | (($a | map(.message.usage.cache_creation) | map(select(. != null)) - | map(if .ephemeral_1h_input_tokens > 0 then 3600 - elif .ephemeral_5m_input_tokens > 0 then 300 else empty end) - | last) // 0) as $ttl - | "\($ts) \($ttl)"' 2>/dev/null)" - if [ -n "${c_ts:-}" ] && [ "$c_ts" != "null" ]; then - c_ttl="${c_ttl:-0}"; [ "$c_ttl" = 0 ] && c_ttl=300 - c_ep="$(date -d "$c_ts" +%s 2>/dev/null || echo 0)" - if [ "$c_ep" != 0 ]; then - age=$(( now - c_ep )); [ "$age" -lt 0 ] && age=0 - rem=$(( c_ttl - age )) - if [ "$rem" -le 0 ]; then - cache_seg="$(paint "$RED" 'cache COLD')" - else - if [ "$rem" -ge 3600 ]; then ctxt=$(printf 'cache %dh%02dm%02ds' $((rem/3600)) $(((rem%3600)/60)) $((rem%60))) - elif [ "$rem" -ge 60 ]; then ctxt=$(printf 'cache %dm%02ds' $((rem/60)) $((rem%60))) - else ctxt=$(printf 'cache %ds' "$rem"); fi - [ "$rem" -lt 60 ] && cache_seg="$(paint "$YELLOW" "$ctxt")" || cache_seg="$(paint "$GREEN" "$ctxt")" - fi - fi - fi -fi - -# ---- assemble line 1 --------------------------------------------------------- -SEP="$(paint "$DIM" ' │ ')" -line1="" -for s in "$ratelimit_seg" "$model_seg" "$cache_seg"; do - [ -n "$s" ] || continue - [ -n "$line1" ] && line1+="$SEP" - line1+="$s" -done - -# ---- identity line (project ⎇ branch · +/- · ⏱) ----------------------------- -proj="$(sj '.workspace.repo.name // empty')" -cwd="$(sj '.workspace.current_dir // .cwd // empty')" -[ -n "$proj" ] || { [ -n "$cwd" ] && proj="$(basename "$cwd")"; } - -branch=""; dirty=0 -if [ -n "$cwd" ] && [ -d "$cwd" ]; then - gkey="$(printf '%s' "$cwd" | cksum | cut -d' ' -f1)" - gcache="${cachedir}/ccproxy-git-${gkey}" - gmtime="$(stat -c %Y "$gcache" 2>/dev/null || stat -f %m "$gcache" 2>/dev/null || echo 0)" - if [ -s "$gcache" ] && [ "$(( now - gmtime ))" -lt 5 ]; then - IFS=$'\t' read -r branch dirty <"$gcache" - else - branch="$(git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null || git -C "$cwd" rev-parse --short HEAD 2>/dev/null || true)" - if [ -n "$branch" ] && [ -n "$(git -C "$cwd" status --porcelain 2>/dev/null)" ]; then dirty=1; fi - printf '%s\t%s' "$branch" "$dirty" >"$gcache" 2>/dev/null || true - fi -fi - -line2="" -if [ -n "$proj" ]; then - line2="$(paint "$DIM" '⤷ ')$(paint "$CYAN" "$proj")" - if [ -n "$branch" ]; then - line2+=" $(paint "$DIM" '⎇') $(paint "$GREY" "$branch")" - [ "${dirty:-0}" = 1 ] && line2+="$(paint "$YELLOW" '●')" - fi - add="$(sj '.cost.total_lines_added // 0')"; rml="$(sj '.cost.total_lines_removed // 0')" - if [ "${add:-0}" != 0 ] || [ "${rml:-0}" != 0 ]; then - line2+=" $(paint "$DIM" '·') $(paint "$GREEN" "+${add}") $(paint "$RED" "-${rml}")" - fi - dur_ms="$(sj '.cost.total_duration_ms // 0')" - if [ "${dur_ms:-0}" != 0 ]; then - dsec=$(( dur_ms / 1000 )) - if [ "$dsec" -ge 3600 ]; then dtxt=$(printf '%dh%dm' $((dsec/3600)) $(((dsec%3600)/60))) - elif [ "$dsec" -ge 60 ]; then dtxt=$(printf '%dm' $((dsec/60))) - else dtxt=$(printf '%ds' "$dsec"); fi - line2+=" $(paint "$DIM" '·') $(paint "$DIM" "⏱ $dtxt")" - fi -fi - -# ---- emit -------------------------------------------------------------------- -if [ -z "$line1" ] && [ -z "$line2" ]; then - echo "ccproxy: status unavailable (need ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN + jq, or Claude Code stdin)" - exit 0 -fi -[ -n "$line1" ] && printf '%s\n' "$line1" -[ -n "$line2" ] && printf '%s\n' "$line2" -exit 0 From 96885eb8a3371ae8c74abd39022de15843e48aa1 Mon Sep 17 00:00:00 2001 From: rayan-mazouz Date: Thu, 8 Oct 2026 03:15:30 +0200 Subject: [PATCH 3/4] fix(statusline): keep cache files private The status line caches /me/usage responses and git state in the shared temp directory under predictable names, created with the default umask and therefore readable by other local users. Create them with umask 077. --- backend/clients/claude-code-statusline.sh | 2 ++ backend/tests/test_clients.py | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/clients/claude-code-statusline.sh b/backend/clients/claude-code-statusline.sh index 61fa161..6f3a889 100755 --- a/backend/clients/claude-code-statusline.sh +++ b/backend/clients/claude-code-statusline.sh @@ -28,6 +28,8 @@ set -uo pipefail # deliberately NOT -e: every segment degrades to empty rather # Claude Code pipes a session JSON object on stdin. Capture it (may be empty when run by hand). stdin_json="$(cat 2>/dev/null || true)" +# Cache files live in a shared temp dir and hold pool data fetched with this user's key; keep them private. +umask 077 cachedir="${TMPDIR:-/tmp}" now="$(date +%s)" diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py index 25bf8f2..ebc957c 100644 --- a/backend/tests/test_clients.py +++ b/backend/tests/test_clients.py @@ -46,3 +46,4 @@ def test_served_statusline_is_the_repository_copy(client): assert response.status_code == 200 assert response.text == (REPOSITORY_ROOT / "backend/clients/claude-code-statusline.sh").read_text() assert 'usage_tmp="${usage_cache}.tmp.$$"' in response.text + assert "umask 077" in response.text From 2f68c31e7f29d8ccc82fb2eb08440600ec7791bc Mon Sep 17 00:00:00 2001 From: rayan-mazouz Date: Thu, 8 Oct 2026 03:15:30 +0200 Subject: [PATCH 4/4] refactor(clients): remove the unreferenced backend installer The dashboard's install commands use frontend/public/install.sh and install.ps1. Nothing referenced the separate /clients/install.sh route served by the backend, which had drifted from the public installer. --- backend/app/routes/clients.py | 165 ---------------------------------- backend/tests/test_clients.py | 8 -- 2 files changed, 173 deletions(-) diff --git a/backend/app/routes/clients.py b/backend/app/routes/clients.py index 9015fc1..d74d6a6 100644 --- a/backend/app/routes/clients.py +++ b/backend/app/routes/clients.py @@ -17,168 +17,3 @@ def statusline_script() -> PlainTextResponse: """Serve the Claude Code status line script so users can curl it directly.""" return PlainTextResponse(_STATUSLINE_PATH.read_text(), media_type="text/x-shellscript") - - -@router.get( - "/install.sh", - response_class=PlainTextResponse, - responses={200: {"description": "One-line installer for Claude Code"}}, -) -def install_script() -> PlainTextResponse: - """Installer that configures Claude Code to use this proxy and installs the status line.""" - script = r"""#!/usr/bin/env bash -set -euo pipefail - -usage() { - echo "Usage: curl -fsSL .../install.sh | bash -s -- " >&2 - echo "The installer prompts for the API key without echoing it." >&2 - echo "For automation, set CLAUDE_PROXY_KEY_FILE to a protected file containing the key." >&2 - exit 1 -} - -if [ "$#" -ne 1 ]; then - usage -fi - -BASE_URL="$1" -API_KEY="" -case "$BASE_URL" in - http://*|https://*) ;; - *) echo "Base URL must start with http:// or https://" >&2; exit 1 ;; -esac - -read_proxy_key() { - if [ -n "${CLAUDE_PROXY_KEY_FILE:-}" ]; then - if [ ! -f "$CLAUDE_PROXY_KEY_FILE" ] || [ ! -r "$CLAUDE_PROXY_KEY_FILE" ]; then - echo "CLAUDE_PROXY_KEY_FILE must name a readable regular file." >&2 - exit 1 - fi - IFS= read -r API_KEY < "$CLAUDE_PROXY_KEY_FILE" || [ -n "$API_KEY" ] - elif [ -t 2 ]; then - printf 'Paste the Claude Code Proxy API key (input hidden): ' > /dev/tty - if ! IFS= read -r -s API_KEY < /dev/tty; then - printf '\nUnable to read the API key from the terminal.\n' > /dev/tty - exit 1 - fi - printf '\n' > /dev/tty - else - echo "No interactive terminal is available." >&2 - echo "Set CLAUDE_PROXY_KEY_FILE to a protected file containing the key." >&2 - exit 1 - fi - if [ -z "$API_KEY" ]; then - echo "API key cannot be empty." >&2 - exit 1 - fi -} - -read_proxy_key - -SETTINGS="$HOME/.claude/settings.json" -STATUSLINE_DIR="$HOME/.claude/bin" -STATUSLINE="$STATUSLINE_DIR/claude-code-statusline.sh" - -umask 077 -mkdir -p "$HOME/.claude" "$STATUSLINE_DIR" - -SCRIPT_URL="${BASE_URL%/}/v1/clients/claude-code-statusline.sh" -echo "Downloading status line script..." -curl -fsSL "$SCRIPT_URL" -o "$STATUSLINE" -chmod +x "$STATUSLINE" - -# Keep unrelated settings and write atomically. The key is supplied via a -# protected file, never as a command-line argument or generated command text. -KEY_FILE="$(mktemp "$HOME/.claude/proxy-key.XXXXXX")" -TEMP_SETTINGS="" -cleanup() { - [ -z "${KEY_FILE:-}" ] || rm -f -- "$KEY_FILE" - [ -z "${TEMP_SETTINGS:-}" ] || rm -f -- "$TEMP_SETTINGS" -} -trap cleanup EXIT -printf '%s\n' "$API_KEY" > "$KEY_FILE" -unset API_KEY - -if command -v python3 >/dev/null 2>&1; then - CC_INSTALL_BASE_URL="$BASE_URL" \ - CC_INSTALL_KEY_FILE="$KEY_FILE" \ - CC_INSTALL_SETTINGS="$SETTINGS" \ - CC_INSTALL_STATUSLINE="$STATUSLINE" \ - python3 - <<'PY' -import json -import os -import shutil -import tempfile - -path = os.environ["CC_INSTALL_SETTINGS"] -with open(os.environ["CC_INSTALL_KEY_FILE"], encoding="utf-8") as handle: - api_key = handle.readline().rstrip("\r\n") -if not api_key: - raise SystemExit("API key cannot be empty") -if os.path.exists(path): - try: - with open(path, encoding="utf-8") as handle: - settings = json.load(handle) - except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"Could not parse {path}; it was left unchanged: {exc}") - if not isinstance(settings, dict): - raise SystemExit(f"Could not update {path}: its top-level value is not an object") - shutil.copy2(path, path + ".bak") -else: - settings = {} -env = settings.get("env") -if env is None: - env = {} - settings["env"] = env -elif not isinstance(env, dict): - raise SystemExit(f"Could not update {path}: env is not an object") -env["ANTHROPIC_BASE_URL"] = os.environ["CC_INSTALL_BASE_URL"] -env["ANTHROPIC_AUTH_TOKEN"] = api_key -settings["statusLine"] = { - "type": "command", - "command": os.environ["CC_INSTALL_STATUSLINE"], - "refreshInterval": 1, -} -directory = os.path.dirname(path) or "." -fd, temporary = tempfile.mkstemp(prefix="settings.json.tmp.", dir=directory) -try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(settings, handle, indent=2) - handle.write("\n") - os.chmod(temporary, 0o600) - os.replace(temporary, path) -finally: - if os.path.exists(temporary): - os.unlink(temporary) -PY -elif command -v jq >/dev/null 2>&1; then - if [ -f "$SETTINGS" ]; then - jq empty "$SETTINGS" || { echo "Could not parse $SETTINGS; it was left unchanged." >&2; exit 1; } - cp -p "$SETTINGS" "$SETTINGS.bak" - else - printf '{}\n' > "$SETTINGS" - fi - TEMP_SETTINGS="$(mktemp "$HOME/.claude/settings.json.tmp.XXXXXX")" - jq --rawfile key "$KEY_FILE" --arg base "$BASE_URL" --arg statusline "$STATUSLINE" \ - '.env = ((.env // {}) + {ANTHROPIC_BASE_URL: $base, ANTHROPIC_AUTH_TOKEN: ($key | rtrimstr("\\n"))}) - | .statusLine = {type: "command", command: $statusline, refreshInterval: 1}' \ - "$SETTINGS" > "$TEMP_SETTINGS" - mv "$TEMP_SETTINGS" "$SETTINGS" - TEMP_SETTINGS="" - chmod 600 "$SETTINGS" -else - echo "python3 or jq is required to safely update $SETTINGS." >&2 - exit 1 -fi - -cleanup -trap - EXIT - -echo "" -echo "Claude Code configured!" -echo " Base URL: $BASE_URL" -echo " Status line: $STATUSLINE" -echo " Settings: $SETTINGS" -echo "" -echo "Run 'claude' to start using the proxy." -""" - return PlainTextResponse(script, media_type="text/x-shellscript") diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py index ebc957c..c51fb76 100644 --- a/backend/tests/test_clients.py +++ b/backend/tests/test_clients.py @@ -12,14 +12,6 @@ def _assert_unix_installer_keeps_key_out_of_argv(script: str) -> None: assert 'jq --rawfile key "$KEY_FILE"' in script -def test_backend_installer_prompts_for_key_instead_of_accepting_it_in_argv(client): - response = client.get("/api/v1/clients/install.sh") - - assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/x-shellscript") - _assert_unix_installer_keeps_key_out_of_argv(response.text) - - def test_public_installers_never_put_key_in_command_or_process_arguments(): unix_script = (REPOSITORY_ROOT / "frontend/public/install.sh").read_text() powershell_script = (REPOSITORY_ROOT / "frontend/public/install.ps1").read_text()