fix(installer): retry git ops + reject placeholder api_key + sync public configs
User hit two bugs in production:
1. uclaude_install.sh ERROR: Command failed at line 82 — gitea returned
transient HTTP 502 to `git fetch`, `2>/dev/null` masked stderr but
ERR trap fired with cryptic message.
2. After install, claude model picker showed only 5 models (built-in
defaults) instead of 19. Root cause: load_config() fell back to the
PUBLIC sanitized patcher.config.json (api_key='YOUR_API_KEY') after
remote fetch failed → claude API auth broken → custom models invisible.
Fixes:
claude/uclaude_install.sh:
- New retry_git() helper: 3 attempts, 5s backoff, loud diagnostic
- Existing-clone branch: retry_git wraps `git fetch` AND `git reset`
- Fallback: if fetch fails 3x on existing clone, nuke and re-clone fresh
(incremental fetch breaks more often than full clone on flaky gitea)
- Secondary fetch (before updater): tolerates failure with `|| true`
(we already have a working clone)
claude/uclaude_updater.py:
- _config_is_usable() guard: rejects {"api_key": "YOUR_API_KEY"} etc.
- load_config() retries remote 3x with backoff before falling back
- Removed local-file fallback (was loading public sanitized = bait)
- Cache-only fallback now (from previous successful fetch)
Public configs synced from canonical (api_key sanitized, models list
fully refreshed):
- claude/patcher.config.json: 17 → 19 models (+gpt-5.5, +gemini-3.1-pro etc)
- codex/codex_config.json: 4 → 5 models (+gpt-5.5)
- gemini/gemini_config.json: refreshed
- target_version: 2.1.112 → 2.1.119
Tests: tests/test_installer_robustness.py — 6 new GREEN guards.
Total: 196 → 207 GREEN.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,14 +9,16 @@
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.1-pro-preview-customtools",
|
||||
"gemini-3-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-flash",
|
||||
"qwen3.5-coder-plus",
|
||||
"gemini-2.5-flash",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"glm-4.7"
|
||||
@@ -26,7 +28,7 @@
|
||||
"timeout_ms": 3000000,
|
||||
"theme": "dark",
|
||||
"complete_onboarding": true,
|
||||
"target_version": "2.1.112",
|
||||
"target_version": "2.1.119",
|
||||
"effort_level": "high",
|
||||
"_note": "Production api_key lives in PRIVATE unlimitedcoding-config repo. uclaude_updater.py fetches it at runtime with token auth."
|
||||
}
|
||||
@@ -76,39 +76,75 @@ fi
|
||||
|
||||
# ---- Clone / Update repo ----
|
||||
|
||||
# Retry helper for git ops — gitea sporadically returns 502 (HTTP) or
|
||||
# closes RPC mid-packfile. Plain `2>/dev/null` masks stderr but the ERR
|
||||
# trap still fires on non-zero exit. Run with retries + clear diagnostic
|
||||
# instead of silent fail.
|
||||
retry_git() {
|
||||
local desc="$1"; shift
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
if "$@" 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo " $desc failed (attempt $attempt/3), retrying in 5s..." >&2
|
||||
sleep 5
|
||||
done
|
||||
echo " ERROR: $desc failed after 3 attempts" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo " Already cloned, updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
git fetch --depth 1 origin master 2>/dev/null
|
||||
git reset --hard origin/master 2>/dev/null
|
||||
if ! retry_git "git fetch" git fetch --depth 1 origin master; then
|
||||
# Fallback: nuke and re-clone fresh (gitea RPC consistently failing
|
||||
# for incremental fetch happens occasionally; full re-clone is more
|
||||
# robust).
|
||||
echo " Fetch failed permanently — falling back to fresh clone" >&2
|
||||
cd /
|
||||
rm -rf "$INSTALL_DIR"
|
||||
retry_git "git clone (fresh)" git clone --depth 1 --no-checkout "$REPO_URL" "$INSTALL_DIR" || exit 1
|
||||
cd "$INSTALL_DIR"
|
||||
git sparse-checkout init --no-cone
|
||||
git sparse-checkout set '/*' 'claude/*' '!claude/releases/v*' 'claude/releases/index.json' 'codex/*'
|
||||
retry_git "git checkout (after fresh clone)" git checkout || exit 1
|
||||
else
|
||||
retry_git "git reset --hard" git reset --hard origin/master || exit 1
|
||||
fi
|
||||
else
|
||||
echo " Cloning (shallow, sparse — only latest version)..."
|
||||
|
||||
# Shallow clone without checkout
|
||||
git clone --depth 1 --no-checkout "$REPO_URL" "$INSTALL_DIR"
|
||||
# Shallow clone without checkout — retry on transient gitea 502
|
||||
retry_git "git clone" git clone --depth 1 --no-checkout "$REPO_URL" "$INSTALL_DIR" || exit 1
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Enable sparse checkout: root + claude/ + codex/ (so optional codex
|
||||
# install works) + index.json (first pass)
|
||||
git sparse-checkout init --no-cone
|
||||
git sparse-checkout set '/*' 'claude/*' '!claude/releases/v*' 'claude/releases/index.json' 'codex/*'
|
||||
git checkout 2>/dev/null
|
||||
# Allow checkout to fail on transient errors; trap won't catch
|
||||
# because we wrap in `|| true`.
|
||||
git checkout 2>/dev/null || git checkout || exit 1
|
||||
|
||||
# Read latest version from index.json and add only that release dir
|
||||
if [ -f claude/releases/index.json ]; then
|
||||
VER=$(python3 -c "import json; print(json.load(open('claude/releases/index.json'))['latest'])")
|
||||
echo " Latest version: v${VER}"
|
||||
git sparse-checkout add "claude/releases/v${VER}"
|
||||
git checkout 2>/dev/null
|
||||
git checkout 2>/dev/null || git checkout || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Updating repo to latest version before running updater..."
|
||||
# Update repo to latest version BEFORE running updater (so we get latest MIN_NODE_VERSION fix)
|
||||
# Update repo to latest version BEFORE running updater (so we get latest
|
||||
# MIN_NODE_VERSION fix). Tolerant of transient gitea 502 — `|| true`
|
||||
# because we already have a working clone, fresh updater can run with
|
||||
# slightly older code if the secondary fetch fails.
|
||||
cd "$INSTALL_DIR"
|
||||
git fetch --depth 1 origin master 2>/dev/null
|
||||
git reset --hard origin/master 2>/dev/null || git pull --quiet 2>/dev/null
|
||||
git fetch --depth 1 origin master 2>/dev/null || echo " (secondary fetch failed; continuing with existing clone)" >&2
|
||||
git reset --hard origin/master 2>/dev/null || git pull --quiet 2>/dev/null || true
|
||||
|
||||
echo " Running updater..."
|
||||
|
||||
|
||||
@@ -626,12 +626,28 @@ CONFIG_URL = "https://git.sensey24.ru/aibot777/unlimitedcoding-config/raw/branch
|
||||
CONFIG_TOKEN = "cadffcb0a6a3be728ac1ff619bb40c86588f6837"
|
||||
|
||||
|
||||
PLACEHOLDER_API_KEYS = {"YOUR_API_KEY", "PLACEHOLDER", "REDACTED", "", None}
|
||||
|
||||
|
||||
def _config_is_usable(data):
|
||||
"""Reject configs with placeholder api_key — public sanitized files
|
||||
have api_key='YOUR_API_KEY' and would silently install with broken
|
||||
auth + stale models list."""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return data.get("api_key") not in PLACEHOLDER_API_KEYS
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load patcher.config.json from private config repo (with token auth).
|
||||
|
||||
Falls back to local file if network is unavailable.
|
||||
Falls back to LOCAL CACHE if network is unavailable. Refuses to use
|
||||
public sanitized patcher.config.json (api_key='YOUR_API_KEY') — that
|
||||
would silently install broken auth, masking the real failure.
|
||||
"""
|
||||
# 1. Try fetching from private repo
|
||||
# 1. Try fetching from private repo (with retry on transient gitea 502)
|
||||
last_err = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
@@ -640,6 +656,7 @@ def load_config():
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if _config_is_usable(data):
|
||||
# Cache locally for offline use
|
||||
cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json")
|
||||
try:
|
||||
@@ -648,29 +665,31 @@ def load_config():
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
eprint(f" {Y}Remote config returned placeholder api_key (attempt {attempt}/3){D}")
|
||||
last_err = "placeholder api_key"
|
||||
except Exception as e:
|
||||
eprint(f" {Y}Remote config fetch failed: {e}{D}")
|
||||
last_err = e
|
||||
eprint(f" {Y}Remote config fetch failed (attempt {attempt}/3): {e}{D}")
|
||||
if attempt < 3:
|
||||
time.sleep(2 * attempt)
|
||||
|
||||
# 2. Fallback: cached copy
|
||||
# 2. Fallback: cached copy from previous successful fetch
|
||||
cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json")
|
||||
if os.path.isfile(cache_path):
|
||||
try:
|
||||
with open(cache_path, "r") as f:
|
||||
eprint(f" {Y}Using cached config{D}")
|
||||
return json.load(f)
|
||||
cached = json.load(f)
|
||||
if _config_is_usable(cached):
|
||||
eprint(f" {Y}Using cached config (remote fetch failed){D}")
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Fallback: local file (legacy, will be removed)
|
||||
config_path = os.path.join(SCRIPT_DIR, "patcher.config.json")
|
||||
if os.path.isfile(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
eprint(f" {R}patcher.config.json not found (remote or local){D}")
|
||||
# 3. NO MORE local-file fallback — public patcher.config.json is sanitized
|
||||
# with placeholder api_key. Using it would silently install broken auth.
|
||||
eprint(f" {R}Cannot load config: remote unreachable AND no usable cache.{D}")
|
||||
eprint(f" {R} Last error: {last_err}{D}")
|
||||
eprint(f" {R} Try again later or set UCLAUDE_API_KEY env var manually.{D}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"/tmp"
|
||||
],
|
||||
"target_version": "0.125.0",
|
||||
"_note": "Production config (with real api_key) lives in PRIVATE unlimitedcoding-config repo. This file is a template only."
|
||||
"_note": "Production api_key lives in PRIVATE unlimitedcoding-config repo."
|
||||
}
|
||||
@@ -19,5 +19,5 @@
|
||||
"telemetry_enabled": false,
|
||||
"npm_package": "@google/gemini-cli",
|
||||
"npm_registry": "https://npm.sensey24.ru",
|
||||
"_note": "Production config (with real api_key) lives in PRIVATE unlimitedcoding-config repo. This file is a template only."
|
||||
"_note": "Production api_key lives in PRIVATE unlimitedcoding-config repo."
|
||||
}
|
||||
Reference in New Issue
Block a user