184 Commits
Author SHA1 Message Date
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
fe615cb0d3 chore(release): v3.20.1 — TUI credential-isolated auth (ends recurring 401) + defunct-session reaping (#141) (#142)
Bump 3.20.0 → 3.20.1 + CHANGELOG. Ships the already-merged, twice-reviewed #141
(credential-isolated env-token home + zombie reaping). README/docs updated in #141.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
v3.20.1
2026-06-13 16:55:59 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
60930f0ba4 fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions

Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).

Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).

Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.

Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.

Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>

* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)

Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
6394ca3) is necessary but INSUFFICIENT to fix the PI231 401: interactive `claude`
PREFERS ~/.claude/.credentials.json over the env var (unlike `-p`, where the env
token wins), so a stale/corrupt credentials.json SHADOWS the env token. Decisive
live evidence on PI231 (claude 2.1.104):

  - env token passed + a broken ~/.claude/.credentials.json present → 401
    ("Please run /login · API Error: 401").
  - env token passed + credentials.json moved aside              → real answer.

Fix: when CLAUDE_CODE_OAUTH_TOKEN is set (and OCP_TUI_HOME is unset), run the TUI
`claude` in a CREDENTIAL-FREE scratch home (<HOME>/.ocp-tui/home) that has NO
credentials.json — no symlink, no copy. The env token is then the only credential
and is authoritative because nothing shadows it. This ALSO ends the original
refresh-corruption incident (25-zombie / empty-refresh-token) at the ROOT: with no
credentials file, claude never runs the token-refresh path, so the single-use
refresh token can never be rotated/corrupted by the per-request spawn+kill cycle.

This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern. The old
caveat was about a SYMLINKED credentials.json being forked on token refresh; in
env-token mode there is no credentials file to fork and no refresh ever happens.

Mechanism: scratch HOME (not CLAUDE_CONFIG_DIR). The claude binary supports
CLAUDE_CONFIG_DIR, but it relocates transcripts to <CONFIG_DIR>/projects/ rather
than <HOME>/.claude/projects/, forking the transcript-resolution rule across modes
for no benefit. Scratch-HOME reuses the existing, tested prepareTuiHome/ehome
plumbing; readTuiTranscript reads from the same home claude runs under, so
transcripts land under the scratch home and findTranscriptPath globs them there.

Backward compatible: when CLAUDE_CODE_OAUTH_TOKEN is unset, behaviour is byte-for-
byte unchanged (real home + credentials.json) so hosts that intentionally rely on
credentials.json are unaffected. Explicit OCP_TUI_HOME still wins. Onboarding +
cwd-trust are seeded in the scratch .claude.json (hasCompletedOnboarding=true +
trust ONLY the scratch cwd) so no interactive trust/onboarding dialog can hang the
turn.

Changes:
- lib/tui/session.mjs: add resolveTuiHome() (pure) + DEFAULT_TUI_SCRATCH_HOME;
  prepareTuiHome() gains { envTokenMode } — skips the credentials symlink and seeds
  a minimal .claude.json; runTuiTurn derives envTokenMode = token set && ehome!==rhome.
- server.mjs: TUI_HOME computed via resolveTuiHome(); boot log surfaces the auth mode.
- test-features.mjs: env-token credential-free prepareTuiHome test (asserts NO
  credentials.json created/symlinked, .claude.json seeded with onboarding + cwd
  trust) + 3 resolveTuiHome decision tests; existing buildTuiCmd-token + reaper +
  legacy/real-home tests stay green (245 passed, 0 failed).
- docs/adr/0007: PR-D amendment (corrects the PR-C rationale + the original
  scratch-home caveat); README Troubleshooting #401 + env-var table + TUI section.

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js has no analogue for the TUI pane's
auth/home strategy — authorized by ADR 0007 (PR-D amendment) per ALIGNMENT.md's
Class B citation requirement. No Class A wire path, no alignment.yml blacklist
token, no models.json touched. server.mjs is touched only to wire TUI_HOME via
resolveTuiHome() and surface auth mode in the boot log.

Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:54:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-fable-5> <noreply@anthropic.com>
c86e3d014f chore(release): v3.20.0 — TUI billing-safety hardening for 2026-06-15 (PR-A/B/C, #137-139) (#140)
Bump 3.19.0 → 3.20.0 + CHANGELOG. Aggregates three already-reviewed, already-merged PRs:
- #137 (PR-A) TUI honesty/cache correctness
- #139 (PR-B) TUI concurrency limit + /health observability
- #138 (PR-C) 6/15 canary + flip/rollback runbooks + setup auth-probe guard

README env-var / endpoint tables were updated in the respective feature PRs.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-fable-5> <noreply@anthropic.com>
v3.20.0
2026-06-10 21:56:56 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3322d7bdae feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).

C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.

