diff --git a/.github/workflows/alignment.yml b/.github/workflows/alignment.yml index 0e8f2ca..3bfe3aa 100644 --- a/.github/workflows/alignment.yml +++ b/.github/workflows/alignment.yml @@ -4,6 +4,11 @@ on: pull_request: paths: - 'server.mjs' + - 'setup.mjs' + - 'scripts/**' + - 'lib/**' + - 'ocp' + - 'ocp-connect' - '.github/workflows/alignment.yml' jobs: @@ -66,6 +71,80 @@ jobs: echo "Blacklist scan clean." + port-spot: + name: port literal SPOT (hard fail) + # Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13 + # a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded + # into wrong baseUrl writes for the OpenClaw "claude-local" provider, + # taking out the "大内总管" Telegram agent. + # + # Rule: the only places allowed to write a literal port number in source + # are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect + # (which can't import .mjs and must keep the literal in sync — flagged + # with a `// keep in sync with lib/constants.mjs` style comment), and + # (c) test-features.mjs (intentionally pins historical ports for plist / + # systemd parser tests). Everything else MUST import from lib/constants.mjs. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Scan for hardcoded port literals outside SPOT + shell: bash + run: | + set -euo pipefail + + # Files/paths exempt from the SPOT requirement. + EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)' + + # Hardcoded port literals to forbid in non-exempt source. + FORBIDDEN_PORTS=("3478" "3456") + + FAIL=0 + for port in "${FORBIDDEN_PORTS[@]}"; do + HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \ + | xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \ + | grep -v -E "${EXEMPT_REGEX}" \ + || true)" + if [ -n "$HITS" ]; then + echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:" + echo "$HITS" + FAIL=1 + fi + done + + if [ "$FAIL" -ne 0 ]; then + cat <<'EOF' + + ============================================================ + PORT LITERAL SPOT VIOLATION + ============================================================ + A hardcoded TCP port literal was found in a source file + that should import from lib/constants.mjs instead. + + Background: this rule exists because between 2026-05-08 and + 2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs + and scripts/doctor.mjs cascaded into downstream OpenClaw + config writes, taking out the OpenClaw Telegram agent. + See v3.16.3 CHANGELOG and lib/constants.mjs header comment. + + Required action: + 1. Import DEFAULT_PORT (or related constant) from + lib/constants.mjs instead of hardcoding the literal. + 2. If the file genuinely cannot import .mjs (e.g. bash + script), add it to EXEMPT_REGEX in this workflow and + add a `keep in sync with lib/constants.mjs` comment + at the reference. + 3. For test files that intentionally pin historical ports + (test-features.mjs), the regex already exempts them. + + ============================================================ + EOF + exit 1 + fi + + echo "Port SPOT scan clean." + commit-citation: name: commit message citation (soft check) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e3b2d5..641dbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## v3.16.4 — 2026-05-13 + +### Refactor — port-literal SPOT + CI guardrail + +Closes the structural side of the port-drift cascade addressed by v3.16.2 +and v3.16.3. Those two releases reverted plist / plugin / scripts back to +3456 line-by-line, but the underlying invitation to drift — a hardcoded +port literal scattered across six source files — was still intact. + +Changes: + +- **New `lib/constants.mjs`** — single source of truth for shared literals. + Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`, + `OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`. +- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`, + `scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** — + all replaced with imports from `lib/constants.mjs`. Behavior is + identical; the literal `3456` now exists in exactly one place per + language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for + bash, `test-features.mjs` for pinned historical-port tests). +- **`.github/workflows/alignment.yml`** — extended the path filter to + `setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new + `port-spot` hard-fail job that greps for any hardcoded `3478` or `3456` + literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists + `lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the + workflow itself). Any future PR re-introducing a hardcoded port + literal will be blocked at CI before it can cascade. +- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage + banner reworded so the literal `3456` no longer appears as + documentation text (CI grep is intentionally aggressive — it does not + parse comments — so doc strings reference `DEFAULT_PORT from + lib/constants.mjs` instead). + +No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains +the runtime override; the only difference is the unset-env fallback +now flows through one shared constant. + +ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line +import + one literal swap, mechanical). No cli.js operation changed; +the citation requirement does not apply. SPOT principle (Rule 2 spirit) +is the entire motivation. + ## v3.16.3 — 2026-05-13 ### Fixes — completes v3.16.2 port-drift revert diff --git a/lib/constants.mjs b/lib/constants.mjs new file mode 100644 index 0000000..b18596b --- /dev/null +++ b/lib/constants.mjs @@ -0,0 +1,33 @@ +/** + * OCP shared constants — single source of truth. + * + * Any literal that appears in more than one place across server.mjs, setup.mjs, + * scripts/* belongs here so port-drift / URL-drift cascades cannot recur. + * + * Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13 + * (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs + * cascaded into every downstream config write, ultimately taking out the + * OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3. + * + * Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers. + * If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh) + * that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note + * at the shell-script reference; CI grep prevents drift. + */ + +// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT +// at runtime; this is the fallback when env is unset. +// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs, +// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal +// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml). +export const DEFAULT_PORT = 3456; + +// Localhost bind for client-side fetches (curl, health checks). +export const LOCAL_HOST = "127.0.0.1"; + +// OpenAI-compatible API base path appended to the proxy URL. +export const OPENAI_API_BASE = "/v1"; + +// Convenience: full local URL the OCP proxy listens on by default. +// scripts that want to probe locally can use this directly. +export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`; diff --git a/package.json b/package.json index 47825d4..268bcec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-claude-proxy", - "version": "3.16.3", + "version": "3.16.4", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "type": "module", "bin": { diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index a88748b..c8445c1 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; +import { DEFAULT_PORT } from "../lib/constants.mjs"; const SCHEMA_VERSION = "1"; @@ -81,7 +82,7 @@ export async function runDoctor(opts = {}) { health = opts.mockHealth; } else { try { - const port = process.env.CLAUDE_PROXY_PORT || "3456"; + const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT); const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString(); health = { status: 200, body: JSON.parse(out) }; } catch (e) { @@ -202,7 +203,7 @@ function runOauthOnly(opts, checks, push) { health = opts.mockHealth; } else { try { - const port = process.env.CLAUDE_PROXY_PORT || "3456"; + const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT); const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString(); health = { status: 200, body: JSON.parse(out) }; } catch (e) { diff --git a/scripts/sync-openclaw.mjs b/scripts/sync-openclaw.mjs index b114371..b6904fa 100644 --- a/scripts/sync-openclaw.mjs +++ b/scripts/sync-openclaw.mjs @@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; +import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, ".."); @@ -70,7 +71,7 @@ if (!config.models.providers) config.models.providers = {}; if (!config.models.providers[PROVIDER_NAME]) { // First-time registration config.models.providers[PROVIDER_NAME] = { - baseUrl: "http://127.0.0.1:3456/v1", + baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`, api: "openai-completions", authHeader: false, models: desiredModels, diff --git a/scripts/upgrade.mjs b/scripts/upgrade.mjs index d3db021..7e92957 100644 --- a/scripts/upgrade.mjs +++ b/scripts/upgrade.mjs @@ -15,6 +15,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync, copyFileSync } from "node:fs"; import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs"; +import { DEFAULT_PORT } from "../lib/constants.mjs"; export async function runUpgrade(opts = {}) { const dryRun = !!opts.dryRun; @@ -134,7 +135,7 @@ async function runFullUpgrade({ doctor, opts }) { // phase 6: post-flight (10s budget; skipped under mockExec) if (!opts.mockExec) { - const port = process.env.CLAUDE_PROXY_PORT || "3456"; + const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT); let ok = false; for (let i = 0; i < 10; i++) { try { diff --git a/server.mjs b/server.mjs index 6e48a47..c085fce 100644 --- a/server.mjs +++ b/server.mjs @@ -11,7 +11,7 @@ * This matches LiteLLM, OpenAI SDK, and other major LLM proxies. * * Env vars: - * CLAUDE_PROXY_PORT — listen port (default: 3456) + * CLAUDE_PROXY_PORT — listen port (default: DEFAULT_PORT from lib/constants.mjs) * CLAUDE_BIN — path to claude binary (default: auto-detect) * CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000) * CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set) @@ -35,6 +35,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; +import { DEFAULT_PORT } from "./lib/constants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); @@ -123,7 +124,7 @@ function resolveClaude() { // ── Configuration ─────────────────────────────────────────────────────── // Settings marked with `let` can be changed at runtime via PATCH /settings. -const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); +const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10); const CLAUDE = resolveClaude(); let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10); const PROXY_API_KEY = process.env.PROXY_API_KEY || ""; diff --git a/setup.mjs b/setup.mjs index 68959ac..f356555 100755 --- a/setup.mjs +++ b/setup.mjs @@ -3,7 +3,8 @@ * OCP (Open Claude Proxy) setup * * Automatically configures OpenClaw to use Claude CLI as a model provider. - * Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run] + * Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run] + * (default port = DEFAULT_PORT from lib/constants.mjs) * * What it does: * 1. Verifies claude CLI is installed and authenticated @@ -18,6 +19,7 @@ import { execSync } from "node:child_process"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; +import { DEFAULT_PORT } from "./lib/constants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const HOME = homedir(); @@ -32,7 +34,7 @@ const opt = (name, fallback) => { return i >= 0 && args[i + 1] ? args[i + 1] : fallback; }; -const PORT = parseInt(opt("port", "3456"), 10); +const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10); const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku const DRY_RUN = flag("dry-run"); const SKIP_START = flag("no-start");