mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
eeec2bf83d
* fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) Defense-in-depth for the key-store isolation shipped in #163, plus a correction to the overstated claim that fix's comments made. Surfaced by an independent (Codex) re-review. Background: keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so the key store can be pointed at a scratch dir for the test suite. If BOTH vars reached a production daemon's environment, it would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent total auth outage. #163's comments claimed a production server "runs without NODE_ENV, so it CANNOT honor the override no matter how the variable got in." That is not something keys.mjs can enforce — it is only true while the daemon's env happens to lack NODE_ENV=test. This PR makes it true for every server OCP itself launches, and softens the docs to stop overclaiming. Three parts (all in OCP's own launch/installer paths — no server.mjs change, no cli.js analogue): 1. scripts/lib/plist-merge.mjs — new exported NEVER_PRESERVE = {NODE_ENV, OCP_DIR_OVERRIDE}, stripped from the preserved set in BOTH mergePlistEnv and mergeSystemdEnv. The preservation rule ("keys only in the EXISTING unit are kept verbatim") was the vector: a unit that once carried these test-only vars would otherwise survive every setup re-run. setup.mjs's template never injects them, so preservation was the only entry path, and this closes it. 2. ocp (cmd_restart manual fallback) — the one direct `node server.mjs` launch OCP controls now runs under `env -u NODE_ENV -u OCP_DIR_OVERRIDE`, so a maintainer who exported both while debugging and then restarted can't silently boot the daemon onto a scratch store. 3. keys.mjs + test-env.mjs — softened the overstated comments to state what is actually enforced (the two-key gate makes neither var alone do anything; OCP's launchers strip both) and to name the one residual path honestly: a hand-rolled `node server.mjs` with both vars explicitly exported, bypassing every launcher — for which the loud getDb() "NOT the default" log is the backstop. No library-level gate can catch an operator who both sets a test flag and bypasses the launchers; the honest fix is a non-silent wrong-store, which #163 already provides. Severity: LOW (defense-in-depth; the default/shipped path was already safe). No behavior change on any correctly-configured install. ALIGNMENT.md: this PR does not touch server.mjs, so the cli.js-citation hard requirement does not apply; and no cli.js operation is involved — key-store isolation and installer env hygiene are entirely OCP-owned (no Class A / cli.js-mirror surface). Tests: +4 mutation-proven (3 behavioral: drop the `!NEVER_PRESERVE.has(k)` guard in either merge fn and they fail — verified 326 passed / 3 failed under mutation; restored). The `ocp` bash `env -u` line is verified by `bash -n` + inspection (the suite does not exec the installer/daemon). Full suite: 329 passed / 0 failed (was 325). Version bump + CHANGELOG deferred to the later chore(release) PR, per the repo's #148/#149/#150 -> #151 convention (matching PR #164). Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com> * test(setup): assert NEVER_PRESERVE.size === 2 so the "exactly two" test matches its name Reviewer nit (LOW): the membership assertion let a future spurious third entry slip past a test whose name promises "exactly the two". Behavior stays guarded by the 3 mutation-proof tests; this just makes the contract test honest. Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
935 lines
33 KiB
Bash
Executable File
935 lines
33 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)"
|
|
# env -u strips test-only key-store redirection vars (A4): if the invoking shell had
|
|
# NODE_ENV=test + OCP_DIR_OVERRIDE exported (e.g. from a debugging session), this manual
|
|
# fallback would otherwise inherit them and start the daemon against a scratch/empty key
|
|
# store — a silent auth outage in AUTH_MODE=multi. The plist/systemd paths strip these via
|
|
# plist-merge's NEVER_PRESERVE; this covers the one direct-launch path OCP controls.
|
|
DISABLE_AUTOUPDATER=1 env -u NODE_ENV -u OCP_DIR_OVERRIDE 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
|