C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.

ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).

Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:54:07 +10:00
79c1d61e1d docs(tui): 6/15 canary + flip/rollback runbooks + setup auth-probe guard (PR-C) (#138)
Docs scope (no server.mjs touched):
- docs/runbooks/615-canary.md — executable canary runbook: quiesce host,
  read Agent SDK credit balance manually (no programmatic API exists for
  that pool — this is stated explicitly to avoid endpoint hallucination),
  send one Haiku turn via TUI-mode, confirm cc_entrypoint=cli in transcript,
  re-read balance, green/red decision tree, periodic self-classification
  mini-canary with OCP_TUI_ENTRYPOINT=auto.
- docs/runbooks/tui-flip-rollback.md — flip and rollback procedure for
  systemd (EnvironmentFile + daemon-reload) and launchd (plist edit +
  bootout/bootstrap cycle). Calls out both known pitfalls explicitly:
  daemon-reload required on systemd; launchctl kickstart -k does NOT
  reload plist env on launchd (matches MEMORY.md operational pit entry).
- README.md § "2026-06-15 operator checklist" — brief fleet checklist +
  pointers to both runbooks. Placed inside the existing TUI-mode section
  just before "Architecture and design decisions".
- README.md § Environment Variables — adds OCP_SKIP_AUTH_TEST row.

Operator-tooling scope (setup.mjs, not a Class A wire path):
- setup.mjs auth quick-test: wraps the existing claude -p probe in an
  OCP_SKIP_AUTH_TEST=1 gate; adds inline comment warning that after
  2026-06-15 the probe draws from the Agent SDK credit pool. The setup
  flow is unchanged when OCP_SKIP_AUTH_TEST is unset.

Alignment note: this PR does not touch server.mjs. The setup.mjs change
is operator-tooling only — it guards a local test spawn, not any wire
path. No cli.js citation required (no Class A surface is modified).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude claude-sonnet-4-6 <noreply@anthropic.com>
2026-06-10 21:41:03 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
a37ff713d9 fix(tui): honest error/truncation handling + version-robust entrypoint + short-prompt paste (PR-A) (#137)
TUI-mode (CLAUDE_TUI_MODE=true) is an OCP-owned subscription-pool bridge for the
2026-06-15 Anthropic billing split. This PR fixes four honesty/robustness defects
confirmed by a prior audit and live-reproduced on PI231 (2026-06-10).

C-1 (P1) — upstream AUTH-FAILURE banner returned/cached as a real answer.
  The interactive claude CLI renders in-session errors as ordinary assistant text.
  The R-1 case C-1 exists to catch is expired/invalid credentials, where EVERY turn
  comes back as the same one-line auth-failure banner, e.g. the two live PI231
  banners (2026-06-10):
    "Please run /login · API Error: 401 Invalid authentication credentials"  (69 chars)
    "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
  callClaudeTui returned that banner verbatim → OCP cached it, shared it via
  singleflight, and recorded a model SUCCESS. New detectTuiUpstreamError()
  (lib/tui/transcript.mjs) flags such a turn; callClaudeTui throws tui_upstream_error
  + logEvent("error", …) BEFORE recordModelSuccess/cache write-back, so the error
  never enters the cache; the client gets a 5xx.

  C-1 NARROWING (false-positive probe, 2026-06-10). An earlier generalised default
  rule — ^<short auth-failure prefix>?API Error: <3-digit> <detail>$ — was TOO BROAD:
  the unbounded ".*" detail tail let any short prefix + "API Error: NNN" + an
  arbitrarily long sentence match, so it KILLED legitimate long answers that merely
  DISCUSS an API error (e.g. "API Error: 500 happened because the server was
  overloaded. To fix this, retry with exponential backoff …"). A false-positive costs
  the user a missing answer AND a double-burn retry — strictly worse than the rare
  false-negative (caching one transient error for the 5-min TTL). C-1 is therefore
  reframed from "detect any API error" to "detect a claude-CLI AUTHENTICATION-FAILURE
  banner", and is CONSERVATIVE: when unsure it PASSES. The narrowed default detector
  flags a turn only when ALL of the following hold over the WHOLE trimmed text
  (a conjunction; any one failing => PASS):
    1. SHORT whole-message — length ≤ 100 (live banners are 69/73; cap gives headroom
       while rejecting multi-sentence prose; a 226-char auth answer is dropped on
       length alone).
    2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403); transient 5xx
       and bare "HTTP 401 means unauthorized." (no API-Error core) are excluded.
    3. Contains an auth keyword — authenticat | /login | credential (case-insensitive);
       rejects "To debug a 401 … API Error: 401 Unauthorized …" (authoriz-, not
       authenticat-).
    4. Contains NO backtick/quote char (` ' ") — a real banner is plain text; quoted
       text signals an answer QUOTING the error, e.g. "You'll see `API Error: 401` …
       run /login to fix it." (short + 4xx + /login, excluded only by this signal).
  Full required matrix (2 KILL + 7 PASS) encoded as tests; npm test green.
  CLAUDE_TUI_ERROR_PATTERNS still overrides (a non-empty value REPLACES the default
  with operator regexes; empty/whitespace DISABLES detection). New fixture
  lib/tui/fixtures/error-401-failauth.jsonl + positive/negative tests retained.

C-2 (P1) — wallclock-truncated partial text returned as silent success.
  readTuiTranscript returned {text, entrypoint} identically on the terminal-marker
  path and the cap-with-partial path, so a cut-off turn was cached + returned as
  finish_reason:stop. It now returns truncated:false on terminal-marker and
  truncated:true on the cap-with-partial path (additive field; no-text cap path
  still throws). callClaudeTui throws tui_wallclock_truncated on truncated.

C-3 (P1) — verifyEntrypoint only read the turn_duration line.
  Some claude builds don't emit turn_duration (Mac mini: absent; PI231/2.1.104:
  present), while the entrypoint field is on ordinary lines on BOTH. Reading only
  turn_duration made the server.mjs tui_entrypoint_mismatch assertion get got:null
  every turn on non-emitting builds. verifyEntrypoint now PREFERS the turn_duration
  entrypoint and FALLS BACK to the entrypoint field on any line.

C-4 (P2) — short prompts 100% failed paste-landing.
  tuiPromptLanded required needle.length >= 3, so a 1–2 char first line ("hi","ok")
  never matched and 5s-failed with tui_paste_not_landed every time (live-repro:
  "hi"). Threshold lowered 3 → 2; the input box starts empty (placeholder excluded
  by the affirmative-signal design) so a 2-char needle present in the pane is the
  prompt. Kept >=2 (not >=1) to avoid collisions with claude's chrome glyphs.

Tests: extended test-features.mjs with unit coverage for each fix + three fixtures
(lib/tui/fixtures/error-401.jsonl, error-401-failauth.jsonl, no-turn-duration.jsonl).
The C-1 block now encodes the full narrowed auth-banner matrix (2 must-kill + 7
must-pass + supporting regression guards incl. a length-cap-load-bearing test).
npm test: 224 passed, 0 failed. Default path (CLAUDE_TUI_MODE unset →
upstreamCall=callClaude) is byte-identical: the only server.mjs change is one import
+ the callClaudeTui body, and callClaudeTui is unreachable when TUI_MODE is off.

ALIGNMENT: Class B (OCP-owned compatibility surface). cli.js does not perform this
operation (TUI is an OCP-owned subscription-pool bridge); scope authorized by ADR
0007 (Class B). No Class A path touched (callClaude / handleUsage / OAuth unchanged);
no change to .github/workflows/alignment.yml or any blacklisted token.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:39:01 +10:00
6d4751f983 feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tools for single-user TUI (#135)
* feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tool surface for single-user TUI

Lets a SINGLE-USER / trusted TUI deployment run a tool-using / MCP agent (e.g. an
OpenClaw assistant) on the subscription pool. When OCP_TUI_FULL_TOOLS=1, buildTuiCmd
grants the interactive session the SAME tool surface as the -p A-path — --allowedTools
(+ optional --mcp-config / --dangerously-skip-permissions, read from the same
CLAUDE_ALLOWED_TOOLS / CLAUDE_MCP_CONFIG / CLAUDE_SKIP_PERMISSIONS env as buildCliArgs)
— instead of the default MCP-walled, built-in-tools-only set.

Motivation: the default TUI tool wall (--strict-mcp-config --disallowedTools mcp__*)
exists for multi-tenant safety, but it also blocks a trusted single-operator agent from
its MCP tools — forcing tool-using agents onto the metered -p pool after 2026-06-15.
This gate resolves that for the single-user case.

Safe to gate ON only because TUI is hard-incompatible with AUTH_MODE=multi (server.mjs
refuses to boot, see existing guard), so this can NEVER widen a guest's tool surface.
Default (gate unset) is unchanged: MCP wall + built-in tools only.

Verified live on PI231 through the real OCP TUI path (spike 2026-06-02):
- built-in tools: claude created + read a file on the host (2 tool_use entries);
- MCP: claude invoked a custom MCP server's tool (mcp__spike__echo_marker) and returned
  its output;
- billing: entrypoint=cli (subscription pool) WITH full tools + MCP;
- completion: end_turn, no permission stall, no hang.

Tests: +1 (full-tools branch: --allowedTools present + MCP wall dropped; skip-permissions
supersedes; mcp-config threaded). 195 pass. README: new OCP_TUI_FULL_TOOLS env var entry.

ALIGNMENT.md: changes lib/tui/session.mjs, not server.mjs — server.mjs hard requirements
do not trigger. Mirrors buildCliArgs() permissions logic; the flags are documented Claude
Code CLI flags, not invented endpoints. cli.js citation N/A under Rule 2. ADR 0007 (A-path).

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

* fix(tui): shq operator tool tokens in full-tools shell string (reviewer Finding 2)

Independent reviewer (APPROVE WITH CHANGES) caught that buildTuiCmd returns a SHELL
STRING (run by tmux via sh -c), unlike buildCliArgs which returns an argv array to
spawn(). Operator-supplied CLAUDE_ALLOWED_TOOLS can be a scoped specifier such as
"Bash(npm run test:*)" or "Read(~/**)", whose ( ) * ~ would break / inject the shell
command if pasted bare. Now shq() each allowed-tool token (operator-self-injection only —
guests cannot reach TUI per the multi-mode boot guard, but it's a real correctness bug).

Also: tightened the README OCP_TUI_FULL_TOOLS wording per reviewer Finding 1 — the precise
safety property is "no guest key can reach the TUI path (multi-mode boot is a hard exit)",
and noted the AUTH_MODE=shared + OCP_TUI_ALLOW_LAN trust model is unchanged.

Test: +scoped-specifier assertions (token shq'd; never appears unquoted). 195 pass.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:03:20 +10:00
0dced52215 chore(release): v3.19.0 — TUI large-prompt reliability (#130) + proxy-purity context (#4) (#134)
Release prep only: bump package.json 3.18.0 → 3.19.0, CHANGELOG entry, README TUI
"what changes / what doesn't" note that the host CLAUDE.md/auto-memory is never injected.

Bundles two already-merged, independently-reviewed TUI fixes:
- #130 (PR #131): reliable large multi-line paste + version-robust turn detection.
- #4   (PR #132): never inject host CLAUDE.md / auto-memory into proxied turns.

Both verified live on PI231 + Oracle; adversarial multi-host battery passed
(0 hangs / 0 crashes / 0 injection / 0 leaks). Follow-up: #133.

No server.mjs change in this PR (package.json + CHANGELOG + README only) → cli.js
citation N/A. release_kit (Iron Rule 5.5): version_source=package.json, changelog,
README docs all updated; no new env var / endpoint / subcommand.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
v3.19.0
2026-06-02 07:46:55 +10:00
d291331998 fix(tui): never inject the host's CLAUDE.md / auto-memory into proxied turns (#4) (#132)
OCP is a PROXY, not a Claude Code session. The proxied client (OpenClaw / an IDE)
owns its own context and memory — the HOST's CLAUDE.md and auto-memory must never
leak into the agent OCP runs on the user's behalf.

buildTuiCmd now adds CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 + CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
to the pane-command env-prefix, unconditionally (proxy purity is not an opt-in).

Diagnosis (per Iron Rule 3, diagnose-before-fix) of the earlier "suppression works in
recon but not through OCP" gap: it was NEVER an env-delivery bug. The hosts simply have
no CLAUDE.md to suppress — PI231 has no ~/.claude/CLAUDE.md, no ~/CLAUDE.md, no
~/.claude/memory, no cwd/walk-up CLAUDE.md. The earlier "recon worked" run was on a
machine that DID have a global CLAUDE.md. Different machines, different CLAUDE.md
presence — not a code gap. The ~35K one-line-prompt context is therefore the inherent
floor (interactive system prompt + built-in tool schemas; MCP is already hard-disabled
via --strict-mcp-config), not CLAUDE.md.

Verified live 2026-06-02 with a behavioral marker (Iron Rule 2, evidence-first):
- Planted /home/tlab/.ocp-tui/work/CLAUDE.md = "end every reply with QUACKMARKER_42".
- BEFORE fix, through OCP: "Reply with: hello" -> "hello\n\nQUACKMARKER_42" (host CLAUDE.md obeyed = leaked).
- AFTER fix, same marker file present, through OCP: -> "hello" (marker blocked).
This proves both that the host CLAUDE.md was being injected AND that the env-prefix
delivery of the disable flag reaches claude and stops it.

Tests: buildTuiCmd is now exported; +2 regression guards (suppression present;
version-pin/entrypoint/MCP-wall retained, auto-mode leaves entrypoint unset). 194 pass.

Scope (Iron Rule 11): TUI path only. The -p path has an equivalent but GATED mechanism
(CLAUDE_NO_CONTEXT). Making -p unconditionally proxy-pure is a separate decision, noted
as a follow-up, not bundled here.

ALIGNMENT.md: this changes lib/tui/session.mjs, not server.mjs, so the server.mjs hard
requirements do not trigger. TUI is the ADR-0007 proxy-side execution bridge; the two
flags are documented Claude Code env vars (the same two the -p path already uses), not
invented endpoints — cli.js citation N/A under Rule 2.

Closes #4 (TUI portion).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:32:06 +10:00
9568411bcb fix(tui): reliable large-prompt paste + version-robust turn detection (#130) (#131)
TUI mode hung ("stuck typing") on the OpenClaw agent's real (large, multi-line) prompts.
Three root causes, all fixed:

1. transcript.mjs — terminal detection only recognized {system, turn_duration}, which
   claude 2.1.114 (the cloud host) does not emit → readTuiTranscript never saw completion
   and ran to the 120s wallclock even though claude had answered. Now also treats an
   {assistant} line with message.stop_reason ∈ {end_turn, stop_sequence, max_tokens} as
   terminal (version-robust; stop_reason "tool_use" stays non-terminal, so tool turns are
   not truncated — preserves the v3.17.1 narrowing).

2. session.mjs paste — send-keys -l "$(cat file)" delivers a large multi-line prompt's
   newlines as separate key events (≈repeated Enter), so the prompt never lands. Replaced
   with tmux load-buffer + paste-buffer -p (bracketed paste): atomic, no shell arg limit,
   claude ingests it as one "[Pasted text]".

3. session.mjs verify — the old "placeholder-gone" heuristic false-positived on claude's
   empty curly-quote placeholder, so Enter fired into an empty box (THE root cause of the
   hang). tuiPromptLanded now trusts only positive signals ([Pasted text] indicator or the
   prompt's own text); readiness-poll + paste-verify-poll + fast-fail (tui_paste_not_landed)
   turns a 120s wallclock hang into a deterministic ~5s error.

Also: env delivered to the pane via an `env`-prefix on the pane command (tmux does NOT
forward spawnSync's env, and new-session -e needs tmux ≥3.2 while the cloud host runs 2.7),
carrying DISABLE_AUTOUPDATER (version pin) + CLAUDE_CODE_ENTRYPOINT labeling.

Validated live on both hosts: a 300-line / ~30KB prompt that previously hung now returns
in ~5-8s; small prompts ~4-5s. 192 tests pass (new: terminal-detection across schemas,
positive-signal verify incl. the curly-quote false-positive guard, paste predicates).

Scope discipline: an attempt to also strip claude's CLAUDE.md/auto-memory injection (context
reduction) was REVERTED — it didn't measurably reduce context through OCP, caused an
off-response, and is a separate concern. It will be its own measured branch.

ALIGNMENT.md: TUI is the ADR-0007 proxy-side execution bridge, not a forwarded Anthropic
operation, so a cli.js citation is N/A under Rule 2. No blacklisted tokens / port literals.

Independent fresh-context reviewer (opus): APPROVE WITH CHANGES (Iron Rule 10) — validated
the three fixes, caught that a per-session tmux socket would break the startup stale-session
reaper, and flagged the context-suppression scope-creep; both were reverted per the review.

Closes #130.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:26:25 +10:00
1f577c075f chore(release): v3.18.0 — code-audit hardening (P0 /health + P2/P3 batch) + 3 follow-ups (#129)
Bundles the 2026-05-31 multi-agent code audit remediation and its follow-ups:

- #109 (P0): /health no longer leaks the anonymous quota key to LAN (opt-in PROXY_ADVERTISE_ANON_KEY)
- #110: request-validation + OpenAI-compat correctness
- #111: error-output sanitization + process-lifecycle hardening
- #112: OAuth-host verification + models.json SPOT
- #113: CLI/installer hardening
- #114: dashboard XSS escaping + key-name validation
- #115: TUI non-loopback LAN gate + cc_entrypoint assertion
- #123: alignment.yml wrong-host pin + ALIGNMENT.md amendment
- #124: dashboard status/plan card escaping
- #125: isLoopbackBind extracted to lib/net.mjs

Each landed as its own PR with a fresh-context independent reviewer (Iron Rule 10) and
green alignment CI. Version bumped 3.17.1 → 3.18.0; CHANGELOG finalized. 181 tests pass.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
v3.18.0
2026-06-01 08:47:10 +10:00
6dff36959a chore(governance): pin legacy OAuth host in alignment.yml + ALIGNMENT.md amendment (#123) (#128)
Follow-up to the 2026-05-31 audit (deferred from #112). The OAuth token host
platform.claude.com/v1/oauth/token was verified against the compiled cli.js in #119;
this pins the legacy WRONG host so a future accidental revert hard-fails CI.

- .github/workflows/alignment.yml: add "console.anthropic.com/v1/oauth/token" to the
  BLACKLIST; rewrite the comment + failure message so the blacklist now documents TWO
  kinds of token — known hallucinations AND pinned wrong-host variants of a verified
  Class A endpoint (a hit means a drift, not necessarily a hallucination). The pinned
  token is absent from server.mjs (which uses platform.claude.com), so CI stays green.
- ALIGNMENT.md: new "OAuth token-host verification (2026-05-31)" subsection recording the
  binary verification (claude.exe 2.1.154, strings, no live probe) and the dual-purpose
  blacklist policy. Purely additive; Rules / audit pin / Historical Lesson untouched.

Per ALIGNMENT.md Amendment Procedure: (a) motivating evidence cited (issues #112/#119/#123),
(b) independent fresh-context opus reviewer APPROVE — verified the pinned token does not
trip the build (absent from server.mjs; live host not blacklisted), YAML valid, amendment
consistent with the server.mjs verification comment, purely additive scope. (c) not
incident-driven (a confirming verification, not a new drift) so Historical Lesson unchanged.

Closes #123.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:43:36 +10:00
1b02f181fa refactor: extract isLoopbackBind to lib/net.mjs (#125) (#127)
Follow-up cleanup from #115's review. isLoopbackBind was defined in server.mjs and
copy-pasted into test-features.mjs (with a "keep in sync" comment, because importing
server.mjs would run server.listen()). Extracted to a new importable lib/net.mjs;
server.mjs and the test now import the one definition, removing the drift surface.

Pure refactor — the function body is byte-identical to the prior server.mjs version,
the TUI LAN-gate call site is unchanged, and the 8 isLoopbackBind truth-table tests now
exercise the real shared function. 181 tests pass.

ALIGNMENT.md: touches server.mjs but adds/removes no operation — it relocates an existing
(#115) helper into a module. cli.js citation N/A under Rule 2.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — byte-identical body,
exactly one definition, wiring + scope correct, no test dropped, scope clean.

Closes #125.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:03:55 +10:00
0000926358 fix: escape dashboard status/plan cards (#124) (#126)
Follow-up defense-in-depth from #114's review. The status-cards and plan-cards in
dashboard.html rendered string values into innerHTML without escaping. These are
trusted-but-external (p.version/p.uptime are server-local; s.percent/s.resetsIn/
w.percent/w.resetsIn come from Anthropic's upstream plan API), so not the stored-XSS
vector #114 fixed — but wrapping them in the existing escapeHtml() helper gives uniform
defense-in-depth across all innerHTML sinks. Numeric/computed fields (request counts,
sPct/wPct, barColor) are left unescaped (not injectable).

dashboard.html is a client-side static asset, not server.mjs → cli.js citation N/A.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — confirmed every
string sink wrapped, numbers correctly untouched, escapeHtml in scope, template literals
intact, 181 tests pass (no regression).

Closes #124.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:59:15 +10:00
aa1c65beb1 fix: TUI hardening — non-loopback LAN gate + assert cc_entrypoint (#115) (#122)
Two TUI-mode findings from the 2026-05-31 audit (ADR 0007):

1. The fail-loud LAN gate refused TUI boot only when CLAUDE_BIND === "0.0.0.0",
   but binding to a concrete LAN IP, a Tailscale 100.x address, or IPv6 :: is
   equally network-exposed and slipped through — letting any reachable peer drive
   the operator's full-filesystem claude session. New isLoopbackBind() treats only
   127.0.0.0/8 / ::1 / localhost / ::ffff:127.0.0.1 as safe; the gate now trips on
   any non-loopback bind (OCP_TUI_ALLOW_LAN=1 escape hatch retained).

2. verifyEntrypoint() was implemented and unit-tested but never wired: readTuiTranscript
   discarded the events and returned only text, so a silent degrade to the metered
   sdk-cli (Agent SDK) billing pool — which still returns text but costs money — went
   undetected. readTuiTranscript now returns { text, entrypoint }; runTuiTurn passes it
   through; callClaudeTui logs a "tui_entrypoint_mismatch" warning when configured cli
   mode yields a non-cli entrypoint (including null/unverified). callClaudeTui still
   returns Promise<string>, so singleflight/cache/completion downstream is unchanged.

ALIGNMENT.md: TUI is a proxy-side execution-mode bridge (ADR 0007); this adds a local
bind classifier (startup gate) + a non-fatal log assertion of an existing classification
— no Anthropic operation forwarded, so a cli.js citation is N/A under Rule 2. No
blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
isLoopbackBind via truth table (no exposed address misclassified as loopback — the
dangerous direction), the full string→{text,entrypoint} contract ripple across all
callers (Promise<string> preserved), the mismatch logic (warns on sdk-cli AND null in
cli mode, silent for auto/off, non-fatal), and ALIGNMENT N/A via a fetch/header grep.
Minors are process-only (this commit body; a future lib/ extraction of the test-mirrored
helper). npm test → 181 passed, 0 failed.

Closes #115.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:13:47 +10:00
879b40fe93 fix: escape dashboard DB-sourced values + validate key names (#114) (#121)
The dashboard built table rows via template-literal innerHTML, interpolating
DB-sourced strings (key names, usage rows) with no HTML escaping, and an
onclick="revokeKeyUI('${k.name}')" sink a single quote could break out of. Key
names were unvalidated at creation. Admin-gated (self-XSS today), but a real
unescaped-sink gap that becomes cross-user if key creation is ever delegated.

- dashboard.html: added escapeHtml() and applied it to every DB/string-sourced
  interpolation in refreshUsage and refreshKeys (key_name, name, keyPreview,
  created_at, last_request, model). Replaced the inline-onclick revoke button with
  a data-revoke attribute + addEventListener, so a name can never break out into an
  event-handler string. (model is attacker-pickable via the request body, so its
  escaping is the load-bearing one.)
- server.mjs: POST /api/keys now rejects names not matching /^[A-Za-z0-9 ._-]{1,64}$/
  before createKey() — defense-in-depth so a <script>/quote name can never reach the
  DB. Creation-only; existing keys unaffected; the default key-${Date.now()} passes.

Noted (out of scope, optional follow-up): the status/plan summary cards render
trusted server/Anthropic-upstream values unescaped — non-user-controlled, so not part
of this stored-XSS fix.

ALIGNMENT.md: the server.mjs change is proxy-policy input validation with no Anthropic
operation forwarded, so a cli.js citation is N/A under Rule 2. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
escapeHtml correctness, full sink coverage (incl. the attacker-pickable model field and
the data-revoke attribute), revoke round-trip via getAttribute, the anchored/bounded
key-name regex running before createKey, and that createKey has no other unvalidated
caller. Both minors non-blocking (trusted status-card escaping; this commit-body note).

Closes #114.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:03:50 +10:00
68d58e7df4 fix: CLI/installer hardening — restart labels, key permissions, unit-secret escaping (#113) (#120)
Three CLI/installer findings from the 2026-05-31 audit (no server.mjs):

1. ocp-plugin `cmdRestart` hardcoded uid 501 + the legacy `ai.openclaw.proxy`
   label, and fell through to a dangerous `pkill -f 'node.*server.mjs' && cd
   ~/.openclaw/projects/*/; node server.mjs &` that could kill an unrelated node
   process and relaunch an env-less ghost proxy from a glob-ambiguous dir. Now uses
   process.getuid() + the live labels (dev.ocp.proxy on macOS, ocp-proxy on Linux
   systemd; the OpenClaw gateway label is unchanged) and drops the pkill fallback
   entirely (returns a manual `ocp restart` message on failure).

2. ocp-connect wrote the quota key unquoted into rc files and a world-readable
   environment.d/ocp.conf. Now single-quotes the value and chmod 600s the rc files
   and ocp.conf (matching the existing auth-profiles.json 0o600 convention).

3. setup.mjs interpolated the injected service-unit secrets (CLAUDE_BIN,
   OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY) raw into plist <string> and systemd
   Environment= lines. Added xmlEscape() for all plist <string> values and
   assertSafeInjectValue() which rejects control characters (\x00-\x1f — newline,
   CR, tab) before any unit is written, blocking a newline-injected rogue
   Environment= directive. Spaces are intentionally allowed (CLAUDE_BIN paths may
   contain them). XML-escaping on write also resolves plist-merge.mjs's [^<]* regex
   concern transitively (no raw < reaches it) — comment added, logic unchanged.

ALIGNMENT.md: CLI/installer scripts only, no Anthropic operation forwarded → cli.js
citation N/A. No blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — verified the
control-char regex byte-exact via od (no space-rejection regression), the pkill
fallback fully removed, restart labels match setup.mjs ground truth, OCP keys
(base64url) cannot break the single-quoting, and the validator runs before any write.

Closes #113.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:56:47 +10:00
4a7d79c330 fix: record OAuth-host verification + models.json SPOT for usage-probe/default (#112) (#119)
Three alignment/SPOT findings from the 2026-05-31 audit:

1. The OAuth token-refresh host (platform.claude.com/v1/oauth/token, a Class A
   surface) was introduced in the 2026-04-11 drift commit and had no verification
   record. Verified against the compiled cli.js (claude.exe v2.1.154) via `strings`:
   OAUTH_TOKEN_URL and OAUTH_CLIENT_ID both appear in the binary byte-for-byte (in
   the same `prod` config object), and the legacy host console.anthropic.com/v1/oauth
   is absent (0 hits). Recorded this as an inline ALIGNMENT citation comment above the
   constants. No live OAuth probe was run — a refresh-token grant would rotate the
   operator's real credentials; the strings-on-binary evidence is decisive.
   (cli.js: verified against compiled claude.exe v2.1.154, 2026-05-31.)

2. fetchUsageFromApi() hardcoded the haiku model ID; now derives from
   modelsConfig.aliases.haiku (ADR 0003 SPOT). Prevents a silent /usage break on a
   future haiku ID bump.

3. [P3] The default request model hardcoded the sonnet ID; now derives from
   modelsConfig.aliases.sonnet (ADR 0003 SPOT).

Both SPOT values are byte-identical to the literals they replace today, so zero
behavior change — only drift-resistance. The alignment.yml blacklist pin for the
wrong-host variant was deliberately NOT included here: extending the blacklist is a
governance-layer change (alignment.yml inline policy) and belongs in its own PR.

ALIGNMENT.md: finding 1 IS the verification (cli.js citation recorded inline);
findings 2-3 are SPOT hygiene that forward no new operation. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus) INDEPENDENTLY re-ran `strings` on the binary
and confirmed the host/client_id present and the legacy host absent — APPROVE (Iron
Rule 10; alignment hard-requirement #3 satisfied).

Closes #112.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:44:15 +10:00
c3b1f32c86 fix: error-output sanitization + process-lifecycle hardening (#111) (#118)
Four findings from the 2026-05-31 audit (3×P2 + 1×P3), all in the error/process
lifecycle layer:

1. sanitizeError() helper — streaming error paths sent raw claude error_message /
   stderr to the client, leaking home-dir / credential-file paths that the
   non-streaming path already redacted. Factored the path-strip regex into one
   helper and applied it at all 9 client-facing jsonResponse/sendSSE error emits;
   de-duped the 3 pre-existing inline .replace() sites. Operator-log calls
   (logEvent/trackError) and admin-gated endpoints left raw by design.

2. res.on("close") SIGKILL escalation — a client disconnect sent only SIGTERM; a
   SIGTERM-resistant child held its concurrency slot until the request timeout
   (narrow #37 on the hottest exit path). Now escalates to SIGKILL 5s after SIGTERM,
   cleared on proc exit. Per review: gated on the child still being alive
   (exitCode===null && signalCode===null) so the normal-success close no longer
   fires a spurious SIGTERM or leaks the 5s timer; killTimer.unref() so a genuine
   disconnect timer never delays graceful shutdown.

3. Per-key quota TOCTOU — documented as best-effort/eventually-consistent (inline
   comment + README note): concurrent requests at the boundary can overshoot by up
   to MAX_CONCURRENT and cache hits are uncounted. Chose documentation over an
   in-flight counter to avoid a decrement-on-all-paths liability (the #37 class) on
   a low-blast-radius internal family rate-limiter — not a payment boundary.

4. [P3] overallTimer cleared on semantic completion — the request timer was cleared
   only on proc exit, so a streamed response that res.end()'d before the child
   exited could record a spurious post-success timeout. New clearOverallTimer()
   (clears the timer ONLY, never touches the `cleaned` slot-accounting flag — no
   slot leak) is called in the streaming stop-success path; cleanup() on exit still
   clears it idempotently and decrements the slot.

ALIGNMENT.md: error-shaping / process-lifecycle / rate-limit documentation forward
no Anthropic operation, so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — the
two critical concerns (clearOverallTimer slot-leak class, SIGKILL double-kill) were
verified clean; MINOR #1 (kill-timer leak on success path) folded in; MINOR #2
(pre-existing regex over-redaction of ratios/URLs) left as out-of-scope.

Closes #111.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:34:19 +10:00
4458490caa fix: request validation + OpenAI-compat correctness (#110) (#117)
Three correctness/compat defects on the request path:

1. Non-array `messages` (e.g. {"messages":"x"}) passed the `!messages?.length`
   guard but threw in `messages.reduce(...)`; since the handler runs without
   await, the rejection silently hung the client until socket timeout. Now
   guarded with `Array.isArray(messages) && length>0` → 400 invalid_request_error,
   placed before the first use of `messages`.

2. OpenAI array `content` ([{type:"text",text},{type:"image_url",...}]) was
   JSON.stringify'd into the prompt as literal noise. New `contentToText()` helper
   flattens text parts and replaces non-text parts with a placeholder; used in
   messagesToPrompt, extractSystemPrompt, and all promptChars char-count sites
   (zero `JSON.stringify(m.content)` remain).

3. A streamed upstream error arriving after eager SSE headers emitted a bare
   finish_reason:"stop" + [DONE] — byte-identical to a successful empty completion.
   Both streaming error paths (the parsed.error result branch AND the non-zero-exit
   close-handler branch — the latter folded in per reviewer) now emit an SSE
   `data:{"error":{...}}` frame so clients can distinguish failure from empty.

Error-text sanitization across all emit sites is intentionally deferred to #110's
sibling issue #111 (security layer).

ALIGNMENT.md: these are OpenAI-compat shim + input-validation behaviors; OCP does
not forward, add, or alter any Anthropic API operation here (cli.js does not speak
the OpenAI wire format), so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) —
the close-handler sibling fold-in addresses MINOR #1; this commit body addresses
MINOR #2.

Closes #110.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:22:41 +10:00
36be723198 fix(security): gate /health anonymousKey behind opt-in PROXY_ADVERTISE_ANON_KEY (#109) (#116)
/health is an unauthenticated, LAN-reachable endpoint that returned the live
anonymous bearer key (anonymousKey: PROXY_ANONYMOUS_KEY). Any device that could
reach the port harvested a working, quota-spending credential (P0).

The anonymousKey field is now included only when the caller is localhost (already
fully trusted by the auth path, via the unspoofable req.socket.remoteAddress) OR
the admin explicitly opts in with the new PROXY_ADVERTISE_ANON_KEY=1 env var
(default off). ocp-connect's absent-field fallback (interactive --key / anonymous
access) is unchanged — comment-only update there.

ALIGNMENT.md: this gates an OCP proxy-policy field in /health; it does NOT forward,
add, or alter any Anthropic API operation, so a cli.js citation is N/A under Rule 2.
No blacklisted tokens introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10).

Closes #109.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
7b065600aa docs(readme): honest multi-user/security positioning + 6/15 framing + OLP cross-link (#108)
- Deployment model & security section: single-user multi-IDE is the supported
  model; shared/multi keys give usage/quota tracking but NOT a security isolation
  boundary (claude runs with operator FS, not sandboxed) — trusted users only;
  real per-user sandboxed isolation is planned post-2026-06-15.
- Soften the multi-mode 'recommended' label accordingly.
- Add a 'Related: OLP' section linking the multi-provider sibling project.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:44:25 +10:00
1b5a742711 chore(release): v3.17.1 — code-audit P1/P2 hardening (crash fixes + multi-tenant gates) (#107)
Co-authored-by: dtzp555 <dtzp555@gmail.com>
v3.17.1
2026-05-31 13:19:10 +10:00
05a984df89 fix: OCP code-audit P1+P2 hardening (crash bugs + multi-tenant gates) (#106)
* fix(daemon): P1-1 guard proc.stdin against EPIPE crash

In spawnClaudeProcess, attach an error listener on proc.stdin BEFORE
the write/end calls so an EPIPE (child closed stdin mid-write) is
swallowed and logged rather than thrown as an unhandled exception.

The existing proc.on("error") listener is on the ChildProcess object,
NOT on the stdin Writable — it does not catch stdin write errors.

Hardening per OCP code audit; entry-surface contract unchanged for
single-user default path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tui): P1-3 remove tool_use from isTerminalLine; only turn_duration is terminal

In interactive TUI mode, stop_reason=tool_use does NOT mean the turn is
complete. Claude handles the tool call internally and continues generating —
the transcript advances to another assistant entry. Treating tool_use as
terminal truncated tool-using turns mid-flight.

Only {type:"system", subtype:"turn_duration"} is the authoritative
completion marker (claude CLI v2.1.157+ interactive session transcript).

Updated two unit tests that previously asserted tool_use → true; they now
assert false (the correct behaviour). The real-fixture terminal detection
test is unaffected because the fixture uses turn_duration.

Hardening per OCP code audit; TUI path behaviour fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tui): P2-5 add -l (literal) flag to send-keys prompt paste

A prompt that equals a tmux key token (e.g. "C-c", "Escape") would be
interpreted as that key binding rather than typed as literal text.

The -l flag forces literal character-by-character input. The separate
Enter key event afterward deliberately omits -l so tmux sends a real
carriage-return keypress to submit the prompt line.

Authority: tmux send-keys(1) § -l flag. Hardening per OCP code audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audit): String-coerce parsed.error + stale tool_use comment (review fast-follow)

Folds the 2 minor findings from the independent review of the audit fixes:
- String(parsed.error) before .slice/message in callClaude + callClaudeStreaming
  (defensive: claude could emit a non-string result/error_message).
- correct the readTuiTranscript comment that still listed tool_use as terminal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:17:36 +10:00
a30b20978c chore(release): v3.17.0 — Phase 6c stream-json default + opus 4.8 + opt-in TUI-mode (#105)
- Phase 6c: default claude spawn → stream-json + --system-prompt (~64% cost cut)
- opus 4.8 model
- opt-in CLAUDE_TUI_MODE interactive subscription-pool bridge (single-user)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v3.17.0
2026-05-31 09:33:27 +10:00
cd98b51b96 docs(plans): add anthropic-only sandbox strategy handoff (#102)
Forward-looking planning doc capturing prior-art analysis from OLP's
Phase 7 PR-B re-evaluation, scoped down to OCP's single-provider
(anthropic) deployment.

Documents:
- Multi-tenant gap for OCP (cross-key lateral read, OAuth exposure)
- Why OLP's outer-bwrap PR-B approach is the wrong path to copy
  (Anthropic design intent, ~/.claude.json upstream not-planned)
- Three viable alternatives:
  A. Ephemeral $HOME via env var (~50 LOC, recommended Phase 1)
  B. bwrap --tmpfs $HOME + ro-bind credentials (apt dep, Linux only)
  C. OverlayFS lowerdir+upperdir (needs CAP_SYS_ADMIN)
- Orthogonal cross-key isolation layer (per-spawn customConfig denyRead
  or per-OS-user spawning)
- Trust-tier framing (single-user / family-zone / shared-host)

Not an ADR — becomes one only when work actually starts. Not binding;
ALIGNMENT.md authority requirements still apply when sandbox code lands.

Cross-references OLP's parallel multi-provider work at
dtzp555-max/olp ADR 0014 Amendment 1 (pending) and archive branch
phase-7-pr-b-outer-bwrap-snapshot.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:21:40 +10:00
74260d7f6f feat(tui): opt-in CLAUDE_TUI_MODE — interactive subscription-pool bridge (PR-1..PR-4 squashed) (#104)
Reader + driver + home modes + server wiring + entrypoint hardening. 5 independent
reviews folded; e2e green; default stream-json path byte-identical when flag off.
See PR description + ADR 0007 for the full layer/authority/security detail.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:21:11 +10:00
885f62addf feat: Phase 6c — claude stream-json port + --system-prompt + opus 4.8 (#103)
* wip(rescue): OCP Phase 6c stream-json port + opus 4.8 (uncommitted /tmp work)

Rescue commit — preserves the subagent-produced Phase 6c port (claude -p →
stream-json + --system-prompt + NDJSON parser) and opus 4.8 model addition
done 2026-05-30 in a /tmp clone (volatile). NOT reviewed-for-merge; this is
preservation only on a WIP branch so a /tmp reboot does not lose the work.

Strategy context: OCP is being made the first-mover for TUI-mode (users are
on OCP). Phase 6c here is the DEFAULT-path foundation (stream-json = sdk-cli =
the "Max-user stable mode" fallback). TUI-mode (cc_entrypoint=cli subscription
bridge) lands on top as an opt-in. Re-review before merging to main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(phase6c): fold review findings — scrub hostname, README prose, parser tests, version citation

P3-1 (binding): scrub private hostname "PI231" from server.mjs comment; replace
with role-based term "the test server". Repo is public — no machine-specific
hostnames may appear.

P3-2: update README.md "How It Works" diagram and prose from stale "claude -p"
to "claude --output-format stream-json", matching the Phase 6c spawn change already
in server.mjs and CHANGELOG.

P2-2: update all five authority citations in server.mjs from bare "claude CLI v2.1.104"
to "claude CLI § <flag> (ported from OLP, verified v2.1.104; behavior stable through
v2.1.158)" — honest about OLP provenance and version range, without inventing new facts.

P2-1: add "Stream-JSON parsers" suite to test-features.mjs (17 new tests, 84 → 101
total). Copies parseStreamJsonLines and parseStreamJsonEvent verbatim from server.mjs
with a MIRRORS header comment; logEvent stubbed to avoid live-server side-effects.
Covers: content_block_delta deltas, assistant-aggregate fallback, no-double-count guard,
aggregate-only short responses, partial-line buffering across two chunks, is_error result
variants, malformed/non-JSON lines, system/user/unknown event types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:13:36 +10:00
1dd6fb9440 docs(governance): add ADR 0006 OpenAI shim scope + Class A/B taxonomy (#100)
Introduces an explicit two-class taxonomy of OCP endpoints to resolve
the structural ambiguity surfaced by PR #99 (external response_format
honoring on /v1/chat/completions):

- Class A: cli.js-mirror endpoints. Rules 1-5 of ALIGNMENT.md apply
  verbatim. Citation requirement unchanged (cli.js:NNNN). The 2026-04-11
  drift discipline is preserved without weakening.

- Class B: OCP-owned compatibility endpoints. Anchored to OpenAI's
  /v1/chat/completions specification (B.1) or to an authorizing ADR
  (B.2). Citation shifts to spec section + ADR number. Class B inherits
  the same anti-invention discipline; the anchor differs.

Grandfather provision in ADR 0006 retroactively authorizes the 12
existing B.2 administrative endpoints at v3.16.4 behaviour (one-time,
contract-frozen). New B.2 endpoints or any new method on a grandfathered
endpoint requires its own ADR per Rule 4 (Class B mapping).

Changes:
- new: docs/adr/0006-openai-shim-scope.md
- new: docs/openai-compat-pin.md (placeholder for first B.1 audit)
- mod: ALIGNMENT.md (Class B section, rule mapping table, updated
  Unalignable Policy and Annual Audit scope; Rules 1-5 byte-identical)
- mod: .github/PULL_REQUEST_TEMPLATE.md (Endpoint Class radio, separate
  evidence sections for A vs B, reviewer checklist updated)
- mod: docs/adr/README.md (index entry for ADR 0006)

Independent reviewer (fresh-context opus per Iron Rule 10) verified:
all 12 load-bearing checks pass — Rules 1-5 byte-identical to
origin/main, 2026-04-11 drift narrative unchanged, explicit
non-relitigation safeguard present in ADR 0006, grandfather provision
narrowly scoped, Class B inventory matches server.mjs reality (14/14),
single governance layer per Iron Rule 11 (no server.mjs touch),
alignment.yml unmodified. Verdict: APPROVE_WITH_MINOR with 3 of 5
non-blocking suggestions folded in (operations-vs-endpoints precision,
PR template Hybrid wording, openai-compat-pin.md stub).

Triggering incident: PR #99 by external contributor (response_format
honoring on /v1/chat/completions). This governance PR ships separately
per Iron Rule 11 (IDR); the feature PR #99 will rebase on this and
declare Class B with ADR 0006 citation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:01:10 +10:00
9e25160527 refactor: hoist port literal to lib/constants.mjs + CI gate (v3.16.4) (#98)
Closes the structural side of the port-drift cascade addressed by
v3.16.2/v3.16.3. Those releases reverted the literal line-by-line; this
one removes the invitation to drift.

Changes:
  * NEW lib/constants.mjs — exports DEFAULT_PORT=3456, LOCAL_HOST,
    OPENAI_API_BASE, LOCAL_PROXY_URL.
  * server.mjs / setup.mjs / scripts/upgrade.mjs / scripts/doctor.mjs
    (x2) / scripts/sync-openclaw.mjs all import DEFAULT_PORT from
    lib/constants.mjs instead of hardcoding "3456".
  * .github/workflows/alignment.yml:
    - path filter extended to setup.mjs, scripts/**, lib/**,
      ocp, ocp-connect.
    - NEW job port-spot hard-fails any PR that introduces a hardcoded
      "3478" or "3456" literal outside EXEMPT_REGEX (lib/constants.mjs,
      test-features.mjs, ocp/ocp-connect bash CLIs, docs, the workflow
      itself).
  * Doc-comment rewording so CI grep finds zero hits.

No behavior change for any user. CLAUDE_PROXY_PORT env var still wins
at runtime; only the unset-env fallback now flows through one constant.

ALIGNMENT.md note: server.mjs change is one import + one literal swap,
mechanical. No cli.js operation changed; the citation requirement does
not apply.

cli.js: not applicable — mechanical refactor, no behavior change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
v3.16.4
2026-05-13 06:42:15 +10:00
49c6d32e3b fix(scripts): default CLAUDE_PROXY_PORT to 3456 (completes v3.16.2 revert) (#97)
Three places in scripts/ still defaulted to 3478 after v3.16.2's
plugin / manifest / README / plist revert:

  scripts/upgrade.mjs:137
  scripts/doctor.mjs:84
  scripts/doctor.mjs:205

These were the residual cascade source for the port-drift bug originally
caused by the PR #71 dogfood accident on 2026-05-08 (see
~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md
and v3.16.2 CHANGELOG entry).

Every `ocp doctor` / `ocp upgrade` invocation without an explicit
`CLAUDE_PROXY_PORT` in env probed port 3478 — got "OCP not responding"
against a healthy 3456 instance — and on the maintainer's host this
cascaded into wrong baseUrl writes for the OpenClaw `claude-local`
provider, taking out the OpenClaw Telegram agent ("大内总管") on
2026-05-13.

This change is the smallest possible: three string literals
`"3478"` → `"3456"`, aligning unset-env defaults with `server.mjs:126`.
Env-set users are unaffected (env precedence is unchanged).

cli.js: not applicable — this is a scripts/ change, not server.mjs.
ALIGNMENT.md hard-requirements (cli.js citation, blacklist CI,
independent reviewer) target server.mjs; this PR honors the SPOT
spirit by ending the literal-port drift across the codebase.

Bumps to v3.16.3. CHANGELOG entry added.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
v3.16.3
2026-05-13 06:14:37 +10:00
7766fa0868 fix(plugin): revert default to 3456 + correct v3.16.1 narrative; v3.16.2 (#96)
v3.16.1's narrative ("OCP server moved to 3478 default in v3.14+") was
incorrect. OCP source default has been 3456 since 593d0dc (initial
release) and never changed. The single observation of 3478 is the
maintainer's Mac mini, whose plist was rewritten with --port 3478
during a 2026-05-08 PR #71 dogfood smoke-test accident (see
~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md).
The drift was never reconciled and v3.16.1 mistakenly canonised the
post-accident port as the new default.

This release reverts:
- ocp-plugin/index.js fallback → http://127.0.0.1:3456
- openclaw.plugin.json configSchema.proxyUrl.default → http://127.0.0.1:3456
- README §Environment Variables CLAUDE_PROXY_PORT default → 3456
- top-level package.json → 3.16.2

PR #95's env-reading path (OCP_PROXY_URL → CLAUDE_PROXY_PORT → fallback)
is preserved — that part was good design and stays. Only the hardcoded
fallback default changes.

Hosts whose OCP plist injects a non-default port must also inject the
same CLAUDE_PROXY_PORT into the OpenClaw plist for the plugin to follow
(documented in the new index.js comment block).

Mac mini's plist was reverted from 3478 to 3456 as part of this deploy
(per-host correction; no source code reflects host-specific state).

CHANGELOG includes an explicit erratum entry under v3.16.1 marking it
superseded.

Process note: this PR was triggered by maintainer asking "why was the
port changed?" — the answer revealed I (PM) wrote v3.16.1's CHANGELOG
without running `git log -G "3478" -- setup.mjs`. Iron Rule 2
(evidence-first) was violated. Future commits asserting historical
facts must include the grep that confirmed them.

No cli.js citation needed: OCP-internal plugin + docs, no server.mjs
change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
v3.16.2
2026-05-12 12:17:13 +10:00
70faeff067 fix(plugin): default OCP plugin port to 3478 + env-overridable (v3.16.1) (#95)
* fix(plugin): default OCP plugin port to 3478 + env-overridable; v3.16.1

ocp-plugin/index.js hard-coded http://127.0.0.1:3456 since the plugin
was created. OCP server moved to 3478 default in v3.14+ as part of
the same wave that renamed the launchd label (dev.ocp.proxy). The
plugin never got the memo. Result: `/ocp usage` from OpenClaw bots
(e.g. the home Telegram bot 大内总管) hit the dead port 3456 and
returned "OCP error: fetch failed".

Fix:
- Default PROXY → http://127.0.0.1:3478
- Read OCP_PROXY_URL env (full URL) first
- Else read CLAUDE_PROXY_PORT env (port only, localhost assumed)
- Else fall back to the 3478 default

openclaw.plugin.json bumped (3.12.0 → 3.16.1) and configSchema
default updated. Plugin version now matches OCP version.

Top-level package.json bumped 3.16.0 → 3.16.1. CHANGELOG entry added.

Diagnostic trail: caught 2026-05-12 when home Telegram bot reported
"OCP error: fetch failed" against `/ocp usage`. Mac mini OCP service
was healthy on port 3478; lsof -iTCP:3456 had no listener; plugin
index.js had hardcoded 3456.

No cli.js citation needed: this is OCP-internal plugin code with no
corresponding cli.js operation.

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

* docs(readme): document OCP_PROXY_URL + CLAUDE_PROXY_PORT plugin reuse (per release_kit 5.5)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
v3.16.1
2026-05-12 11:51:54 +10:00
7a69d72886 feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94)
Adds snapshot garbage collection with retention policy: keep last 5,
keep snapshots within 30 days, always keep the most recent. Configurable
via keepCount / keepDays opts.

Wire-up:
- ocp update --rollback --gc (manual trigger; --dry-run supported)
- runFullUpgrade auto-GC after successful upgrade (best-effort,
  swallows errors)

4 unit tests: keepCount enforcement, keepDays override, never-delete-
most-recent safety, dry-run mode.

Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature).

No cli.js citation needed: this is OCP-internal snapshot lifecycle with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
v3.16.0
2026-05-11 07:33:13 +10:00
a8601a6d30 feat(doctor): --check oauth fast path (#93)
* feat(doctor): --check oauth fast path

Implements the --check oauth fast path documented in cmd_doctor_help
but previously unimplemented. Skips version detection, from-version
check, git operations, and models endpoint — runs only the curl
against /health + auth.ok extraction.

Use cases:
- After `claude auth login`, fast verify OCP can spawn cli.js
- After a known service blip, quick health gate before larger ops
- AI agent's setup-repair loop: ./ocp doctor --check oauth in a
  retry-after-fix step

3 unit tests cover: PASS path, OAuth FAIL → fix_oauth, service down →
fix_service.

No cli.js citation needed: this is OCP-internal doctor logic with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(doctor): nit fixes for --check oauth (N3 body=null test + N4 skipped sentinel comment)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:25:40 +10:00
cd6ec2a212 fix(doctor): dynamic latest_version from origin/main; release v3.15.1 (#92)
v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, causing
any v3.15.0+ install to report kind=upgrade against a stale value.
`ocp update` would then attempt `git checkout v3.14.0` — a downgrade.

Doctor now fetches `git -C ~/ocp show origin/main:package.json` to
determine the actual latest. On failure (offline, fresh clone, no
remote), falls back to currentVersion so kind=noop instead of
recommending a downgrade.

Regression test added: doctor with unreachable ocpDir falls back to
currentVersion as latest (not the old hardcoded v3.14.0).

Caught during v3.15.0 post-deploy verification on home-mac: ./ocp
doctor reported `kind=upgrade` immediately after v3.15.0 install,
which would have been a critical user-facing bug.

No cli.js citation needed: this is OCP-internal doctor logic with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
v3.15.1
2026-05-11 07:01:49 +10:00
ab03c13332 feat(upgrade): ocp doctor + cross-version ocp update + rollback + AI prompts (#91)
* feat(doctor): add ocp doctor with --json + next_action contract

Implements scripts/doctor.mjs with semver-aware path selection
(noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON
contract documented in the design spec.

Service health + OAuth checks integrated; mockable via opts.mockHealth
for unit tests. 8 unit tests cover the kind dispatch tree and the
next_action shape for each kind.

No cli.js citation needed: this is OCP-internal tooling with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(doctor): handle unparseable version + empty health body

Three issues raised by code-quality reviewer on b65201b:

1. semverCompare returned 0 for unparseable input, causing fromSupported=true
   and kind=noop for an install with unreadable package.json. Now treats
   unparseable currentVersion as fresh_install candidate.
2. mockHealth: { status: 200, body: null } routed to fix_oauth (because
   health.body?.auth?.ok was undefined → falsy). 200 with empty body is
   server-broken, not OAuth-broken; now routes to fix_service.
3. Removed unused KIND_ENUM declaration (dead code).

Two regression tests added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(upgrade): add scripts/upgrade.mjs + scripts/lib/snapshot.mjs

Implements the upgrade dispatcher (noop / dry-run / light delegation /
full path) and the snapshot writer/reader/list module. Full path snapshots
plist + db + admin-key + openclaw.json before mutating, runs the 6 phases
(pre-flight, snapshot, fetch+install, reconfigure, restart, post-flight),
and emits a heads-up before launchctl bootout per
notify_before_prod_service_restart.md policy.

mockExec/mockDoctor injection points let tests verify the phase ordering
without touching the real shell. fresh_install + rollback paths are
deferred to Bundle 3.

No cli.js citation needed: this is OCP-internal upgrade tooling with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(upgrade): error path completeness + observability

5 issues raised by code-quality reviewer on c12013a:

A. exec() wrapper now captures stderr from execSync failures and
   re-throws with `phase X failed: <stderr>` instead of the terse
   "Command failed: ..." default. Operators see the actual git/npm
   error.
B. runFullUpgrade body wrapped in try/catch; any error after phase 2
   (snapshot written) carries snapshotPath + phases + hint pointing
   at `ocp update --rollback`. Aligns with the post-flight failure
   pattern.
C. CLI entrypoint now prints snapshotPath + hint on error.

Plus minor:
- snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently
  swallowing copy errors (e.g. permission-denied admin-key)
- heads-up window 1s → 3s, more operable per the policy intent
- opts.yes intent comment added (Bundle 3 will use)

One regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(upgrade): fresh-install + rollback paths

Implements the two missing branches of runUpgrade dispatcher:

- runFreshInstall: gated by --yes, runs doctor.next_action.ai_executable
  steps in order, fails fast on first error, attaches steps[] to thrown
  errors. Accepts mockExec for unit tests.
- runRollback: locates latest or named snapshot in ~/.ocp/, reads
  from-commit.txt, restores plist + db + admin-key + service file (with
  per-file warn lines on copy failure), git-checkouts the from-commit,
  npm installs at that revision, restarts the service. --list shows all
  snapshots; --dry-run prints the plan without mutation.

Both paths use the same exec() error-wrap pattern as runFullUpgrade
(stderr capture, phases attached to thrown errors, restart heads-up).

CLI entrypoint extended to parse --rollback / --list / --target / and
optional positional snapshot path after --rollback.

6 unit tests cover: --yes gate, fresh_install ai_executable run,
--rollback --list, no-snapshots error, --rollback --dry-run, mock-exec
restore.

No cli.js citation needed: this is OCP-internal upgrade tooling with
no corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(upgrade): nit fixes from Bundle 3 code-quality review

3 micro-fixes on 48e9408:
1. Remove unused mkdirSync import
2. snapshot-not-found error message hints "must be inside ~/.ocp/upgrade-snapshot-*"
3. runFreshInstall failure now includes e.stderr (or e.message fallback) in the
   thrown error and steps[].error so non-interactive callers see the actual reason

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

* refactor(ocp): cmd_update dispatches via doctor; --rollback added; light path preserved

cmd_update now calls scripts/doctor.mjs to determine which path to take:
  noop          → "already at latest" exit 0
  update        → existing light path (git pull + npm install + restart),
                  extracted into _cmd_update_light helper to keep the daily
                  case fast and shell-only
  upgrade       → exec node scripts/upgrade.mjs (full path with snapshot
                  + post-flight)
  fresh_install → exec node scripts/upgrade.mjs (gated by --yes)
  fix_oauth/fix_service → print error referring user to `ocp doctor`

cmd_update --rollback path: exec node scripts/upgrade.mjs --rollback "$@"
forwards remaining args (--list, --dry-run, optional snapshot path).

cmd_update_help expanded to document new flags.

cmd_update --check fast path is preserved exactly (no doctor call there).

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ocp): forward all args to cmd_update so multi-flag invocations work

Bug found via runtime smoke test:
  ./ocp update --rollback --list → "no snapshots" (wrong; should list)

Root cause: dispatch was `cmd_update "\${1:-}"` (only first arg). When
user typed `--rollback --list`, cmd_update only received `--rollback`,
the shift left $@ empty, and exec node ... --rollback got no flags.
Other commands using "\${1:-}" don't need multi-arg, but cmd_update now
does (--rollback --list, --rollback --dry-run, --target X --yes, etc.).

Change: dispatch is now `cmd_update "\$@"`. cmd_update internals already
handle multi-arg correctly (\$1 == --check fast path; \$1 == --rollback
shift+forward; otherwise doctor-driven).

Verified:
  ./ocp update --check         → existing behaviour preserved
  ./ocp update --rollback --list → "Found 0 snapshots:" exit 0
  ./ocp update --rollback --dry-run → no-snapshot error exit 1

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

* docs(release): v3.15.0 — README AI prompt blocks + Upgrading rewrite + CHANGELOG

§Installation, §Upgrading, §Troubleshooting each start with a copy-paste
AI prompt block for Claude Code / Cursor / Copilot. The Upgrading section
explains the three paths (light / full / fresh-install) and rollback usage.

All Commands table gains an `ocp doctor` row.

package.json bumped to 3.15.0.

CHANGELOG.md gains the v3.15.0 entry covering doctor, the cross-version
update path, --rollback, fresh-install routing, and AI prompt blocks.
Notes the dependency on PR #90 (plist env merge bug fix, already merged).

No cli.js citation needed: docs + version bump only, no server.mjs change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(readme): show --yes in rollback usage examples

Per Iron Rule 10 reviewer nit on PR #91: live rollback requires --yes
even for interactive humans. Update §Upgrading examples to show the
canonical human form. (AI agents pass --yes by convention; humans were
hitting a confusing "requires --yes" error following the prior README.)

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

* fix(scripts): CLI entrypoint guard resilient to symlinked install paths

Bug found via integration test on MacBook Pro (macOS /tmp → /private/tmp):
`import.meta.url === \`file://\${process.argv[1]}\`` evaluates false when
the install path traverses a symlink, because import.meta.url is canonicalised
but process.argv[1] is not. Result: ./ocp doctor (and ./ocp update via
upgrade.mjs) exit silently with code 0 and no output, instead of running.

Fix: use fileURLToPath + realpathSync on both sides of the comparison.
Affects any install at a symlinked path (/tmp, NFS mounts, /var/ paths,
docker bind mounts, etc.). Normal ~/ocp installs were unaffected.

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
v3.15.0
2026-05-11 06:56:17 +10:00
55c576bbb1 fix(setup): preserve user-customised plist/systemd env vars on re-setup (#90)
* fix(setup): merge plist/systemd env vars instead of overwriting

setup.mjs previously wrote the launchd plist and the Linux systemd unit
with writeFileSync(path, NEW_TEMPLATE_STRING), which silently dropped any
user-customised env vars (CLAUDE_HEARTBEAT_INTERVAL, CLAUDE_CACHE_TTL,
etc.) on every re-setup or upgrade. This change introduces
scripts/lib/plist-merge.mjs which preserves keys present only in the
existing file and lets the template's known keys win. Linux variant uses
the same logic against Environment=KEY=VALUE lines.

Tests added in test-features.mjs cover preserve / override / first-install
for both formats.

No cli.js citation needed: this is OCP-internal installer behaviour with
no corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(setup): plist-merge code-quality follow-up

Three issues raised by the code-quality reviewer on 0fd1838:

1. mergeSystemdEnv now early-returns when the template has no
   Environment= anchor, instead of silently dropping preserved lines
   (defensive guard for a future template change).
2. Both parsers (parsePlistEnv, parseSystemdEnv) now Buffer-normalise
   their input, removing the TypeError-vs-coerce asymmetry.
3. Two idempotency tests added (calling merge twice on the same
   input/output pair must be stable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:41:53 +10:00
750b25ba77 chore(release): v3.14.0 — security hardening (sessions namespacing + file modes + /api/usage scope) (#89)
Bump version 3.13.0 → 3.14.0. No functional code change in this PR;
all three security fixes are already merged in main via PRs #86, #87, #88.

This PR covers only metadata + docs:
- package.json: version bump to 3.14.0
- CHANGELOG.md: v3.14.0 entry with Features / Behavior changes / Verification /
  Governance sections
- README.md: /api/usage self-scope note in Auth Modes §, API Endpoints table
  row update, file-mode bullet in Important Notes §

cli.js citation N/A: this release PR modifies no server.mjs / setup.mjs / keys.mjs.
The three underlying security PRs (#86, #87, #88) each carry their own
cli.js-citation-not-applicable disclaimer per PR #75 pattern, as they are
OCP-internal access-control, session-state, and file-permission changes with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
v3.14.0
2026-05-10 06:49:00 +10:00
fd6e875bd7 fix(security): /api/usage default scope = self; admin all-keys requires ?all=true (#88)
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: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:56:16 +10:00
8c0b97f3ae fix(security): tighten on-disk credential file modes (700/600) (#87)
Credential-bearing OCP artifacts were created at default umask (0644/0755),
making them world-readable on multi-user hosts. This commit hardens them at
writer time and adds an idempotent startup reconciliation so existing prod
installs are fixed automatically on next service restart.

Changes:
- keys.mjs: mkdirSync(OCP_DIR, { mode: 0o700 }) + chmodSync after to handle
  pre-existing dirs; chmodSync(DB_PATH, 0o600) after first getDb() open.
- setup.mjs: chmodSync(plistPath, 0o600) after writeFileSync on macOS;
  chmodSync(unitPath, 0o600) after writeFileSync on Linux.
- server.mjs: _tightenFileModesIfPossible() reconciliation block — idempotently
  chmods ~/.ocp (700), ~/.ocp/admin-key (600), ~/.ocp/ocp.db (600) on startup;
  emits a single info-level log line when any file is tightened; ignores ENOENT
  and wraps EPERM in a warn log so startup is never crashed by chmod failure.

Backward compat: all files remain accessible to the same-user owner; 0o600
is still fully readable/writable by the process. Existing prod boxes with
old 0644 ~/.ocp directories get fixed-up on next launchd/systemd restart
without any manual intervention.

cli.js citation: this change is OCP-internal file-permission hardening only.
No cli.js function corresponds to chmod or credential-file management. This
is an OCP-local security improvement that is out of scope for the cli.js
citation requirement per ALIGNMENT.md Rule 2; the cli.js boundary applies
to proxy protocol and API surface changes, not host-filesystem hardening.
Identified during privacy/security audit — .claude/research/ocp-security-audit.md.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:10 +10:00
68acf15373 fix(security): namespace sessions Map by keyId — close cross-key conversation collision (#86)
**Bug**: the sessions Map used the raw client-supplied conversationId string as
its key. Two callers with different API keys (or one anonymous + one
authenticated) using the same session_id="default" collided in the Map,
sharing a cli.js subprocess and conversation history — a cross-tenant context leak.

**Fix**: introduce _sessionKey(conversationId, keyName) → "${keyName}|${conversationId}".
Replace every sessions Map site (has / set / get / delete) with the namespaced key.
keyName comes from req._authKeyName (set by auth middleware). Anonymous callers
produce "anon|<id>"; admin produces "admin|<id>"; per-key callers produce
"<keyName>|<id>" — matching the convention used by cacheHash() for per-key cache
isolation (D1, v3.13.0).

**cli.js citation — not applicable**: This PR changes OCP-internal session lifecycle
bookkeeping (the sessions Map that OCP maintains to thread --resume flags across
requests). There is no corresponding cli.js operation to cite. ALIGNMENT.md Rule 2
(limiting OCP to operations cli.js performs) does not constrain in-process state
representation. Same pattern as #75.

**Scope of changes (server.mjs only)**:
- New helper: _sessionKey() — 3 lines after sessions Map declaration
- spawnClaudeProcess(): accepts keyName param; all 4 sessions Map calls → _sessionKey
- handleSessionFailure(): uses sessionKey (not raw conversationId) for sessions.delete
- callClaude(): accepts and forwards keyName to spawnClaudeProcess
- callClaudeStreaming(): forwards authInfo.keyName to spawnClaudeProcess
- Request handler: both callClaude() call sites pass req._authKeyName
- Cleanup interval + GET /health + GET /sessions: strip "keyName|" prefix before log/display

**Smoke test**:
- node --check server.mjs → SYNTAX OK
- npm test → 43/43 passed, 0 failed
- Hand-traced has/set/get/delete paths: cross-key collision absent ✓

Identified during privacy/security positioning brainstorm — `.claude/research/ocp-security-audit.md`

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:04 +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