mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
* fix(server): isolate default -p spawn in credential-free scratch HOME (latency ③) The default (-p/stream-json) spawn inherited the operator's real HOME (global ~/.claude plugins/skills/hooks) and ran with cwd=~/ocp (project CLAUDE.md/skills), loading heavy host context on EVERY request. Measured: pure API floor for haiku "hi" ≈ 1–2s; same CLI in the operator's real HOME/cwd ≈ 10–28s; a clean minimal HOME + CLAUDE_CODE_OAUTH_TOKEN ≈ 3–7s with auth intact. When an OAuth token is resolvable (and OCP_SPAWN_REAL_HOME!=1), spawnClaudeProcess now runs claude under a credential-free minimal scratch HOME (<HOME>/.ocp/spawn-home, no .credentials.json / settings.json / plugins) with cwd = that neutral dir and the resolved token in CLAUDE_CODE_OAUTH_TOKEN (env token is authoritative for -p). Mirrors the TUI path's resolveTuiHome() env-token mode. Falls back to real HOME + inherited cwd when no token resolves (zero regression); OCP_SPAWN_REAL_HOME=1 is a kill-switch. The token is resolved ONCE (memoized via getSpawnHomeMode, reusing getOAuthCredentials, the same resolver the /usage probe uses) and never logged. Adds a startup log line and an additive /health `spawn` block so the operator can confirm isolation is on. ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change (HOME/cwd/env isolation of the spawned process). It does NOT mirror a cli.js wire operation, introduces no new endpoint/header, and adds no API token to the wire path — so there is no cli.js function to cite. CLAUDE_CODE_OAUTH_TOKEN / CLAUDE_CODE_DISABLE_* are existing claude-CLI env contracts already used by the TUI path and getOAuthCredentials. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): bounded wait-queue + HTTP 429 for -p concurrency overflow (⑥) spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` → the client got an opaque 500 AND the rejection was uncounted (a 15-concurrent stress run returned 7×500 while /health stats.errors stayed 0). The TUI path already had a bounded-queue semaphore (TuiSemaphore); the -p path did not. Now the -p path reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT, { maxQueue: CLAUDE_MAX_QUEUE })`. Requests beyond MAX_CONCURRENT WAIT (up to CLAUDE_MAX_QUEUE, default 16) instead of being rejected; only when the queue is ALSO full does the request get HTTP 429 + Retry-After (rate_limit_error, NOT 500), a distinct `concurrency_queue_full` log, and a stats.queueRejections counter surfaced on /health. callClaude / callClaudeStreaming now acquire a slot (acquireClaudeSlot) before spawning; the release fn is wired into the existing idempotent cleanup() so the slot is freed on EVERY exit path (close/error/timeout/abort) — the #37/#40 slot-leak guard. MAX_CONCURRENT semantics (max concurrent claude procs) are unchanged; only overflow handling changed from throw-500 to queue-then-429. claudeSemaphore.limit is kept in sync with runtime /settings maxConcurrent changes. Live-verified (fake claude, MAX_CONCURRENT=1 MAX_QUEUE=1): 3 concurrent → exactly one 429 (Retry-After: 7, rate_limit_error body) + two 200s, on BOTH the non-streaming and streaming paths; /health stats.queueRejections=1 while stats.errors=0; after drain activeRequests=0 / inflight=0 / queued=0 and a follow-up request returns 200 (no slot leak). 2 new unit tests (247 passed, 0 failed). ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change (a concurrency queue + backpressure status code in front of the existing spawn). It does NOT mirror a cli.js wire operation, adds no new endpoint or wire header, and introduces no API token — so there is no cli.js function to cite. (Retry-After is a standard HTTP response header on OCP's own 429, not a claude wire header.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ocp): macOS restart uses bootout+bootstrap so plist env is re-read The macOS restart path used `launchctl kickstart -k gui/$uid/dev.ocp.proxy`, which only re-execs the process and reuses launchd's CACHED environment — so a plist EnvironmentVariables edit (CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN, etc.) was silently ignored until a full unload/reload. This is the documented pit-index footgun. `ocp restart` (macOS) now does a full `launchctl bootout` + `bootstrap` of the agent via a new `_launchd_reload` helper, which re-reads the plist EnvironmentVariables so env changes take effect. Success is keyed on the bootstrap (the env-reloading load), not the bootout (which may legitimately fail if the agent is not currently loaded). A missing plist returns failure so the `elif` chain falls through to the legacy label and then to the Linux `systemctl --user restart` path unchanged (systemctl already re-reads its EnvironmentFile). Updates `ocp restart` help text and adds a README Troubleshooting subsection ("Env var change doesn't take effect after restart") with the manual bootout+bootstrap commands and a ps-based verification one-liner. Verified `_launchd_reload` with a stubbed launchctl: missing plist → rc=1 (falls through, no launchctl call); bootout-fail + bootstrap-ok → rc=0; bootstrap-fail → rc=1; call order is bootout then bootstrap. `bash -n ocp` clean; npm test 247/0 (server.mjs untouched). ALIGNMENT.md Rule 2 justification: this changes ONLY the `ocp` CLI wrapper's local service-restart mechanism (launchctl invocation). It does not touch server.mjs, the wire path, any endpoint/header, or any API token — so there is no cli.js function to cite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(readme): document new env vars CLAUDE_MAX_QUEUE / CLAUDE_QUEUE_RETRY_AFTER / OCP_SPAWN_REAL_HOME Release_kit contract (CLAUDE.md Iron Rule 5.5: "new env var → README § Environment Variables table") requires the three env vars added by the perf/concurrency fixes to be in the README table. Adds rows for CLAUDE_MAX_QUEUE (16) + CLAUDE_QUEUE_RETRY_AFTER (5) (FIX ⑥ -p wait-queue + 429) and OCP_SPAWN_REAL_HOME (FIX ③ spawn-home isolation kill-switch), each cross-referencing the additive /health.concurrency and /health.spawn fields. Addresses the independent reviewer's MEDIUM finding. Docs only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ocp-plugin): add openclaw.extensions for OpenClaw 2026.5.27 compat + sync version (②/ocp) ocp-plugin/package.json's openclaw object lacked the 'extensions' field that OpenClaw 2026.5.27 requires to install/load a plugin (matches the sibling olp plugin). Without it the daemon refused to load the local path plugin, breaking /ocp Telegram commands. Version synced 3.12.0 -> 3.16.2 to match the manifest. Plugin-layer change; no server.mjs / ALIGNMENT cli.js surface touched. --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
930 lines
32 KiB
Bash
Executable File
930 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ocp — OpenClaw Proxy CLI
|
|
# Usage: ocp <command> [args]
|
|
#
|
|
# Talks to the local claude-proxy at http://127.0.0.1:3456
|
|
|
|
set -euo pipefail
|
|
|
|
PROXY="http://127.0.0.1:3456"
|
|
|
|
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
|
# Using a bash array preserves word boundaries — no eval needed.
|
|
_AUTH_ARGS=()
|
|
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
|
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
|
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
|
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
|
fi
|
|
|
|
# Wrapper: curl with optional auth
|
|
_curl() {
|
|
curl "${_AUTH_ARGS[@]}" "$@"
|
|
}
|
|
|
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
|
|
|
_bar() {
|
|
local pct=$1 width=20
|
|
local filled=$(( pct * width / 100 ))
|
|
local empty=$(( width - filled ))
|
|
printf '['
|
|
printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true
|
|
printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true
|
|
printf '] %d%%' "$pct"
|
|
}
|
|
|
|
# ── usage ────────────────────────────────────────────────────────────────
|
|
cmd_usage_help() {
|
|
cat <<'EOF'
|
|
ocp usage — Show Claude plan usage limits
|
|
|
|
Displays current session utilization, weekly limits, extra usage status,
|
|
and proxy request statistics. Data is fetched from the Anthropic API
|
|
via a minimal probe call (cached for 5 minutes).
|
|
|
|
Usage: ocp usage [--by-key]
|
|
|
|
Options:
|
|
--by-key Show per-key usage statistics
|
|
EOF
|
|
}
|
|
|
|
cmd_usage() {
|
|
if [[ "${1:-}" == "--by-key" ]]; then
|
|
local data
|
|
data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; }
|
|
echo "$data" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
by_key = d.get('byKey', [])
|
|
if not by_key:
|
|
print('No usage data yet.')
|
|
else:
|
|
print('Usage by Key')
|
|
print('─────────────────────────────────────────────────────────────────')
|
|
hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}'
|
|
print(hdr)
|
|
print(' ' + '─' * (len(hdr) - 2))
|
|
for k in by_key:
|
|
avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-'
|
|
print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}')
|
|
"
|
|
return
|
|
fi
|
|
|
|
local data
|
|
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
|
|
|
|
echo "$data" | python3 -c "
|
|
import sys, json
|
|
|
|
d = json.loads(sys.stdin.read())
|
|
p = d['plan']
|
|
s = p['currentSession']
|
|
w = p['weeklyLimits']['allModels']
|
|
e = p['extraUsage']
|
|
px = d['proxy']
|
|
models = d.get('models', {})
|
|
|
|
print('Plan Usage Limits')
|
|
print('─────────────────────────────────────')
|
|
print(f' Current session {s[\"percent\"]:>4} used')
|
|
print(f' Resets in {s[\"resetsIn\"]} ({s[\"resetsAtHuman\"]})')
|
|
print()
|
|
print(f' Weekly (all models) {w[\"percent\"]:>4} used')
|
|
print(f' Resets in {w[\"resetsIn\"]} ({w[\"resetsAtHuman\"]})')
|
|
print()
|
|
status_icon = 'on' if e['status'] == 'allowed' else 'off'
|
|
print(f' Extra usage {status_icon}')
|
|
print()
|
|
|
|
if models:
|
|
print('Model Stats (since proxy start)')
|
|
print('─────────────────────────────────────────────────────────────────────')
|
|
hdr = f' {\"Model\":<25} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Max Time\":>9} {\"Avg Prompt\":>11} {\"Max Prompt\":>11}'
|
|
print(hdr)
|
|
print(' ' + '─' * (len(hdr) - 2))
|
|
total_reqs = 0
|
|
for model in sorted(models):
|
|
m = models[model]
|
|
total_reqs += m['requests']
|
|
avg_t = f'{m[\"avgElapsed\"]/1000:.0f}s' if m['avgElapsed'] else '-'
|
|
max_t = f'{m[\"maxElapsed\"]/1000:.0f}s' if m['maxElapsed'] else '-'
|
|
avg_p = f'{m[\"avgPromptChars\"]/1000:.0f}K' if m['avgPromptChars'] else '-'
|
|
max_p = f'{m[\"maxPromptChars\"]/1000:.0f}K' if m['maxPromptChars'] else '-'
|
|
print(f' {model:<25} {m[\"requests\"]:>5} {m[\"successes\"]:>4} {m[\"errors\"]:>4} {avg_t:>9} {max_t:>9} {avg_p:>11} {max_p:>11}')
|
|
print(f' {\"Total\":<25} {total_reqs:>5}')
|
|
else:
|
|
print(' No model requests yet.')
|
|
|
|
print()
|
|
print(f'Proxy: up {px[\"uptime\"]} | {px[\"totalRequests\"]} reqs | {px[\"activeRequests\"]} active | {px[\"errors\"]} err | {px[\"timeouts\"]} timeout')
|
|
"
|
|
}
|
|
|
|
# ── status ───────────────────────────────────────────────────────────────
|
|
cmd_status_help() {
|
|
cat <<'EOF'
|
|
ocp status — Quick combined overview (usage + health)
|
|
|
|
Usage: ocp status
|
|
EOF
|
|
}
|
|
|
|
cmd_status() {
|
|
curl -sf --max-time 15 "$PROXY/status" | _json
|
|
}
|
|
|
|
# ── health ───────────────────────────────────────────────────────────────
|
|
cmd_health_help() {
|
|
cat <<'EOF'
|
|
ocp health — Proxy health and diagnostics
|
|
|
|
Shows proxy status, version, uptime, auth, config, sessions, and recent errors.
|
|
|
|
Usage: ocp health
|
|
EOF
|
|
}
|
|
|
|
cmd_health() {
|
|
curl -sf --max-time 10 "$PROXY/health" | _json
|
|
}
|
|
|
|
# ── logs ─────────────────────────────────────────────────────────────────
|
|
cmd_logs_help() {
|
|
cat <<'EOF'
|
|
ocp logs — Show recent proxy log entries
|
|
|
|
Usage: ocp logs [N] [LEVEL]
|
|
|
|
Arguments:
|
|
N Number of entries to show (default: 20, max: 200)
|
|
LEVEL Filter level: error, warn, info, all (default: error)
|
|
|
|
Examples:
|
|
ocp logs Last 20 errors
|
|
ocp logs 50 all Last 50 entries of any level
|
|
ocp logs 10 warn Last 10 warnings
|
|
EOF
|
|
}
|
|
|
|
cmd_logs() {
|
|
local n=${1:-20}
|
|
local level=${2:-error}
|
|
curl -sf --max-time 10 "$PROXY/logs?n=$n&level=$level" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
entries = d.get('entries', [])
|
|
if not entries:
|
|
print(f'No {d.get(\"level\",\"\")} log entries.')
|
|
else:
|
|
for e in entries:
|
|
if 'raw' in e:
|
|
print(e['raw'][:200])
|
|
else:
|
|
ts = e.get('ts','')[:19]
|
|
ev = e.get('event','?')
|
|
lvl = e.get('level','')
|
|
model = e.get('model','')
|
|
extra = ' '.join(f'{k}={v}' for k,v in e.items() if k not in ('ts','event','level','model'))
|
|
parts = [ts, lvl.upper(), ev]
|
|
if model: parts.append(model)
|
|
if extra: parts.append(extra)
|
|
print(' | '.join(parts))
|
|
"
|
|
}
|
|
|
|
# ── models ───────────────────────────────────────────────────────────────
|
|
cmd_models_help() {
|
|
cat <<'EOF'
|
|
ocp models — List available Claude models
|
|
|
|
Usage: ocp models
|
|
EOF
|
|
}
|
|
|
|
cmd_models() {
|
|
curl -sf --max-time 5 "$PROXY/v1/models" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
for m in d.get('data', []):
|
|
print(f\" {m['id']}\")
|
|
"
|
|
}
|
|
|
|
# ── sessions ─────────────────────────────────────────────────────────────
|
|
cmd_sessions_help() {
|
|
cat <<'EOF'
|
|
ocp sessions — List active CLI sessions
|
|
|
|
Usage: ocp sessions
|
|
EOF
|
|
}
|
|
|
|
cmd_sessions() {
|
|
curl -sf --max-time 5 "$PROXY/sessions" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
sessions = d.get('sessions', [])
|
|
if not sessions:
|
|
print('No active sessions.')
|
|
else:
|
|
for s in sessions:
|
|
print(f\" {s['id'][:16]}... model={s['model']} msgs={s['messages']} last={s['lastUsed']}\")
|
|
"
|
|
}
|
|
|
|
# ── clear ────────────────────────────────────────────────────────────────
|
|
cmd_clear_help() {
|
|
cat <<'EOF'
|
|
ocp clear — Clear all active CLI sessions
|
|
|
|
Usage: ocp clear
|
|
EOF
|
|
}
|
|
|
|
cmd_clear() {
|
|
local result
|
|
result=$(curl -sf --max-time 5 -X DELETE "$PROXY/sessions")
|
|
local count
|
|
count=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['cleared'])")
|
|
echo "Cleared $count sessions."
|
|
}
|
|
|
|
# ── keys ────────────────────────────────────────────────────────────────
|
|
cmd_keys_help() {
|
|
cat <<'EOF'
|
|
ocp keys — Manage API keys (multi-key auth mode)
|
|
|
|
Usage:
|
|
ocp keys List all keys
|
|
ocp keys add <name> Create a new key
|
|
ocp keys revoke <name|id> Revoke a key
|
|
|
|
Examples:
|
|
ocp keys add wife-laptop
|
|
ocp keys add son-ipad
|
|
ocp keys revoke wife-laptop
|
|
EOF
|
|
}
|
|
|
|
cmd_keys() {
|
|
case "${1:-}" in
|
|
add)
|
|
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add <name>"; return 1; fi
|
|
local result
|
|
result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; }
|
|
echo "$result" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
if 'key' in d:
|
|
print(f'✓ Key created for \"{d[\"name\"]}\"')
|
|
print(f'')
|
|
print(f' API Key: {d[\"key\"]}')
|
|
print(f'')
|
|
print(f' Copy this key now — you won\\'t see it again.')
|
|
print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}')
|
|
else:
|
|
print(f'✗ {d.get(\"error\", \"Unknown error\")}')
|
|
"
|
|
;;
|
|
revoke)
|
|
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke <name|id>"; return 1; fi
|
|
_curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
if d.get('revoked'):
|
|
print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.')
|
|
else:
|
|
print(f'✗ Key not found or already revoked.')
|
|
"
|
|
;;
|
|
--help|-h)
|
|
cmd_keys_help
|
|
;;
|
|
"")
|
|
_curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
keys = d.get('keys', [])
|
|
if not keys:
|
|
print('No API keys configured.')
|
|
print('Create one: ocp keys add <name>')
|
|
else:
|
|
print('API Keys')
|
|
print('─────────────────────────────────────────────────')
|
|
for k in keys:
|
|
status = '✗ revoked' if k['revoked'] else '✓ active'
|
|
print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}')
|
|
" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; }
|
|
;;
|
|
*)
|
|
echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# ── connect ─────────────────────────────────────────────────────────────
|
|
cmd_connect_help() {
|
|
cat <<'EOF'
|
|
ocp connect — Connect this machine to a remote OCP as a LAN client
|
|
|
|
Configures OPENAI_BASE_URL (and optionally OPENAI_API_KEY) in your shell
|
|
rc file so tools like Claude Code point to a remote OCP instance.
|
|
|
|
Usage:
|
|
ocp connect <host-ip> [--port PORT] [--key API_KEY]
|
|
|
|
Arguments:
|
|
host-ip IP address of the machine running OCP
|
|
--port PORT Port OCP listens on (default: 3456)
|
|
--key API_KEY API key to use (prompted if remote requires auth)
|
|
|
|
Examples:
|
|
ocp connect 192.168.1.10
|
|
ocp connect 192.168.1.10 --port 8080
|
|
ocp connect 192.168.1.10 --key sk-abc123
|
|
EOF
|
|
}
|
|
|
|
cmd_connect() {
|
|
local host="" port=3456 key=""
|
|
|
|
# Parse args
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
|
--key) key="${2:?'--key requires a value'}"; shift 2 ;;
|
|
--help|-h) cmd_connect_help; return 0 ;;
|
|
-*) echo "Unknown option: $1"; cmd_connect_help; return 1 ;;
|
|
*) host="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$host" ]]; then
|
|
echo "Error: host IP is required."
|
|
echo ""
|
|
cmd_connect_help
|
|
return 1
|
|
fi
|
|
|
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
echo "Error: invalid host '$host'"
|
|
return 1
|
|
fi
|
|
|
|
local base_url="http://$host:$port"
|
|
|
|
echo "OCP Connect"
|
|
echo "─────────────────────────────────────"
|
|
echo " Remote: $base_url"
|
|
echo ""
|
|
|
|
# Step 1: Test connectivity via /health
|
|
echo " Checking connectivity..."
|
|
local health_json
|
|
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
|
echo " ✗ Cannot reach $base_url/health"
|
|
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
|
return 1
|
|
}
|
|
echo " ✓ Connected"
|
|
echo ""
|
|
|
|
# Step 2: Show remote info
|
|
local remote_version auth_mode
|
|
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
|
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
|
|
|
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
|
echo ""
|
|
|
|
# Step 3: Determine if key is needed
|
|
local needs_key=0
|
|
if [[ "$auth_mode" != "none" ]]; then
|
|
needs_key=1
|
|
fi
|
|
|
|
if [[ $needs_key -eq 1 && -z "$key" ]]; then
|
|
echo " Remote requires authentication."
|
|
echo " Ask the admin to run: ocp keys add <name>"
|
|
printf " Enter API key (or press Enter to skip): "
|
|
read -rs key </dev/tty
|
|
echo
|
|
if [[ -z "$key" ]]; then
|
|
echo ""
|
|
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Step 4: Test API access via /v1/models
|
|
echo " Testing API access..."
|
|
local models_out models_ok=0
|
|
if [[ -n "$key" ]]; then
|
|
models_out=$(curl -sf --max-time 10 \
|
|
-H "Authorization: Bearer $key" \
|
|
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
|
else
|
|
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
|
fi
|
|
|
|
if [[ $models_ok -eq 0 ]]; then
|
|
echo " ✗ API access failed — key may be invalid or revoked."
|
|
return 1
|
|
fi
|
|
local model_count
|
|
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
|
echo " ✓ API accessible ($model_count models available)"
|
|
echo ""
|
|
|
|
# Step 5: Detect shell rc file
|
|
local rc_file
|
|
if [[ "${SHELL:-}" == */zsh ]]; then
|
|
rc_file="$HOME/.zshrc"
|
|
else
|
|
rc_file="$HOME/.bashrc"
|
|
fi
|
|
|
|
# Step 6: Remove any previously written OCP LAN lines
|
|
if [[ -f "$rc_file" ]]; then
|
|
local tmp_rc
|
|
tmp_rc=$(mktemp)
|
|
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
|
import sys
|
|
src, dst = sys.argv[1], sys.argv[2]
|
|
lines = open(src).readlines()
|
|
out = []
|
|
skip = False
|
|
for line in lines:
|
|
s = line.rstrip('\n')
|
|
if s == '# OCP LAN (added by ocp connect)':
|
|
skip = True
|
|
continue
|
|
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
|
continue
|
|
skip = False
|
|
out.append(line)
|
|
open(dst, 'w').writelines(out)
|
|
PYEOF
|
|
then
|
|
cp "$tmp_rc" "$rc_file"
|
|
fi
|
|
rm -f "$tmp_rc"
|
|
fi
|
|
|
|
# Step 7: Append new config
|
|
{
|
|
echo ""
|
|
echo "# OCP LAN (added by ocp connect)"
|
|
echo "export OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo "export OPENAI_API_KEY=$key"
|
|
fi
|
|
} >> "$rc_file"
|
|
|
|
echo " Written to $rc_file:"
|
|
echo " OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo " OPENAI_API_KEY=${key:0:8}..."
|
|
fi
|
|
echo ""
|
|
|
|
# Step 8: Quick smoke test — send a minimal chat completion
|
|
echo " Running smoke test..."
|
|
local chat_payload='{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":10}'
|
|
local chat_out chat_ok=0
|
|
if [[ -n "$key" ]]; then
|
|
chat_out=$(curl -sf --max-time 30 \
|
|
-H "Authorization: Bearer $key" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$chat_payload" \
|
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
|
else
|
|
chat_out=$(curl -sf --max-time 30 \
|
|
-H "Content-Type: application/json" \
|
|
-d "$chat_payload" \
|
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
|
fi
|
|
|
|
if [[ $chat_ok -eq 1 ]]; then
|
|
local reply
|
|
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
|
echo " ✓ Smoke test passed: $reply"
|
|
else
|
|
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
|
echo " The env vars have still been written. Check the remote OCP logs."
|
|
fi
|
|
|
|
echo ""
|
|
echo " Done. Reload your shell to apply:"
|
|
echo " source $rc_file"
|
|
}
|
|
|
|
# ── lan ─────────────────────────────────────────────────────────────────
|
|
cmd_lan_help() {
|
|
cat <<'EOF'
|
|
ocp lan — Quick LAN mode setup guide
|
|
|
|
Shows current network configuration and connection instructions
|
|
for other devices on the same network.
|
|
|
|
Usage: ocp lan
|
|
EOF
|
|
}
|
|
|
|
cmd_lan() {
|
|
local ip
|
|
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
|
|
local port=3456
|
|
|
|
echo "OCP LAN Setup"
|
|
echo "─────────────────────────────────────"
|
|
echo ""
|
|
echo " Your IP: $ip"
|
|
echo " Port: $port"
|
|
echo ""
|
|
echo " For IDE users, set:"
|
|
echo " OPENAI_BASE_URL=http://$ip:$port/v1"
|
|
echo " OPENAI_API_KEY=<your-key> (if auth enabled)"
|
|
echo ""
|
|
echo " Dashboard: http://$ip:$port/dashboard"
|
|
echo ""
|
|
|
|
if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then
|
|
echo " Status: ✓ LAN-accessible"
|
|
else
|
|
echo " Status: ✗ Not LAN-accessible (bound to localhost only)"
|
|
echo ""
|
|
echo " To enable LAN mode, set env var and restart:"
|
|
echo " CLAUDE_BIND=0.0.0.0"
|
|
echo " ocp restart"
|
|
fi
|
|
}
|
|
|
|
# ── restart ──────────────────────────────────────────────────────────────
|
|
cmd_restart_help() {
|
|
cat <<'EOF'
|
|
ocp restart — Restart the proxy or gateway
|
|
|
|
Usage:
|
|
ocp restart Restart the Claude proxy service
|
|
ocp restart gateway Restart the OpenClaw gateway
|
|
(briefly disconnects all Telegram/Discord bots)
|
|
|
|
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
|
|
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
|
|
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
|
|
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
|
|
cached env, so env edits would be silently ignored. (Linux systemctl already
|
|
re-reads its EnvironmentFile on restart.)
|
|
EOF
|
|
}
|
|
|
|
# macOS only: reload a launchd agent via bootout + bootstrap so plist
|
|
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
|
|
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
|
|
_launchd_reload() {
|
|
local uid="$1" label="$2" plist="$3"
|
|
[[ -f "$plist" ]] || return 1
|
|
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
|
|
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
|
|
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
|
|
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
|
|
}
|
|
|
|
cmd_restart() {
|
|
if [[ "${1:-}" == "gateway" ]]; then
|
|
echo "Restarting gateway..."
|
|
openclaw gateway restart 2>&1
|
|
else
|
|
echo "Restarting proxy..."
|
|
# Try current service name, then legacy, then manual restart.
|
|
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
|
|
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
|
|
local uid
|
|
uid=$(id -u)
|
|
if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
|
|
true
|
|
elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
|
|
true
|
|
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
|
true
|
|
elif systemctl --user restart openclaw-proxy 2>/dev/null; then
|
|
true
|
|
else
|
|
echo "Service restart failed, trying kill + restart..."
|
|
pkill -f "server.mjs.*CLAUDE_PROXY" 2>/dev/null || pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
|
sleep 1
|
|
local self_r script_dir
|
|
self_r="${BASH_SOURCE[0]}"
|
|
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
|
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
|
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
|
fi
|
|
sleep 3
|
|
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
|
echo "✓ Proxy restarted successfully."
|
|
cmd_usage
|
|
else
|
|
echo "✗ Proxy not responding after restart."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── settings ─────────────────────────────────────────────────────────────
|
|
cmd_settings_help() {
|
|
cat <<'EOF'
|
|
ocp settings — View or update proxy settings at runtime
|
|
|
|
Usage:
|
|
ocp settings Show all tunable settings
|
|
ocp settings <key> <value> Update a setting (no restart needed)
|
|
|
|
Tunable keys:
|
|
timeout Overall request timeout (ms) [30000 - 600000]
|
|
firstByteTimeout Base first-byte timeout (ms) [15000 - 300000]
|
|
maxConcurrent Max concurrent claude processes [1 - 32]
|
|
sessionTTL Session idle expiry (ms) [60000 - 86400000]
|
|
maxPromptChars Prompt truncation limit (chars) [10000 - 1000000]
|
|
tiers.opus.base Opus first-byte timeout base (ms) [30000 - 600000]
|
|
tiers.opus.perChar Opus per-char timeout (ms/char) [0 - 0.01]
|
|
tiers.sonnet.base Sonnet first-byte timeout base (ms) [30000 - 600000]
|
|
tiers.sonnet.perChar Sonnet per-char timeout (ms/char) [0 - 0.01]
|
|
tiers.haiku.base Haiku first-byte timeout base (ms) [15000 - 300000]
|
|
tiers.haiku.perChar Haiku per-char timeout (ms/char) [0 - 0.01]
|
|
|
|
Examples:
|
|
ocp settings maxPromptChars 200000
|
|
ocp settings tiers.sonnet.base 150000
|
|
EOF
|
|
}
|
|
|
|
cmd_settings() {
|
|
if [[ -z "${1:-}" ]]; then
|
|
# GET — show all settings
|
|
curl -sf --max-time 5 "$PROXY/settings" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
print('OCP Settings')
|
|
print('─────────────────────────────────────')
|
|
for k in ('timeout','firstByteTimeout','maxConcurrent','sessionTTL','maxPromptChars'):
|
|
v = d.get(k, {})
|
|
val = v.get('value', '?')
|
|
unit = v.get('unit', '')
|
|
desc = v.get('desc', '')
|
|
print(f' {k:<20} {val:>8} {unit:<6} {desc}')
|
|
t = d.get('tiers', {})
|
|
print()
|
|
print('Timeout Tiers (first-byte):')
|
|
for tier in ('opus','sonnet','haiku'):
|
|
info = t.get(tier, {})
|
|
print(f' {tier:<8} base={info.get(\"base\",\"?\"):>6}ms perChar={info.get(\"perPromptChar\",\"?\")}')
|
|
"
|
|
elif [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
cmd_settings_help
|
|
elif [[ -z "${2:-}" ]]; then
|
|
echo "Usage: ocp settings <key> <value>"
|
|
echo "Run 'ocp settings --help' for available keys."
|
|
return 1
|
|
else
|
|
# PATCH — update a setting
|
|
local key="$1"
|
|
local value="$2"
|
|
local result
|
|
result=$(curl -s --max-time 5 -X PATCH "$PROXY/settings" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"$key\": $value}" 2>&1)
|
|
if echo "$result" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); errs=d.get('errors',[]); exit(1 if errs else 0)" 2>/dev/null; then
|
|
echo "✓ $key = $value"
|
|
else
|
|
echo "$result" | python3 -c "
|
|
import sys, json
|
|
d = json.loads(sys.stdin.read())
|
|
for e in d.get('errors', []):
|
|
print(f'✗ {e}')
|
|
" 2>/dev/null || echo "✗ $result"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── update ──────────────────────────────────────────────────────────────
|
|
cmd_update_help() {
|
|
cat <<'EOF'
|
|
ocp update — Smart upgrade dispatcher
|
|
|
|
Runs `ocp doctor` internally to choose the right path:
|
|
• Patch bump (same minor): light path (git pull + npm install + restart)
|
|
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
|
|
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
|
|
|
|
Usage:
|
|
ocp update Smart auto-pick path
|
|
ocp update --check Show available updates, don't apply
|
|
ocp update --dry-run Preview the plan, don't mutate
|
|
ocp update --target v3.13.0 Pin a specific version
|
|
ocp update --yes Skip y/N prompts (AI agents pass this)
|
|
ocp update --rollback Restore the most recent upgrade snapshot
|
|
ocp update --rollback --list List available snapshots
|
|
ocp update --rollback <path> Restore a specific snapshot
|
|
ocp update --rollback --dry-run Preview rollback plan
|
|
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
|
ocp update --rollback --gc --dry-run Preview what would be deleted
|
|
EOF
|
|
}
|
|
|
|
cmd_update() {
|
|
local script_dir self
|
|
self="${BASH_SOURCE[0]}"
|
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
|
|
|
# Pass through --check fast path (existing behaviour)
|
|
if [[ "${1:-}" == "--check" ]]; then
|
|
cd "$script_dir"
|
|
git fetch origin main --quiet 2>/dev/null || true
|
|
local local_ver remote_ver
|
|
local_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
|
remote_ver=$(git show origin/main:package.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "?")
|
|
local behind
|
|
behind=$(git rev-list HEAD..origin/main --count 2>/dev/null || echo "?")
|
|
echo "OCP Update Check"
|
|
echo "─────────────────────────────────────"
|
|
echo " Current: v$local_ver"
|
|
echo " Latest: v$remote_ver"
|
|
if [[ "$behind" == "0" ]]; then
|
|
echo " Status: ✓ Up to date"
|
|
else
|
|
echo " Status: $behind commit(s) behind"
|
|
echo " Run 'ocp update' to apply."
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# Rollback path
|
|
if [[ "${1:-}" == "--rollback" ]]; then
|
|
shift
|
|
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
|
|
fi
|
|
|
|
# Doctor-driven path selection
|
|
local kind
|
|
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
|
|
|
|
case "$kind" in
|
|
noop)
|
|
echo "Already at latest. Nothing to do."
|
|
return 0
|
|
;;
|
|
update)
|
|
_cmd_update_light "$script_dir"
|
|
;;
|
|
upgrade|fresh_install)
|
|
exec node "$script_dir/scripts/upgrade.mjs" "$@"
|
|
;;
|
|
fix_oauth|fix_service)
|
|
echo "Pre-upgrade check failed: $kind"
|
|
echo "Run \`ocp doctor\` for details and ai_executable steps."
|
|
return 1
|
|
;;
|
|
*)
|
|
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
|
|
_cmd_update_light() {
|
|
local script_dir="$1"
|
|
echo "Updating OCP (light path)..."
|
|
cd "$script_dir"
|
|
local old_ver new_ver
|
|
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
|
echo " Pulling latest from GitHub..."
|
|
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
|
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
|
return 1
|
|
fi
|
|
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
|
if [[ "$old_ver" == "$new_ver" ]]; then
|
|
echo " ✓ Already at latest (v$new_ver)"
|
|
else
|
|
echo " ✓ Updated v$old_ver → v$new_ver"
|
|
fi
|
|
|
|
# Sync plugin (existing logic preserved)
|
|
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
|
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
|
echo " Syncing OCP plugin..."
|
|
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
|
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
|
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
|
echo " ✓ Plugin synced"
|
|
fi
|
|
|
|
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
|
echo " Syncing OpenClaw registry..."
|
|
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
|
|
fi
|
|
|
|
echo " Restarting proxy..."
|
|
cmd_restart > /dev/null 2>&1
|
|
}
|
|
|
|
cmd_doctor_help() {
|
|
cat <<'EOF'
|
|
ocp doctor — Health & upgrade-readiness check
|
|
|
|
Runs a series of checks (Node version, git state, service health,
|
|
OAuth token, plist customisation, OpenClaw provider) and emits either
|
|
human-readable PASS/WARN/FAIL output or a JSON next_action that
|
|
AI agents can execute.
|
|
|
|
Usage:
|
|
ocp doctor Human-readable output
|
|
ocp doctor --json JSON for AI agents and ocp update internal use
|
|
ocp doctor --check oauth Fast path: OAuth check only
|
|
EOF
|
|
}
|
|
|
|
cmd_doctor() {
|
|
local script_dir self
|
|
self="${BASH_SOURCE[0]}"
|
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
|
exec node "$script_dir/scripts/doctor.mjs" "$@"
|
|
}
|
|
|
|
# ── help ─────────────────────────────────────────────────────────────────
|
|
cmd_help() {
|
|
cat <<'EOF'
|
|
ocp — OpenClaw Proxy CLI
|
|
|
|
Usage: ocp <command> [args]
|
|
|
|
Commands:
|
|
usage Plan usage limits (--by-key for per-key stats)
|
|
status Quick overview (usage + health)
|
|
health Proxy diagnostics
|
|
settings View or update tunable settings
|
|
logs [N] [level] Recent logs (default: 20, error)
|
|
models Available models
|
|
sessions Active sessions
|
|
clear Clear all sessions
|
|
keys Manage API keys (add/list/revoke)
|
|
lan LAN mode setup guide
|
|
connect <ip> Connect to a remote OCP (sets env vars in rc file)
|
|
restart Restart proxy
|
|
restart gateway Restart gateway
|
|
update Update OCP to latest version
|
|
update --check Check for updates
|
|
|
|
Run 'ocp <command> --help' for details on a specific command.
|
|
EOF
|
|
}
|
|
|
|
# ── dispatch ─────────────────────────────────────────────────────────────
|
|
# Check for --help/-h on any subcommand: ocp <cmd> --help
|
|
subcmd="${1:-help}"
|
|
shift 2>/dev/null || true
|
|
|
|
# Global --help
|
|
if [[ "$subcmd" == "--help" || "$subcmd" == "-h" || "$subcmd" == "help" ]]; then
|
|
cmd_help
|
|
exit 0
|
|
fi
|
|
|
|
# Per-subcommand --help
|
|
for arg in "$@"; do
|
|
if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then
|
|
fn="cmd_${subcmd}_help"
|
|
if declare -f "$fn" > /dev/null 2>&1; then
|
|
"$fn"
|
|
else
|
|
echo "No help available for '$subcmd'."
|
|
fi
|
|
exit 0
|
|
fi
|
|
done
|
|
|
|
case "$subcmd" in
|
|
usage) cmd_usage "${1:-}" ;;
|
|
status) cmd_status ;;
|
|
health) cmd_health ;;
|
|
settings) cmd_settings "${1:-}" "${2:-}" ;;
|
|
logs) cmd_logs "${1:-20}" "${2:-error}" ;;
|
|
models) cmd_models ;;
|
|
sessions) cmd_sessions ;;
|
|
clear) cmd_clear ;;
|
|
keys) cmd_keys "${1:-}" "${2:-}" ;;
|
|
lan) cmd_lan ;;
|
|
connect) cmd_connect "$@" ;;
|
|
restart) cmd_restart "${1:-}" ;;
|
|
doctor) cmd_doctor "$@" ;;
|
|
update) cmd_update "$@" ;;
|
|
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
|
esac
|