mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e1f0d18e | ||
|
|
a71c939bf8 | ||
|
|
d245c62df7 | ||
|
|
047750e642 | ||
|
|
3bdeb50ed5 | ||
|
|
fbbf3b6c7c | ||
|
|
d760d7fcce | ||
|
|
5e2effd05b | ||
|
|
fb2d1d3feb | ||
|
|
12b09c236e | ||
|
|
c0f2d3ab20 | ||
|
|
0d61da5153 | ||
|
|
49baffe2da | ||
|
|
4b01d4e768 | ||
|
|
36fa81d1e6 |
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||||
|
|
||||||
|
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
||||||
|
|
||||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -83,13 +85,96 @@ OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Quick install with AI assistance
|
||||||
|
|
||||||
|
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
|
||||||
|
|
||||||
|
**Single-machine use** — install OCP for IDEs on this same machine only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this machine to use my Claude Pro/Max subscription
|
||||||
|
as an OpenAI-compatible API for local IDEs.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Server Setup" → "Single-machine use" path:
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
|
||||||
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
|
using my system's package manager.
|
||||||
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
|
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
|
||||||
|
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||||
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
|
Before each step, tell me what you'll run and wait for confirmation.
|
||||||
|
On any error, diagnose first — don't auto-retry.
|
||||||
|
```
|
||||||
|
|
||||||
|
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this device as a LAN server so my family and other
|
||||||
|
devices on the network can share my Claude Pro/Max subscription.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Server Setup" → "LAN mode" path:
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
|
||||||
|
22.5+, git, Claude CLI installed and authenticated.
|
||||||
|
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
|
||||||
|
I'll need it to manage per-user keys later.
|
||||||
|
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
|
||||||
|
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
|
||||||
|
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||||
|
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||||
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
|
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
|
||||||
|
|
||||||
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client connect** — configure this device to use an existing OCP server on your LAN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
|
||||||
|
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude
|
||||||
|
Code, OpenClaw).
|
||||||
|
|
||||||
|
Server IP: <SERVER_IP>
|
||||||
|
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Client Setup" path:
|
||||||
|
|
||||||
|
1. Download ocp-connect:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
chmod +x ocp-connect
|
||||||
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
|
||||||
|
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||||
|
|
||||||
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you'd rather do everything manually, the **Server Setup** and **Client Setup** sections below have the same steps in handbook form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Server Setup
|
### Server Setup
|
||||||
|
|
||||||
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
|
||||||
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
||||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
- `git`
|
||||||
|
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
|
||||||
|
```bash
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
|
||||||
|
```
|
||||||
|
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone and run setup
|
# 1. Clone and run setup
|
||||||
@@ -102,7 +187,8 @@ The setup script will:
|
|||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Start the proxy on port 3456
|
2. Start the proxy on port 3456
|
||||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
|
||||||
|
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
||||||
|
|
||||||
**Single-machine use** — just set your IDE to use the proxy:
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
```bash
|
```bash
|
||||||
@@ -117,7 +203,9 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
|||||||
|
|
||||||
Then create API keys for each person/device:
|
Then create API keys for each person/device:
|
||||||
```bash
|
```bash
|
||||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
# Generate a strong admin key (one-time — save it for later key management):
|
||||||
|
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
|
||||||
|
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
|
||||||
|
|
||||||
ocp keys add wife-laptop
|
ocp keys add wife-laptop
|
||||||
# ✓ Key created for "wife-laptop"
|
# ✓ Key created for "wife-laptop"
|
||||||
@@ -136,6 +224,22 @@ curl http://127.0.0.1:3456/v1/models
|
|||||||
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Headless install notes
|
||||||
|
|
||||||
|
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
|
||||||
|
|
||||||
|
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
|
||||||
|
|
||||||
|
**Option 2 — long-lived token (auth once, no re-prompts).**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude setup-token # subscription-backed long-lived token
|
||||||
|
```
|
||||||
|
|
||||||
|
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
|
||||||
|
|
||||||
|
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Uninstall
|
### Uninstall
|
||||||
@@ -152,6 +256,8 @@ Removes the launchd (macOS) or systemd (Linux) auto-start entry. Handles both le
|
|||||||
### Client Setup
|
### Client Setup
|
||||||
|
|
||||||
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||||
|
>
|
||||||
|
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
|
||||||
|
|
||||||
**One-command setup** — download the lightweight `ocp-connect` script:
|
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||||
|
|
||||||
@@ -292,11 +398,15 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
|
|||||||
|
|
||||||
**Enable**:
|
**Enable**:
|
||||||
|
|
||||||
|
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||||
ocp start # or however you start the server
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||||
|
|
||||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||||
|
|
||||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||||
@@ -597,6 +707,37 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Setup fails with "claude: command not found"
|
||||||
|
|
||||||
|
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
||||||
|
|
||||||
|
### Setup fails with "EADDRINUSE: port 3456 already in use"
|
||||||
|
|
||||||
|
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -nP -iTCP:3456 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
If it's an old OCP process, stop it before re-running setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp stop # if the CLI is on PATH
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback
|
||||||
|
sudo systemctl stop ocp-proxy # Linux systemd fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup fails with "node: command not found" or version error
|
||||||
|
|
||||||
|
OCP requires Node.js 22.5+. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node # macOS
|
||||||
|
# Linux: see https://nodejs.org/en/download for current install commands
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm with `node --version` (should be ≥ v22.5).
|
||||||
|
|
||||||
### Requests fail or agents stuck
|
### Requests fail or agents stuck
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 366 KiB |
@@ -8,21 +8,18 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROXY="http://127.0.0.1:3456"
|
PROXY="http://127.0.0.1:3456"
|
||||||
|
|
||||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||||
_AUTH_HEADER=""
|
# Using a bash array preserves word boundaries — no eval needed.
|
||||||
|
_AUTH_ARGS=()
|
||||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
||||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wrapper: curl with optional auth
|
# Wrapper: curl with optional auth
|
||||||
_curl() {
|
_curl() {
|
||||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
curl "${_AUTH_ARGS[@]}" "$@"
|
||||||
eval curl "$_AUTH_HEADER" "$@"
|
|
||||||
else
|
|
||||||
curl "$@"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||||
|
|||||||
+12
-2
@@ -582,11 +582,19 @@ print(k if k else '')
|
|||||||
if [[ "${SHELL:-}" == */fish ]]; then
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
rc_files+=("$HOME/.bashrc")
|
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
|
else
|
||||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
# Linux / other: write to whichever rc files already exist or match current shell
|
||||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||||
# If neither exists, create for current shell
|
# If neither exists, fall back to creating one for the current shell
|
||||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -705,7 +713,9 @@ PYEOF
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Done. Reload your shell to apply:"
|
echo " Done. Reload your shell to apply:"
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
echo " source $rc_file"
|
echo " source $rc_file"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+88
-26
@@ -30,7 +30,7 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync } from "node:child_process";
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
import { readFileSync, readdirSync, accessSync, existsSync, constants } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -41,8 +41,48 @@ const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
|||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||||
|
|
||||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||||
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
||||||
// Fail-fast if not found — never start with an unresolvable binary.
|
// installs > which lookup. Fail-fast if not found — never start with an
|
||||||
|
// unresolvable binary.
|
||||||
|
function _listVersionDirs(parent) {
|
||||||
|
try { return readdirSync(parent); } catch { return []; }
|
||||||
|
}
|
||||||
|
function _collectNodeManagerCandidates(home) {
|
||||||
|
if (!home) return [];
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
// nvm: $HOME/.nvm/versions/node/<version>/bin/claude
|
||||||
|
const nvmRoot = join(home, ".nvm/versions/node");
|
||||||
|
for (const v of _listVersionDirs(nvmRoot)) {
|
||||||
|
out.push(join(nvmRoot, v, "bin/claude"));
|
||||||
|
}
|
||||||
|
// nvm default alias: resolve $HOME/.nvm/aliases/default if it points to a version
|
||||||
|
try {
|
||||||
|
const aliasFile = join(home, ".nvm/aliases/default");
|
||||||
|
const aliasVer = readFileSync(aliasFile, "utf8").trim();
|
||||||
|
if (aliasVer) {
|
||||||
|
const direct = join(nvmRoot, aliasVer, "bin/claude");
|
||||||
|
if (!out.includes(direct)) out.unshift(direct);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// fnm: $HOME/.fnm/node-versions/<version>/installation/bin/claude
|
||||||
|
const fnmRoot = join(home, ".fnm/node-versions");
|
||||||
|
for (const v of _listVersionDirs(fnmRoot)) {
|
||||||
|
out.push(join(fnmRoot, v, "installation/bin/claude"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// asdf: $HOME/.asdf/installs/nodejs/<version>/bin/claude
|
||||||
|
const asdfRoot = join(home, ".asdf/installs/nodejs");
|
||||||
|
for (const v of _listVersionDirs(asdfRoot)) {
|
||||||
|
out.push(join(asdfRoot, v, "bin/claude"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// npm prefix-relocated: $HOME/.npm-global/bin/claude
|
||||||
|
out.push(join(home, ".npm-global/bin/claude"));
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
function resolveClaude() {
|
function resolveClaude() {
|
||||||
if (process.env.CLAUDE_BIN) {
|
if (process.env.CLAUDE_BIN) {
|
||||||
try {
|
try {
|
||||||
@@ -54,11 +94,13 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const home = process.env.HOME || "";
|
||||||
const candidates = [
|
const candidates = [
|
||||||
"/opt/homebrew/bin/claude",
|
"/opt/homebrew/bin/claude",
|
||||||
"/usr/local/bin/claude",
|
"/usr/local/bin/claude",
|
||||||
"/usr/bin/claude",
|
"/usr/bin/claude",
|
||||||
join(process.env.HOME || "", ".local/bin/claude"),
|
join(home, ".local/bin/claude"),
|
||||||
|
..._collectNodeManagerCandidates(home),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||||
@@ -72,6 +114,8 @@ function resolveClaude() {
|
|||||||
console.error(
|
console.error(
|
||||||
"FATAL: claude binary not found.\n" +
|
"FATAL: claude binary not found.\n" +
|
||||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||||
|
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
||||||
|
" shown by `which claude` in your interactive shell.\n" +
|
||||||
" Checked: " + candidates.join(", ")
|
" Checked: " + candidates.join(", ")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -158,25 +202,36 @@ const MODEL_MAP = Object.fromEntries([
|
|||||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||||
|
|
||||||
// ── Session management ──────────────────────────────────────────────────
|
// ── Session management ──────────────────────────────────────────────────
|
||||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
// Maps namespaced session keys to Claude CLI session UUIDs.
|
||||||
|
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
|
||||||
|
// when two callers (different API keys or anon + authenticated) use the same
|
||||||
|
// session_id string. Anonymous callers use "anon"; admin uses "admin".
|
||||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
||||||
|
|
||||||
|
// Build the namespaced key used for all sessions Map operations.
|
||||||
|
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
|
||||||
|
function _sessionKey(conversationId, keyName) {
|
||||||
|
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionCleanupInterval = setInterval(() => {
|
const sessionCleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
const idleMs = now - s.lastUsed;
|
const idleMs = now - s.lastUsed;
|
||||||
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
||||||
|
// id is "${keyName}|${conversationId}"; strip prefix for log output
|
||||||
|
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
|
||||||
if (idleMs > SESSION_TTL) {
|
if (idleMs > SESSION_TTL) {
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
|
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
||||||
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
||||||
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
||||||
// is the suspected bug. Log without action so the pattern can be confirmed
|
// is the suspected bug. Log without action so the pattern can be confirmed
|
||||||
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
||||||
logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("warn", "session_long_lived", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -383,7 +438,7 @@ function getModelTier(cliModel) {
|
|||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// timeouts. Returns context object or throws synchronously.
|
||||||
function spawnClaudeProcess(model, messages, conversationId) {
|
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||||
}
|
}
|
||||||
@@ -399,8 +454,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
// ── Session logic ──
|
// ── Session logic ──
|
||||||
if (conversationId && sessions.has(conversationId)) {
|
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
||||||
const session = sessions.get(conversationId);
|
// when two callers with different API keys share the same conversationId string.
|
||||||
|
const sessionKey = _sessionKey(conversationId, keyName);
|
||||||
|
if (sessionKey && sessions.has(sessionKey)) {
|
||||||
|
const session = sessions.get(sessionKey);
|
||||||
session.lastUsed = Date.now();
|
session.lastUsed = Date.now();
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
sessionInfo = { uuid: session.uuid, resume: true };
|
||||||
stats.sessionHits++;
|
stats.sessionHits++;
|
||||||
@@ -411,17 +469,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
: "";
|
: "";
|
||||||
session.messageCount = messages.length;
|
session.messageCount = messages.length;
|
||||||
|
|
||||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||||
|
|
||||||
} else if (conversationId) {
|
} else if (sessionKey) {
|
||||||
const uuid = randomUUID();
|
const uuid = randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
||||||
sessionInfo = { uuid, resume: false };
|
sessionInfo = { uuid, resume: false };
|
||||||
stats.sessionMisses++;
|
stats.sessionMisses++;
|
||||||
prompt = messagesToPrompt(messages);
|
prompt = messagesToPrompt(messages);
|
||||||
|
|
||||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
@@ -466,11 +524,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && conversationId) {
|
if (sessionInfo?.resume && sessionKey) {
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
||||||
sessions.delete(conversationId);
|
sessions.delete(sessionKey);
|
||||||
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
|
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
||||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
||||||
// in the sessions map. Log without action so the staleness pattern can be
|
// in the sessions map. Log without action so the staleness pattern can be
|
||||||
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
||||||
@@ -513,11 +571,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||||
function callClaude(model, messages, conversationId) {
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
@@ -597,7 +655,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
@@ -1280,7 +1338,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||||
if (recheck) return recheck.response;
|
if (recheck) return recheck.response;
|
||||||
const c = await callClaude(model, messages, conversationId);
|
const c = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
@@ -1302,7 +1360,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||||
try {
|
try {
|
||||||
const content = await callClaude(model, messages, conversationId);
|
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
@@ -1436,8 +1494,10 @@ const server = createServer(async (req, res) => {
|
|||||||
const uptimeMs = Date.now() - START_TIME;
|
const uptimeMs = Date.now() - START_TIME;
|
||||||
const sessionList = [];
|
const sessionList = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
sessionList.push({
|
sessionList.push({
|
||||||
id: id.slice(0, 12) + "...",
|
id: convId.slice(0, 12) + "...",
|
||||||
model: s.model,
|
model: s.model,
|
||||||
messages: s.messageCount,
|
messages: s.messageCount,
|
||||||
idleMs: Date.now() - s.lastUsed,
|
idleMs: Date.now() - s.lastUsed,
|
||||||
@@ -1482,7 +1542,9 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.url === "/sessions" && req.method === "GET") {
|
if (req.url === "/sessions" && req.method === "GET") {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
|
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 200, { sessions: list });
|
return jsonResponse(res, 200, { sessions: list });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* 4. Creates start.sh for easy launch
|
* 4. Creates start.sh for easy launch
|
||||||
* 5. Optionally starts the proxy
|
* 5. Optionally starts the proxy
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -39,6 +39,29 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|||||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||||
|
|
||||||
|
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
||||||
|
// These are read from the user's shell env at install time and written into
|
||||||
|
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
||||||
|
|
||||||
|
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
||||||
|
let CLAUDE_BIN_INJECT = null;
|
||||||
|
if (process.env.CLAUDE_BIN) {
|
||||||
|
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
||||||
|
if (detected && existsSync(detected)) {
|
||||||
|
CLAUDE_BIN_INJECT = detected;
|
||||||
|
}
|
||||||
|
} catch { /* which not available or claude not on PATH — omit */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
||||||
|
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||||
|
|
||||||
|
// PROXY_ANONYMOUS_KEY — same pattern
|
||||||
|
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||||
|
|
||||||
// ── Models: derived from models.json (single source of truth) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -107,11 +130,18 @@ try {
|
|||||||
warn("Make sure you're logged in: claude login");
|
warn("Make sure you're logged in: claude login");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check openclaw config
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||||
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
|
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
||||||
|
if (OPENCLAW_PRESENT) {
|
||||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||||
|
} else {
|
||||||
|
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
||||||
|
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
||||||
|
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
||||||
|
if (OPENCLAW_PRESENT) {
|
||||||
console.log("\n📝 Configuring OpenClaw...\n");
|
console.log("\n📝 Configuring OpenClaw...\n");
|
||||||
|
|
||||||
const config = readJSON(CONFIG_PATH);
|
const config = readJSON(CONFIG_PATH);
|
||||||
@@ -175,8 +205,6 @@ const agentDirs = existsSync(agentsDir)
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
|
|
||||||
for (const agentId of agentDirs) {
|
for (const agentId of agentDirs) {
|
||||||
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
||||||
try {
|
try {
|
||||||
@@ -208,6 +236,7 @@ for (const agentId of agentDirs) {
|
|||||||
if (agentDirs.length === 0) {
|
if (agentDirs.length === 0) {
|
||||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
||||||
console.log("\n🚀 Creating launcher...\n");
|
console.log("\n🚀 Creating launcher...\n");
|
||||||
@@ -238,33 +267,71 @@ if (!DRY_RUN) {
|
|||||||
log(`Launcher: ${startPath}`);
|
log(`Launcher: ${startPath}`);
|
||||||
|
|
||||||
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
||||||
console.log(`
|
const banner = [
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
`╔══════════════════════════════════════════════════════════════╗`,
|
||||||
║ Setup complete! ║
|
`║ Setup complete! ║`,
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
`╠══════════════════════════════════════════════════════════════╣`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Provider: ${PROVIDER_NAME.padEnd(44)}║
|
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
||||||
║ Port: ${String(PORT).padEnd(44)}║
|
`║ Port: ${String(PORT).padEnd(44)}║`,
|
||||||
║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║
|
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
||||||
║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║
|
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Start proxy: ║
|
`║ Start proxy: ║`,
|
||||||
║ bash ${startPath.replace(HOME, "~").padEnd(50)}║
|
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Or directly: ║
|
`║ Or directly: ║`,
|
||||||
║ node ${serverPath.replace(HOME, "~").padEnd(49)}║
|
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Set as default model in openclaw.json: ║
|
];
|
||||||
║ agents.defaults.model.primary = ║
|
if (OPENCLAW_PRESENT) {
|
||||||
║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║
|
banner.push(
|
||||||
║ ║
|
`║ Set as default model in openclaw.json: ║`,
|
||||||
║ Then restart gateway: ║
|
`║ agents.defaults.model.primary = ║`,
|
||||||
║ openclaw gateway restart ║
|
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
`║ Then restart gateway: ║`,
|
||||||
`);
|
`║ openclaw gateway restart ║`,
|
||||||
|
`║ ║`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
banner.push(
|
||||||
|
`║ OpenClaw not detected — running in standalone mode. ║`,
|
||||||
|
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
||||||
|
`║ Aider / OpenClaw) at: ║`,
|
||||||
|
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
||||||
|
`║ ║`,
|
||||||
|
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
||||||
|
`║ ║`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
||||||
|
console.log("\n" + banner.join("\n") + "\n");
|
||||||
|
|
||||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||||
|
|
||||||
|
// Log service-env injection plan (shown in both dry-run and live mode)
|
||||||
|
console.log("\n🔧 Service unit env vars to inject:\n");
|
||||||
|
if (CLAUDE_BIN_INJECT) {
|
||||||
|
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
||||||
|
} else {
|
||||||
|
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
||||||
|
}
|
||||||
|
if (OCP_ADMIN_KEY_INJECT) {
|
||||||
|
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
||||||
|
} else {
|
||||||
|
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
||||||
|
}
|
||||||
|
if (PROXY_ANON_KEY_INJECT) {
|
||||||
|
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
||||||
|
} else {
|
||||||
|
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log("\n [dry-run] would write service unit with above env vars\n");
|
||||||
|
}
|
||||||
|
|
||||||
if (!DRY_RUN) {
|
if (!DRY_RUN) {
|
||||||
console.log("\n🔄 Installing auto-start on login...\n");
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
@@ -337,7 +404,13 @@ if (!DRY_RUN) {
|
|||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${BIND_ADDRESS}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
<string>${AUTH_MODE_CONFIG}</string>
|
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||||
|
<key>CLAUDE_BIN</key>
|
||||||
|
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||||
|
<key>OCP_ADMIN_KEY</key>
|
||||||
|
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||||
|
<key>PROXY_ANONYMOUS_KEY</key>
|
||||||
|
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -375,7 +448,7 @@ After=network.target
|
|||||||
ExecStart=${nodeBin} ${serverPath}
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
|
|||||||
Reference in New Issue
Block a user