Files
ocp/ocp-connect
T
36be723198 fix(security): gate /health anonymousKey behind opt-in PROXY_ADVERTISE_ANON_KEY (#109) (#116)
/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>
2026-05-31 22:12:26 +10:00

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 "$@"