Files
ocp/ocp
T
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
eeec2bf83d fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) (#165)
* 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>
2026-07-15 22:28:49 +10:00

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