Compare commits

...
Author SHA1 Message Date
taodengandClaude Opus 4.7 8bb43ef9ec fix(security): /api/usage default scope = self; admin all-keys requires ?all=true
Identified during privacy/security audit (.claude/research/ocp-security-audit.md
§3 "/api/usage 全量泄露所有 key 时序"). Previously, any caller authenticated as
admin received the full per-key usage byKey + recent + timeline block, which
means a brief admin-token compromise exposes every household member's request
volume, timing, and which-model — metadata, but sensitive metadata.

This is a deliberate breaking change for least-privilege.

Behavior matrix (post-change):

  Caller                                  | Default scope     | ?all=true
  ----------------------------------------|-------------------|---------------------
  anonymous (PROXY_ANONYMOUS_KEY)         | own ("anonymous") | ignored (still own)
  authenticated non-admin key             | own (key.name)    | ignored (still own)
  admin (no flag)                         | own ("admin")     | n/a
  admin with ?all=true                    | n/a               | full byKey/recent
  localhost-no-token / "local"            | own ("local")     | full (isAdmin=true)

Response shape is unchanged except for an additional advisory `scope` object
(`{self, all}`); existing fields keep their structure so dashboards and scripts
continue to parse cleanly — they just see less data unless they opt in.

Audit-friendly addition: when admin opts into `?all=true`, the server now
emits `logEvent("info", "admin_usage_full_scope", { caller, ip })` so every
privilege-escalation moment is recorded.

Backward-compat warning for admin scripts:
existing cron jobs / scripts that call `/api/usage` to inventory all keys must
add `?all=true` after this change. Default-self is the new safe behavior.

Dashboard (dashboard.html): adds a "Show all keys" checkbox in the Usage by
Key section. Hidden by default; revealed only when refreshKeys() succeeds
(same admin gate as the keys-management section). Toggle state persists in
localStorage as `ocp_usage_show_all`.

cli.js citation
---------------
This is OCP-internal admin API behavior — there is no `cli.js` operation to
cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations that exist
in `cli.js`) does not constrain access-control rules for the OCP server's
own admin endpoints.

Smoke test (isolated test server on :3489, prod :3478 untouched):
- node --check server.mjs       SYNTAX_OK
- npm test                      43/43 passed
- alignment.yml blacklist grep  BLACKLIST_CLEAR
- LAN scope matrix              alice/bob own only; alice ?all=true denied;
                                anon ?all=true denied; admin all=true full +
                                audit log emitted

