mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
/health is an unauthenticated, LAN-reachable endpoint that returned the live anonymous bearer key (anonymousKey: PROXY_ANONYMOUS_KEY). Any device that could reach the port harvested a working, quota-spending credential (P0). The anonymousKey field is now included only when the caller is localhost (already fully trusted by the auth path, via the unspoofable req.socket.remoteAddress) OR the admin explicitly opts in with the new PROXY_ADVERTISE_ANON_KEY=1 env var (default off). ocp-connect's absent-field fallback (interactive --key / anonymous access) is unchanged — comment-only update there. ALIGNMENT.md: this gates an OCP proxy-policy field in /health; it does NOT forward, add, or alter any Anthropic API operation, so a cli.js citation is N/A under Rule 2. No blacklisted tokens introduced; alignment.yml passes. Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10). Closes #109. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
724 lines
26 KiB
Bash
Executable File
724 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ocp-connect — Lightweight client script to connect to a remote OCP instance
|
|
# No dependencies beyond curl and python3 (available on most Linux/Mac systems)
|
|
#
|
|
# Install:
|
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
|
# chmod +x ocp-connect
|
|
#
|
|
# Or run directly:
|
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <host-ip> --key <key>
|
|
#
|
|
set -euo pipefail
|
|
|
|
OCP_CONNECT_VERSION="1.3.0"
|
|
|
|
show_version() {
|
|
echo "ocp-connect $OCP_CONNECT_VERSION"
|
|
}
|
|
|
|
show_help() {
|
|
cat <<'EOF'
|
|
ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy)
|
|
|
|
Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file
|
|
so tools like Claude Code, Cline, Aider, etc. point to the remote OCP.
|
|
|
|
Usage:
|
|
ocp-connect <host-ip> [options]
|
|
|
|
Options:
|
|
--port PORT Port OCP listens on (default: 3456)
|
|
--key API_KEY API key (prompted interactively if remote requires auth)
|
|
--version Print version and exit
|
|
--help, -h Show this help
|
|
|
|
Examples:
|
|
ocp-connect 192.168.1.10
|
|
ocp-connect 192.168.1.10 --port 8080
|
|
ocp-connect 192.168.1.10 --key ocp_abc123
|
|
|
|
What it does:
|
|
1. Tests connectivity to the remote OCP
|
|
2. Verifies your API key (if auth is enabled)
|
|
3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc
|
|
4. Sets system-level env vars (launchctl on macOS, systemd on Linux)
|
|
5. Configures OpenClaw automatically; prints setup hints for other IDEs
|
|
(Cline, Continue.dev, Cursor — manual configuration required)
|
|
6. Runs a smoke test to confirm everything works
|
|
|
|
After running, reload your shell: source ~/.bashrc (or ~/.zshrc)
|
|
EOF
|
|
}
|
|
|
|
configure_ides() {
|
|
local base_url="$1" key="$2" models_out="$3"
|
|
|
|
# --- OpenClaw ---
|
|
local oc_config="$HOME/.openclaw/openclaw.json"
|
|
local oc_found=false
|
|
if command -v openclaw &>/dev/null || [[ -f "$oc_config" ]]; then
|
|
oc_found=true
|
|
fi
|
|
|
|
if $oc_found; then
|
|
echo " IDE Configuration"
|
|
echo " ─────────────────────────────────────"
|
|
echo " Detected: OpenClaw ($oc_config)"
|
|
echo ""
|
|
printf " Configure OpenClaw to use this OCP? [Y/n] "
|
|
local oc_answer
|
|
{ read -r oc_answer </dev/tty; } 2>/dev/null || oc_answer="y"
|
|
oc_answer="${oc_answer:-y}"
|
|
|
|
if [[ "$oc_answer" =~ ^[Yy]$ ]]; then
|
|
# Ask for provider name
|
|
printf " Provider name (models show as <name>/model-id) [ocp]: "
|
|
local provider_name
|
|
{ read -r provider_name </dev/tty; } 2>/dev/null || provider_name=""
|
|
provider_name="${provider_name:-ocp}"
|
|
# Sanitize: only allow alphanumeric, dash, underscore
|
|
provider_name=$(echo "$provider_name" | tr -cd 'a-zA-Z0-9_-')
|
|
[[ -z "$provider_name" ]] && provider_name="ocp"
|
|
|
|
# Ask for priority
|
|
echo ""
|
|
echo " How should OCP models be configured?"
|
|
echo " 1) Primary — use OCP by default, keep existing models as backup"
|
|
echo " 2) Backup — keep current primary, add OCP as additional option"
|
|
echo ""
|
|
printf " Choice [1]: "
|
|
local priority_choice
|
|
{ read -r priority_choice </dev/tty; } 2>/dev/null || priority_choice="1"
|
|
priority_choice="${priority_choice:-1}"
|
|
|
|
echo ""
|
|
echo " Writing OpenClaw config..."
|
|
|
|
# Use python3 to safely manipulate JSON
|
|
local py_ok=0
|
|
python3 - "$oc_config" "$base_url" "$key" "$provider_name" "$priority_choice" "$models_out" <<'PYEOF' && py_ok=1
|
|
import sys, json, os
|
|
|
|
config_path = sys.argv[1]
|
|
base_url = sys.argv[2]
|
|
api_key = sys.argv[3]
|
|
provider_name = sys.argv[4]
|
|
priority = sys.argv[5] # "1" = primary, "2" = backup
|
|
models_json_str = sys.argv[6]
|
|
|
|
# Parse models from OCP /v1/models response
|
|
try:
|
|
models_data = json.loads(models_json_str)
|
|
model_ids = [m["id"] for m in models_data.get("data", [])]
|
|
except:
|
|
model_ids = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"]
|
|
|
|
# Build provider entry
|
|
provider = {
|
|
"baseUrl": base_url + "/v1",
|
|
"api": "openai-completions",
|
|
"authHeader": bool(api_key),
|
|
"models": []
|
|
}
|
|
|
|
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
|
model_meta = {
|
|
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
|
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
|
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
|
}
|
|
|
|
def get_model_meta(mid):
|
|
"""Match model metadata by prefix."""
|
|
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
|
|
if mid.startswith(prefix):
|
|
return meta
|
|
return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
|
|
|
|
for mid in model_ids:
|
|
meta = get_model_meta(mid)
|
|
provider["models"].append({
|
|
"id": mid,
|
|
"name": meta["name"],
|
|
"reasoning": meta["reasoning"],
|
|
"input": ["text"],
|
|
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
|
"contextWindow": 200000,
|
|
"maxTokens": meta["maxTokens"]
|
|
})
|
|
|
|
# Load or create config
|
|
if os.path.exists(config_path):
|
|
with open(config_path, "r") as f:
|
|
config = json.load(f)
|
|
else:
|
|
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
config = {}
|
|
|
|
# Ensure models.providers exists
|
|
config.setdefault("models", {})
|
|
config["models"].setdefault("mode", "merge")
|
|
config["models"].setdefault("providers", {})
|
|
|
|
# Remove any previous OCP provider with the same name
|
|
config["models"]["providers"][provider_name] = provider
|
|
|
|
# Set up auth profile if key is provided
|
|
if api_key:
|
|
config.setdefault("auth", {})
|
|
config["auth"].setdefault("profiles", {})
|
|
config["auth"]["profiles"][provider_name + ":default"] = {
|
|
"provider": provider_name,
|
|
"mode": "api_key"
|
|
}
|
|
|
|
# Configure agent defaults — add model aliases
|
|
config.setdefault("agents", {})
|
|
config["agents"].setdefault("defaults", {})
|
|
config["agents"]["defaults"].setdefault("models", {})
|
|
|
|
# Build alias map (prefix match)
|
|
alias_prefixes = {
|
|
"claude-opus-4": "Claude Opus",
|
|
"claude-sonnet-4": "Claude Sonnet",
|
|
"claude-haiku-4": "Claude Haiku",
|
|
}
|
|
|
|
for mid in model_ids:
|
|
full_id = provider_name + "/" + mid
|
|
alias = mid # fallback
|
|
for prefix, name in sorted(alias_prefixes.items(), key=lambda x: -len(x[0])):
|
|
if mid.startswith(prefix):
|
|
alias = name
|
|
break
|
|
config["agents"]["defaults"]["models"][full_id] = {"alias": alias}
|
|
|
|
# Handle primary/backup
|
|
if priority == "1":
|
|
# OCP as primary — pick the best model (prefer sonnet for daily use)
|
|
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
|
|
config["agents"]["defaults"].setdefault("model", {})
|
|
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
|
# Keep existing fallbacks
|
|
config["agents"]["defaults"]["model"].setdefault("fallbacks", [])
|
|
|
|
# If backup (priority == "2"), don't change the primary — just add models to the list
|
|
|
|
# Update agent list entries that use old provider name patterns
|
|
# (only update if agents.list exists and has entries using old OCP-like providers)
|
|
if "list" in config.get("agents", {}):
|
|
for agent in config["agents"]["list"]:
|
|
agent_model = agent.get("model", {})
|
|
if priority == "1" and agent_model.get("primary", "").startswith("claude-local/"):
|
|
# Migrate from claude-local to new provider
|
|
old_model_id = agent_model["primary"].split("/", 1)[1]
|
|
if old_model_id in model_ids:
|
|
agent_model["primary"] = provider_name + "/" + old_model_id
|
|
# Also update subagents if they use claude-local
|
|
sub = agent.get("subagents", config["agents"]["defaults"].get("subagents", {}))
|
|
# Don't modify subagents in agent entries — they inherit from defaults
|
|
|
|
# Update defaults subagents model if using claude-local
|
|
if priority == "1":
|
|
sub = config["agents"]["defaults"].get("subagents", {})
|
|
if sub.get("model", "").startswith("claude-local/"):
|
|
old_id = sub["model"].split("/", 1)[1]
|
|
if old_id in model_ids:
|
|
sub["model"] = provider_name + "/" + old_id
|
|
|
|
with open(config_path, "w") as f:
|
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
# === B1 fix: seed per-agent auth-profiles.json for OpenClaw multi-agent setups ===
|
|
# OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the
|
|
# root openclaw.json's auth.profiles section). Without a real key in each agent's
|
|
# agentDir, OpenClaw rejects the lane with "No API key found for provider X".
|
|
# See https://github.com/dtzp555-max/ocp/issues/12 for the full investigation.
|
|
_seeded = []
|
|
_failed = []
|
|
_skipped_anonymous = []
|
|
_profile_key = provider_name + ":default"
|
|
# OpenClaw stores per-agent auth in <openclaw_root>/agents/<id>/agent/ when
|
|
# the agent has no explicit `agentDir` field — derive that path so the
|
|
# default `main` agent (which never sets agentDir) is also seeded.
|
|
_openclaw_root = os.path.dirname(config_path)
|
|
for _agent in config.get("agents", {}).get("list", []):
|
|
_agent_dir = _agent.get("agentDir")
|
|
if not _agent_dir:
|
|
_agent_id = _agent.get("id")
|
|
if not _agent_id:
|
|
continue
|
|
_agent_dir = os.path.join(_openclaw_root, "agents", _agent_id, "agent")
|
|
if not api_key:
|
|
# Anonymous mode is incompatible with OpenClaw per-agent auth (empty key
|
|
# is dropped by OpenClaw's pi-auth-credentials). Per OCP issue #12 §14
|
|
# decision: take Path C (require --key for OpenClaw multi-agent setups).
|
|
_skipped_anonymous.append(_agent_dir)
|
|
continue
|
|
_profiles_path = os.path.join(_agent_dir, "auth-profiles.json")
|
|
try:
|
|
os.makedirs(_agent_dir, exist_ok=True)
|
|
except OSError as _e:
|
|
_failed.append((_profiles_path, "mkdir: " + str(_e)))
|
|
continue
|
|
if os.path.exists(_profiles_path):
|
|
try:
|
|
with open(_profiles_path) as _f:
|
|
_ap = json.load(_f)
|
|
except json.JSONDecodeError:
|
|
# Corrupted JSON — back up and rebuild from scratch.
|
|
_bak = _profiles_path + ".bak"
|
|
try:
|
|
os.rename(_profiles_path, _bak)
|
|
print(f" ⚠ {_profiles_path} was corrupt; backed up to {_bak}")
|
|
except OSError:
|
|
pass
|
|
_ap = {"version": 1, "profiles": {}}
|
|
except OSError as _e:
|
|
# Read failure (permissions, disk) — skip to AVOID clobbering
|
|
# the user's existing profile (which may hold other providers' keys).
|
|
_failed.append((_profiles_path, "read: " + str(_e)))
|
|
continue
|
|
else:
|
|
_ap = {"version": 1, "profiles": {}}
|
|
_ap.setdefault("version", 1)
|
|
_ap.setdefault("profiles", {})
|
|
_ap["profiles"][_profile_key] = {
|
|
"type": "api_key",
|
|
"provider": provider_name,
|
|
"key": api_key
|
|
}
|
|
try:
|
|
with open(_profiles_path, "w") as _f:
|
|
json.dump(_ap, _f, indent=2, ensure_ascii=False)
|
|
_f.write("\n")
|
|
os.chmod(_profiles_path, 0o600)
|
|
_seeded.append(_profiles_path)
|
|
except OSError as _e:
|
|
_failed.append((_profiles_path, "write: " + str(_e)))
|
|
|
|
# Report — all three sections are independent (mixed scenarios are surfaced).
|
|
if _seeded:
|
|
print(f" ✓ Per-agent auth profile seeded ({len(_seeded)}):")
|
|
for _p in _seeded:
|
|
print(f" • {_p}")
|
|
if _failed:
|
|
print(f" ⚠ Per-agent auth profile FAILED ({len(_failed)}):")
|
|
for _p, _err in _failed:
|
|
print(f" • {_p}: {_err}")
|
|
print(" OpenClaw will report \"No API key found\" for the failed agents.")
|
|
print(" Fix the underlying error (permissions / disk) and re-run ocp-connect.")
|
|
if _skipped_anonymous:
|
|
print(f" ⚠ OpenClaw multi-agent mode detected ({len(_skipped_anonymous)} agents).")
|
|
print(" Anonymous mode does not work for OpenClaw per-agent auth.")
|
|
print(" Re-run with: ocp-connect <host> --key ocp_xxx")
|
|
PYEOF
|
|
|
|
if [[ $py_ok -eq 1 ]]; then
|
|
echo " ✓ OpenClaw configured"
|
|
echo " Provider: $provider_name"
|
|
echo " Models:"
|
|
# List models from the already-fetched models_out
|
|
echo "$models_out" | python3 -c "
|
|
import sys,json
|
|
d=json.loads(sys.stdin.read())
|
|
pn='$provider_name'
|
|
for m in d.get('data',[]):
|
|
print(' • ' + pn + '/' + m['id'])
|
|
" 2>/dev/null
|
|
if [[ "$priority_choice" == "1" ]]; then
|
|
echo " Priority: PRIMARY (default model)"
|
|
else
|
|
echo " Priority: BACKUP (available in model selector)"
|
|
fi
|
|
echo ""
|
|
echo " Restart OpenClaw to apply: openclaw gateway restart"
|
|
else
|
|
echo " ⚠ Failed to write OpenClaw config. You can configure it manually."
|
|
fi
|
|
else
|
|
echo " Skipped OpenClaw configuration."
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# --- Other IDEs: print manual instructions ---
|
|
local other_ides_shown=false
|
|
|
|
# Collect VS Code extension list once (reused by Cline and Continue.dev checks)
|
|
local _vscode_exts=""
|
|
if command -v code &>/dev/null; then
|
|
_vscode_exts=$(code --list-extensions 2>/dev/null || true)
|
|
fi
|
|
if [[ -z "$_vscode_exts" && -d "$HOME/.vscode/extensions" ]]; then
|
|
_vscode_exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
|
fi
|
|
|
|
# Extract model IDs from /v1/models response for use in hints
|
|
local _model_ids
|
|
_model_ids=$(echo "$models_out" | python3 -c "
|
|
import sys,json
|
|
try:
|
|
d=json.loads(sys.stdin.read())
|
|
print(', '.join(m['id'] for m in d.get('data', [])))
|
|
except Exception:
|
|
print('claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001')
|
|
" 2>/dev/null || echo "claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001")
|
|
|
|
# Key display: truncate if longer than 16 chars to avoid screenshot leakage,
|
|
# show explicit "(none)" in anonymous mode so users don't paste blank fields.
|
|
local _key_display
|
|
if [[ -z "$key" ]]; then
|
|
_key_display="(none — anonymous mode; most external IDEs require a non-empty API Key)"
|
|
elif [[ ${#key} -gt 16 ]]; then
|
|
_key_display="${key:0:8}...${key: -4}"
|
|
else
|
|
_key_display="$key"
|
|
fi
|
|
|
|
# Detect Cline (VS Code extension saoudrizwan.claude-dev, see issue #12)
|
|
if echo "$_vscode_exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Cline: VSCode → Cline panel → Settings → API Provider = \"OpenAI Compatible\""
|
|
echo " Base URL: $base_url/v1"
|
|
echo " API Key: $_key_display"
|
|
echo " Model ID: $_model_ids"
|
|
fi
|
|
|
|
# Detect Continue.dev (extension ID continue.continue or config file, see issue #12)
|
|
if echo "$_vscode_exts" | grep -qi 'continue\.continue' \
|
|
|| [[ -f "$HOME/.continue/config.yaml" ]] \
|
|
|| [[ -f "$HOME/.continue/config.json" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Continue.dev: edit ~/.continue/config.yaml — add under \`models:\` (top-level key):"
|
|
echo " models:"
|
|
echo " - name: OCP Sonnet"
|
|
echo " provider: openai"
|
|
echo " model: claude-sonnet-4-6"
|
|
echo " apiBase: $base_url/v1"
|
|
echo " apiKey: $_key_display"
|
|
echo " (other model IDs: $_model_ids)"
|
|
fi
|
|
|
|
# Detect Cursor (command, ~/.cursor dir, or /Applications/Cursor.app, see issue #12)
|
|
if command -v cursor &>/dev/null \
|
|
|| [[ -d "$HOME/.cursor" ]] \
|
|
|| [[ -d "/Applications/Cursor.app" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Cursor: Cmd+Shift+P → 'Cursor Settings' → Models →"
|
|
echo " OpenAI API Key: $_key_display"
|
|
echo " Override OpenAI Base URL: $base_url/v1"
|
|
echo " Custom OpenAI Models: $_model_ids"
|
|
fi
|
|
|
|
# Detect opencode (https://opencode.ai, SST team CLI, see issue #12)
|
|
if command -v opencode &>/dev/null \
|
|
|| [[ -x "$HOME/.opencode/bin/opencode" ]] \
|
|
|| [[ -d "$HOME/.local/share/opencode" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • opencode: not yet auto-configured by ocp-connect (PR follow-up)."
|
|
echo " Run \`opencode providers login openai\` and provide:"
|
|
echo " Base URL: $base_url/v1"
|
|
echo " API Key: $_key_display"
|
|
echo " Available model IDs: $_model_ids"
|
|
fi
|
|
|
|
if $other_ides_shown; then
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
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'}"
|
|
[[ -z "$key" ]] && { echo "Error: --key cannot be empty (omit --key entirely for anonymous mode)"; exit 1; }
|
|
shift 2 ;;
|
|
--version) show_version; exit 0 ;;
|
|
--help|-h) show_help; exit 0 ;;
|
|
-*) echo "Unknown option: $1"; show_help; exit 1 ;;
|
|
*) host="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$host" ]]; then
|
|
echo "Error: host IP is required."
|
|
echo ""
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
echo "Error: invalid host '$host'"
|
|
exit 1
|
|
fi
|
|
|
|
# Check dependencies
|
|
for cmd in curl python3; do
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
echo "Error: '$cmd' is required but not found."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
local base_url="http://$host:$port"
|
|
|
|
echo "OCP Connect v$OCP_CONNECT_VERSION"
|
|
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)."
|
|
exit 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 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
|
# The server advertises anonymousKey in /health ONLY when the admin has set
|
|
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
|
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
|
# Localhost callers always receive it regardless. When the field is absent,
|
|
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
|
if [[ -z "$key" ]]; then
|
|
local anon_key
|
|
anon_key=$(echo "$health_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
k = d.get('anonymousKey')
|
|
except Exception:
|
|
k = None
|
|
print(k if k else '')
|
|
" 2>/dev/null || echo "")
|
|
if [[ -n "$anon_key" ]]; then
|
|
key="$anon_key"
|
|
local _anon_display="$anon_key"
|
|
if [[ ${#anon_key} -gt 16 ]]; then
|
|
_anon_display="${anon_key:0:8}...${anon_key: -4}"
|
|
fi
|
|
echo " ⓘ Using server-advertised anonymous key: $_anon_display"
|
|
echo " (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)"
|
|
echo ""
|
|
fi
|
|
fi
|
|
|
|
# Step 3: Determine if key is needed
|
|
if [[ "$auth_mode" != "none" && -z "$key" ]]; then
|
|
# Try anonymous access first (zero-config: server may allow it)
|
|
if curl -sf --max-time 5 "$base_url/v1/models" >/dev/null 2>&1; then
|
|
echo " Server allows anonymous access — no key needed."
|
|
echo ""
|
|
else
|
|
echo " Remote requires authentication."
|
|
echo " Ask the OCP admin to run: ocp keys add <name>"
|
|
printf " Enter API key (or press Enter to skip): "
|
|
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
|
echo
|
|
if [[ -z "$key" ]]; then
|
|
echo ""
|
|
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
|
echo " Use: ocp-connect $host --key <key>"
|
|
exit 1
|
|
fi
|
|
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."
|
|
exit 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 files and OS
|
|
local rc_files=()
|
|
local is_mac=false
|
|
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
|
|
|
# Write to all relevant rc files
|
|
if [[ "${SHELL:-}" == */fish ]]; then
|
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
|
rc_files+=("$HOME/.bashrc")
|
|
elif $is_mac; then
|
|
# macOS: default shell since Catalina (2019) is zsh.
|
|
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
|
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
|
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
|
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
|
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
|
rc_files+=("$HOME/.zshrc")
|
|
else
|
|
# Linux / other: write to whichever rc files already exist or match current shell
|
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
|
# If neither exists, fall back to creating one for the current shell
|
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
|
fi
|
|
|
|
# Step 6: Remove any previously written OCP LAN lines (idempotent) from all rc files
|
|
for rc_file in "${rc_files[@]}"; do
|
|
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
|
|
done
|
|
|
|
# Step 7: Append new config to all rc files
|
|
for rc_file in "${rc_files[@]}"; do
|
|
{
|
|
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"
|
|
done
|
|
|
|
echo " Shell config:"
|
|
for rc_file in "${rc_files[@]}"; do
|
|
echo " ✓ $(basename "$rc_file")"
|
|
done
|
|
echo " OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo " OPENAI_API_KEY=${key:0:8}..."
|
|
fi
|
|
|
|
# Step 7b: System-level env vars (for IDEs, daemons, GUI apps)
|
|
if $is_mac; then
|
|
# macOS: launchctl setenv makes vars visible to all GUI apps and launchd services
|
|
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null
|
|
if [[ -n "$key" ]]; then
|
|
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo " System-level (launchctl):"
|
|
echo " ✓ OPENAI_BASE_URL set for GUI apps and daemons"
|
|
echo " Note: launchctl vars reset on reboot. Add to Login Items or re-run ocp-connect."
|
|
else
|
|
# Linux: write to environment.d for systemd user services
|
|
local env_dir="$HOME/.config/environment.d"
|
|
mkdir -p "$env_dir" 2>/dev/null
|
|
{
|
|
echo "OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo "OPENAI_API_KEY=$key"
|
|
fi
|
|
} > "$env_dir/ocp.conf"
|
|
echo ""
|
|
echo " System-level (systemd):"
|
|
echo " ✓ $env_dir/ocp.conf"
|
|
echo " Applies to systemd user services after re-login."
|
|
fi
|
|
echo ""
|
|
|
|
# Step 7c: Interactive IDE configuration
|
|
configure_ides "$base_url" "$key" "$models_out"
|
|
|
|
# Step 8: Quick smoke test
|
|
echo " Running smoke test..."
|
|
# Pick the first available model from /v1/models
|
|
local smoke_model
|
|
smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001")
|
|
local chat_payload="{\"model\":\"$smoke_model\",\"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"
|
|
echo " Note: smoke test only verifies OCP is reachable and the key is valid."
|
|
echo " It does not verify your IDE/agent end-to-end. To verify OpenClaw works,"
|
|
echo " restart it (\`openclaw gateway restart\`) and send a test message to your bot."
|
|
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:"
|
|
for rc_file in "${rc_files[@]}"; do
|
|
echo " source $rc_file"
|
|
done
|
|
}
|
|
|
|
main "$@"
|