Iron Rule 10
------------
Author cannot self-approve. A separate fresh-context reviewer must read the
diff and confirm the cli.js-not-applicable statement is correct before merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:51:30 +10:00
a71c939bf8 docs(readme): remove zhihu blog backlink + badge (article was removed) (#85)
The Chinese blog post linked from these two places was taken down by
the 知乎 platform shortly after publication. Both pointers now resolve
to a "content removed" warning page, which is a worse signal to
visitors than no link at all. Removing both before they reach more
README readers.

Reverts the additions from #81 specifically:
- Top badges row: drop the "blog · engineering story" shield
- §Why OCP? closing blockquote: drop the line pointing at the article

The 知乎 article URL is no longer reachable, so retaining the references
would route incoming traffic to a dead page that suggests the project
is itself problematic. Better to leave that section silent until a
working canonical write-up exists somewhere.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:43:59 +10:00
d245c62df7 docs(images): replace dashboard.png with multi-client + multi-session traffic (#84)
Continues #82 / #83 — those captures had stats counters at 0 / 3
respectively, with 0 active sessions. The Sessions card therefore
visually undermined the rest of the dashboard.

Re-captured after deliberately seeding LAN-side traffic from both
Pi231 and MacBook clients (each holding a per-key API token issued
on the Mac mini server), mixing single-turn and multi-turn requests
with distinct session_id values to populate the in-memory sessions
Map.

New screenshot data points:

- Uptime: 21h 56m
- Requests: 24 / 0 active (up from 3 / 0)
- Errors: 0 / 0 timeouts
- Sessions: 8 active (was 0)
- Plan Usage: 5h 20%, weekly 28%
- byKey table: 11 keys with rich history, now including
  pi231-test + macbook-test rows from this round
- Recent Requests: visible row count grown

Multi-turn correctness was incidentally verified during seeding —
a Pi231 request with session_id=pi-multi-01 turn 2 correctly
recalled the number passed in turn 1 ("the number 7"), confirming
session state persists across cli.js subprocess turns as designed.

Capture method unchanged (Chrome headless, 1400x2400,
--virtual-time-budget=14000).

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:05:28 +10:00
047750e642 docs(images): replace dashboard.png with stats-populated version (#83)
#82 captured the dashboard mid-idle — server had been up 21h with
zero traffic since restart, so the in-memory stats counters all
showed 0 (Requests 0 / Errors 0 / Sessions 0). That made the top
strip of the screenshot look like a dead service even though the
historical byKey + Plan Usage data below were healthy.

Re-captured after firing 3 small haiku requests through localhost
to populate stats.totalRequests. The new screenshot now shows:

- Status: ok / v3.13.0
- Uptime: 21h 39m (up from 21h 28m)
- Requests: 3 / 0 active (was 0 / 0)
- Plan Usage: 5h 17%, weekly 28% (was 13% / 27%)
- Recent Requests: 2 visible rows showing model + latency + status
  (was empty)

Same capture method as #82: Chrome headless --window-size=1400,2400
--virtual-time-budget=12000.

The 3 priming requests were short haiku prompts ("reply with the single
word OKn"), max_tokens=12 each. Plan-usage delta < 1%, byKey table
already had 11 active keys with rich history so the priming did not
distort the long-tail data.

This addresses feedback that the previous screenshot's empty stats
strip undermined the rest of the dashboard's data richness for first-
time README readers.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:29:36 +10:00
3bdeb50ed5 docs(images): refresh dashboard.png with current prod data (#82)
The previous dashboard.png was captured on day-1, before any real
traffic — the screenshot was effectively empty (no Plan Usage bars,
single API key, no Recent Requests). It made the Web Dashboard look
like a placeholder rather than a working observability surface.

Replaced with a fresh capture of the maintainer's Mac mini production
OCP (running v3.13.0, multi auth) showing:

- Status / uptime / active+queued counters
- Plan Usage bars (5h: 13%, weekly: 27%) — real subscription draw
- Usage by Key table — 11 active keys with request counts, success/error
  rates, average latency, last-seen timestamps
- API Keys section — all 32 registered keys with truncated key prefixes
  + creation date + active/revoked status (truncation matches existing
  dashboard rendering, no full keys are exposed)
- Recent Requests log — request stream with model, prompt size, latency

Capture method: Chrome headless (no Playwright extension required) at
1400x2400, --virtual-time-budget=12000 to let dashboard JS finish
fetching + rendering before the screenshot frame.

PNG dimensions: 1400 x 2400 (was 1400 x 1739). The added height comes
from the now-populated Usage by Key + API Keys + Recent Requests
sections — not from layout changes.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:18:13 +10:00
fbbf3b6c7c docs(readme): add engineering-story backlink to 知乎 article + badge (#81)
Adds two pointers from README to a Chinese-language engineering blog
post (https://zhuanlan.zhihu.com/p/2036388634207770402) that walks
through OCP's cli.js-alignment philosophy, the 2026-04-11 drift
incident (the 9-day hallucinated /api/oauth/usage endpoint), the
three-tier guardrail design, and the recent fresh-state E2E pass
that produced PRs #74-#78.

Two minimal touches to README only:

1. Badges row: a "blog · engineering story" shield linking to the
   article. Slot fits between the existing Release badge and the
   Buy Me a Coffee badge so the row's visual rhythm is preserved.

2. New blockquote line at the end of §Why OCP? (between the
   single-maintainer / pre-1.0 disclosure and §Supported Tools).
   Brief, factual, no marketing voice.

Why this PR

The 知乎 piece is a self-contained engineering narrative about
maintaining a single-maintainer LLM-assisted proxy without endpoint
drift. README is the project's primary surface; pointing at the
narrative lets readers who land on the repo decide if the project's
discipline matters to them before they invest in install. Reverse
direction: GitHub readers who follow the link bring some traffic
back to the article, validating that engineering content has a
home.

Doc-only change. server.mjs untouched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply. Same pattern as #68 / #69 / #71 / #78 / #79.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:32:52 +10:00
d760d7fcce docs(readme): add restrained star + issue CTA below tagline (#79)
* docs(install): remove false symlink claim, fix Anonymous mode startup command

Three doc inaccuracies surfaced during fresh-state install testing on Pi231 +
MacBook (Round 1+2):

1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for
   CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but
   creates no PATH symlink. Removed the line; added a short PATH tip showing
   the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or
   shell alias) right after the install summary.

2. README §Anonymous Access told users to run `ocp start` to enable the
   feature — there is no `ocp start` subcommand. Available commands are
   restart / stop / status / logs / keys / usage / update / lan / health /
   clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with
   the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs
   --bind 0.0.0.0 --auth-mode multi`.

3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an
   interactive shell is enough to enable anonymous access — but the running
   proxy is auto-started by launchd/systemd from the service unit's own
   env, not from the user's shell. Spelled this out and noted that if OCP
   was installed before exporting the env var, the user must re-run
   setup.mjs (idempotent) so the service unit env is refreshed, then
   `ocp restart`.

The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when
PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs
on main does not yet inject this env into the service unit.

Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation
required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(readme): add restrained star + issue CTA below tagline

A single italic line under the existing personal-note italic, asking
readers to  the repo if they get value, and to file issues — framed
explicitly as "issues are even more useful than stars" so the CTA reads
as feedback-seeking rather than vanity-metric chasing.

Tone matches the rest of README: low-key, single-maintainer self-deprecating,
no marketing voice. No "save money / free / $0" verbiage. Coexists with the
existing buy-me-a-coffee personal-note line above it (different ask:
funding vs. social-proof).

This is doc-only. ALIGNMENT.md Rule 5 (cli.js citation) does not apply.
Same pattern as #68 / #69 / #71 / #78.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 03:01:06 +10:00
5e2effd05b fix(ocp-connect): write ~/.zshrc on macOS (default shell since Catalina) (#77)
macOS default shell has been zsh since Catalina (2019). The previous
rc-file selection logic treated macOS the same as Linux, so on a fresh
Mac where ~/.zshrc did not already exist AND the script was invoked via
`bash -s --` (e.g. `curl | bash`), the $SHELL guard was `/bin/bash`
and neither condition for zshrc was true — resulting in only ~/.bashrc
being written. Since ~/.bashrc is not sourced by interactive zsh
sessions, the OPENAI_BASE_URL / OPENAI_API_KEY exports were invisible
to interactive shells.

Fix:
- Add an explicit `elif $is_mac` branch that unconditionally includes
  ~/.zshrc: create the file (empty) if it does not yet exist, since
  zsh tolerates an empty ~/.zshrc.
- On macOS, ~/.bashrc is only written if it already exists — consistent
  with the task spec ("don't create ~/.bashrc if it didn't exist").
- Linux path is preserved unchanged.
- Fix the "Reload your shell" hint at script end: previously it printed
  only the last loop variable `$rc_file` (stale reference outside the
  loop). Now it iterates `${rc_files[@]}` so both files are shown on
  macOS (reproducing the Round A bug: hint said only `source ~/.bashrc`
  even when zshrc should also be reloaded).

Smoke-tested with HOME=/tmp/fakehome redirect for three scenarios:
  1. Fresh MacBook (no .bashrc, no .zshrc)  → only .zshrc created+written
  2. macOS with existing .bashrc             → both .bashrc and .zshrc written
  3. Linux with bash, no .bashrc             → .bashrc written (unchanged)

Identified during Round A testing on MacBook 2026-05-08.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:50:07 +10:00
fb2d1d3feb fix(ocp-cli): replace eval-curl with bash array to preserve JSON body quoting (#74)
`eval curl "$_AUTH_HEADER" "$@"` re-tokenizes its argument list according
to bash word-splitting rules. When OCP_ADMIN_KEY is set, the JSON body
`'{"name": "laptop"}'` (which contains a space) gets split into
`'{name:'` and `'laptop}'` — two separate args — so curl receives a
malformed body and the server rejects the request.

Fix: replace the `_AUTH_HEADER` string + `eval` pattern with a bash array
`_AUTH_ARGS`. Array expansion via `"${_AUTH_ARGS[@]}"` preserves word
boundaries across substitution with no eval required. Both code paths
(OCP_ADMIN_KEY env var and ~/.ocp/admin-key file fallback) and the empty
case (no admin key) are preserved unchanged.

Verified via `bash -x` trace:
  Before: `curl … -d '{name:' 'laptop}'` (body split, malformed)
  After:  `curl … -d '{"name": "testkey-pr-c"}'` (body intact, single arg)

Identified during fresh-state Round 2 testing on MacBook.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:50:01 +10:00
12b09c236e fix(server): resolve claude binary from nvm/fnm/asdf and PATH fallback (#75)
Real-world macOS dev machines using nvm-managed Node hit a startup FATAL
because the hardcoded candidate list in resolveClaude() only covered
homebrew, /usr/local, /usr/bin, and ~/.local/bin. With Claude CLI
installed at $HOME/.nvm/versions/node/<v>/bin/claude, the launchd job
failed without manual CLAUDE_BIN injection.

Fix: extend the candidate list with user-local Node version manager
paths — nvm (with default-alias), fnm, asdf, and npm-prefix-relocated
$HOME/.npm-global/bin. The existing CLAUDE_BIN env override and `which`
fallback are preserved; resolution order is now explicit CLAUDE_BIN >
hardcoded list > nvm/fnm/asdf > which > FATAL (with the message
upgraded to mention CLAUDE_BIN as a hint).

This is OCP-internal binary discovery — there is no `cli.js` operation
to cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations
that exist in `cli.js`) does not constrain runtime path discovery for
the OCP server itself.

Smoke tests:
- default (no CLAUDE_BIN): picks /opt/homebrew/bin/claude (unchanged)
- CLAUDE_BIN=/nonexistent/claude: fail-fast preserved
- HOME=/tmp/fakenvm with synthetic .nvm tree: candidate list contains
  the fake nvm path; alias-default unshift logic verified
- npm test: 43/43 unit tests pass
- node --check server.mjs: OK
- alignment.yml blacklist grep: no hits

Identified during fresh-state Round 2 testing on MacBook.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:49:55 +10:00
c0f2d3ab20 docs(install): remove false symlink claim, fix Anonymous mode startup command (#78)
Three doc inaccuracies surfaced during fresh-state install testing on Pi231 +
MacBook (Round 1+2):

1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for
   CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but
   creates no PATH symlink. Removed the line; added a short PATH tip showing
   the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or
   shell alias) right after the install summary.

2. README §Anonymous Access told users to run `ocp start` to enable the
   feature — there is no `ocp start` subcommand. Available commands are
   restart / stop / status / logs / keys / usage / update / lan / health /
   clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with
   the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs
   --bind 0.0.0.0 --auth-mode multi`.

3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an
   interactive shell is enough to enable anonymous access — but the running
   proxy is auto-started by launchd/systemd from the service unit's own
   env, not from the user's shell. Spelled this out and noted that if OCP
   was installed before exporting the env var, the user must re-run
   setup.mjs (idempotent) so the service unit env is refreshed, then
   `ocp restart`.

The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when
PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs
on main does not yet inject this env into the service unit.

Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation
required.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:49:49 +10:00
0d61da5153 fix(setup): inject CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY into service env (#76)
Gap #1 partial (CLAUDE_BIN): setup.mjs now detects `which claude` at install
time (or reads $CLAUDE_BIN) and writes CLAUDE_BIN into the service unit
EnvironmentVariables dict. Works for nvm/homebrew paths not in server.mjs's
hardcoded list, and for older server.mjs deployments.

Gap #2 (OCP_ADMIN_KEY): reads $OCP_ADMIN_KEY from the user's shell env and
conditionally injects it into the plist/systemd unit. Empty/unset → key is
omitted entirely (server.mjs treats empty string as "no admin"). Key value is
never logged; only its length is reported.

Gap #6 partial (PROXY_ANONYMOUS_KEY): reads $PROXY_ANONYMOUS_KEY and
conditionally injects it. Unset → key is omitted (anonymous access disabled).

All three keys are read via process.env at install time; no new CLI flags.
Injection status is logged before the !DRY_RUN guard so dry-run shows what
would be written.

Identified during fresh-state Round 2 testing.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:49:43 +10:00
7 changed files with 186 additions and 26 deletions
+9 -2
View File
@@ -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).*
*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.
```
@@ -185,7 +187,8 @@ The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Start the proxy on port 3456
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:
```bash
@@ -395,11 +398,15 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
**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
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.
**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.
+21 -2
View File
@@ -55,7 +55,13 @@
</div>
<div class="section">
<h2>Usage by Key</h2>
<div class="flex" style="justify-content: space-between; align-items: center;">
<h2 style="margin: 0;">Usage by Key</h2>
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
<span>Show all keys</span>
</label>
</div>
<table id="key-usage-table">
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
<tbody></tbody>
@@ -170,7 +176,8 @@ async function refreshStatus() {
async function refreshUsage() {
try {
const data = await api("/api/usage");
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
<tr>
@@ -204,6 +211,8 @@ async function refreshKeys() {
try {
const data = await api("/api/keys");
document.getElementById("key-mgmt-section").style.display = "";
// Admin-only "Show all keys" toggle for /api/usage scope.
document.getElementById("usage-scope-toggle").style.display = "flex";
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
<tr>
@@ -240,6 +249,16 @@ async function refreshAll() {
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
(function setupUsageScopeToggle() {
const cb = document.getElementById("usage-show-all");
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
cb.addEventListener("change", () => {
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
refreshUsage();
});
})();
refreshAll();
setInterval(refreshAll, 30000);
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 366 KiB

+6 -9
View File
@@ -8,21 +8,18 @@ set -euo pipefail
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_HEADER=""
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
# Using a bash array preserves word boundaries — no eval needed.
_AUTH_ARGS=()
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
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
# Wrapper: curl with optional auth
_curl() {
if [[ -n "$_AUTH_HEADER" ]]; then
eval curl "$_AUTH_HEADER" "$@"
else
curl "$@"
fi
curl "${_AUTH_ARGS[@]}" "$@"
}
_json() { python3 -m json.tool 2>/dev/null || cat; }
+13 -3
View File
@@ -582,11 +582,19 @@ print(k if k else '')
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
# 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/.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")
fi
@@ -705,7 +713,9 @@ PYEOF
echo ""
echo " Done. Reload your shell to apply:"
echo " source $rc_file"
for rc_file in "${rc_files[@]}"; do
echo " source $rc_file"
done
}
main "$@"
+83 -8
View File
@@ -30,7 +30,7 @@
import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process";
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 { dirname, join } from "node:path";
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"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
// 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() {
if (process.env.CLAUDE_BIN) {
try {
@@ -54,11 +94,13 @@ function resolveClaude() {
}
}
const home = process.env.HOME || "";
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
join(home, ".local/bin/claude"),
..._collectNodeManagerCandidates(home),
];
for (const p of candidates) {
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(
"FATAL: claude binary not found.\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(", ")
);
process.exit(1);
@@ -1572,14 +1616,45 @@ const server = createServer(async (req, res) => {
}
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
// Least-privilege scope rules (security audit follow-up):
// - non-admin authenticated key → only own rows
// - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
// - admin without ?all=true → only own ("admin") rows
// - admin with ?all=true → full byKey/recent (legacy behavior); audited
// Authenticated callers are required (anyone reaching here passed the auth gate above);
// remote+no-auth requests would have been rejected before this point.
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
const since = url.searchParams.get("since");
const until = url.searchParams.get("until");
const wantAll = url.searchParams.get("all") === "true";
const callerName = req._authKeyName;
// Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
const isAnonCaller = callerName === "anonymous";
const fullScope = isAdmin && wantAll && !isAnonCaller;
// scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
const scopeName = fullScope ? null : callerName;
if (fullScope) {
logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
}
const byKeyAll = getUsageByKey({ since, until });
const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
const timeline = getUsageTimeline({
keyName: scopeName || undefined,
hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
});
const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
return jsonResponse(res, 200, {
byKey: getUsageByKey({ since, until }),
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
byKey,
timeline,
recent,
scope: { self: scopeName, all: fullScope },
});
}
+54 -2
View File
@@ -39,6 +39,29 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
const BIND_ADDRESS = opt("bind", "127.0.0.1");
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) ──────────
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
@@ -286,6 +309,29 @@ banner.push(`╚═════════════════════
console.log("\n" + banner.join("\n") + "\n");
// ── 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) {
console.log("\n🔄 Installing auto-start on login...\n");
@@ -358,7 +404,13 @@ if (!DRY_RUN) {
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<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>
<key>RunAtLoad</key>
<true/>
@@ -396,7 +448,7 @@ After=network.target
ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT}
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
RestartSec=5
StandardOutput=append:${logPath}