mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5a742711 | ||
|
|
05a984df89 | ||
|
|
a30b20978c | ||
|
|
cd98b51b96 | ||
|
|
74260d7f6f | ||
|
|
885f62addf | ||
|
|
1dd6fb9440 | ||
|
|
9e25160527 | ||
|
|
49c6d32e3b | ||
|
|
7766fa0868 | ||
|
|
70faeff067 | ||
|
|
7a69d72886 | ||
|
|
a8601a6d30 | ||
|
|
cd6ec2a212 | ||
|
|
ab03c13332 | ||
|
|
55c576bbb1 | ||
|
|
750b25ba77 | ||
|
|
fd6e875bd7 | ||
|
|
8c0b97f3ae | ||
|
|
68acf15373 | ||
|
|
a71c939bf8 | ||
|
|
d245c62df7 | ||
|
|
047750e642 | ||
|
|
3bdeb50ed5 | ||
|
|
fbbf3b6c7c | ||
|
|
d760d7fcce | ||
|
|
5e2effd05b | ||
|
|
fb2d1d3feb | ||
|
|
12b09c236e | ||
|
|
c0f2d3ab20 | ||
|
|
0d61da5153 | ||
|
|
49baffe2da | ||
|
|
4b01d4e768 | ||
|
|
36fa81d1e6 | ||
|
|
cce0110253 | ||
|
|
40391791a1 | ||
|
|
342a0a44f5 | ||
|
|
9494fd6c69 | ||
|
|
5ff30ac9b6 |
@@ -0,0 +1,9 @@
|
|||||||
|
# GitHub recognizes this file and shows a "Sponsor" button on the repo page.
|
||||||
|
# Add other platforms here as they get set up. Empty / commented-out entries
|
||||||
|
# are skipped silently.
|
||||||
|
|
||||||
|
buy_me_a_coffee: dtzp555
|
||||||
|
|
||||||
|
# github: [dtzp555-max] # uncomment after GitHub Sponsors enrollment is approved
|
||||||
|
# ko_fi: dtzp555
|
||||||
|
# custom: ["https://example.com/donate"]
|
||||||
@@ -4,22 +4,46 @@
|
|||||||
|
|
||||||
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
||||||
|
|
||||||
|
## Endpoint Class (REQUIRED)
|
||||||
|
|
||||||
|
Per `ALIGNMENT.md` and ADR 0006, every PR that touches a network-facing endpoint must declare its class. Pick the most specific applicable class (Hybrid covers PRs that touch both A and B):
|
||||||
|
|
||||||
|
- [ ] **Class A** — forwards a `cli.js` operation (e.g., `/v1/messages`, `/api/oauth/*`, or the Anthropic-side wire call inside `/usage`)
|
||||||
|
- [ ] **Class B** — extends an OCP-owned compatibility endpoint (per ADR 0006). Sub-bucket:
|
||||||
|
- [ ] B.1 — OpenAI-compatibility surface (`/v1/chat/completions`, `/v1/models`)
|
||||||
|
- [ ] B.2 — OCP-administrative surface (`/health`, `/dashboard`, `/sessions`, `/logs`, `/status`, `/settings`, `/api/keys*`, `/api/usage`, `/cache*`)
|
||||||
|
- [ ] **Hybrid** — touches both classes (e.g., `/usage` if the PR modifies both the Anthropic wire call AND the local synthesis layer). Both evidence sections below must be filled.
|
||||||
|
- [ ] **Not endpoint-touching** — refactor / docs / tooling that does not modify any request handler. Skip both evidence sections; explain in Summary.
|
||||||
|
|
||||||
## Claude Code Alignment Evidence (REQUIRED)
|
## Claude Code Alignment Evidence (REQUIRED)
|
||||||
|
|
||||||
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
|
PRs with the relevant evidence section blank or unchecked will receive a `request changes` review and cannot be merged.
|
||||||
|
|
||||||
|
### If Class A
|
||||||
|
|
||||||
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
||||||
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
||||||
|
|
||||||
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
|
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints. If the endpoint is in fact Class B, switch the class above and use the Class B section instead.)
|
||||||
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
||||||
|
|
||||||
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
||||||
|
|
||||||
|
### If Class B
|
||||||
|
|
||||||
|
- [ ] **Authorizing ADR.** Cite the ADR number that authorizes the endpoint this PR modifies (e.g., "ADR 0006 — OpenAI shim scope"). For B.1 endpoints (`/v1/chat/completions`, `/v1/models`), this is ADR 0006. For grandfathered B.2 endpoints, this is "ADR 0006 (grandfathered as of v3.16.4)." For new B.2 endpoints, cite the endpoint's own authorizing ADR; if none exists, the PR cannot proceed — the authorizing ADR must be drafted and merged first.
|
||||||
|
<!-- e.g., ADR 0006 -->
|
||||||
|
|
||||||
|
- [ ] **Specification citation.** For B.1 endpoints, link to the relevant section of OpenAI's `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create), including the specific field or behaviour being implemented. For B.2 endpoints with their own ADR, cite the ADR section that specifies the behaviour. For grandfathered B.2 endpoints, the PR must be a behaviour-preserving refactor — link the existing handler code being modified.
|
||||||
|
<!-- B.1 example: OpenAI chat/completions, `response_format` parameter, https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format -->
|
||||||
|
<!-- B.2 example: ADR 00NN § "Behaviour" -->
|
||||||
|
|
||||||
|
- [ ] **No invention beyond the specification.** I confirm this PR does not introduce any field or behaviour not present in OpenAI's spec for the endpoint (B.1) or beyond the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, I confirm the change is behaviour-preserving (no contract drift). If something the user actually wants is not in the spec, the right answer is to close this PR and propose an upstream spec change or a new ADR.
|
||||||
|
|
||||||
## Type of change
|
## Type of change
|
||||||
|
|
||||||
- [ ] Bug fix (alignment with existing `cli.js` behavior)
|
- [ ] Bug fix (alignment with existing `cli.js` behavior, or with the cited spec / ADR for Class B)
|
||||||
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
|
- [ ] Feature (new `cli.js` behavior now surfaced through OCP, or new field already in OpenAI's spec for Class B)
|
||||||
- [ ] Refactor (no wire-level behavior change)
|
- [ ] Refactor (no wire-level behavior change)
|
||||||
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
||||||
- [ ] Documentation / governance
|
- [ ] Documentation / governance
|
||||||
@@ -28,14 +52,16 @@ Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing sur
|
|||||||
|
|
||||||
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
||||||
|
|
||||||
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
|
- [ ] If Class A, I opened `cli.js` at the cited line range and confirmed the operation matches. If Class B, I opened the OpenAI spec at the cited section (B.1) or the authorizing ADR (B.2) and confirmed the behaviour described in this PR matches the cited reference.
|
||||||
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
||||||
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
||||||
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
|
- [ ] If the PR asserts scope without a `cli.js` citation (Class A) or without an ADR (Class B), I confirmed the justification is sound per `ALIGNMENT.md` Rule 2 and ADR 0006.
|
||||||
|
- [ ] If the PR is Class B and adds a new endpoint or new method, I confirmed the authorizing ADR lands in the same merge or before this PR.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
|
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3, or Rule 3 (Class B mapping) -->
|
||||||
|
- Authorizing ADR (Class B only): <!-- e.g. ADR 0006 -->
|
||||||
- Related issue / prior PR: <!-- #NNN -->
|
- Related issue / prior PR: <!-- #NNN -->
|
||||||
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'server.mjs'
|
- 'server.mjs'
|
||||||
|
- 'setup.mjs'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'lib/**'
|
||||||
|
- 'ocp'
|
||||||
|
- 'ocp-connect'
|
||||||
- '.github/workflows/alignment.yml'
|
- '.github/workflows/alignment.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -66,6 +71,80 @@ jobs:
|
|||||||
|
|
||||||
echo "Blacklist scan clean."
|
echo "Blacklist scan clean."
|
||||||
|
|
||||||
|
port-spot:
|
||||||
|
name: port literal SPOT (hard fail)
|
||||||
|
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||||
|
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
|
||||||
|
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
|
||||||
|
# taking out the "大内总管" Telegram agent.
|
||||||
|
#
|
||||||
|
# Rule: the only places allowed to write a literal port number in source
|
||||||
|
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
|
||||||
|
# (which can't import .mjs and must keep the literal in sync — flagged
|
||||||
|
# with a `// keep in sync with lib/constants.mjs` style comment), and
|
||||||
|
# (c) test-features.mjs (intentionally pins historical ports for plist /
|
||||||
|
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Scan for hardcoded port literals outside SPOT
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Files/paths exempt from the SPOT requirement.
|
||||||
|
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
|
||||||
|
|
||||||
|
# Hardcoded port literals to forbid in non-exempt source.
|
||||||
|
FORBIDDEN_PORTS=("3478" "3456")
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
for port in "${FORBIDDEN_PORTS[@]}"; do
|
||||||
|
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
|
||||||
|
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
|
||||||
|
| grep -v -E "${EXEMPT_REGEX}" \
|
||||||
|
|| true)"
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
|
||||||
|
echo "$HITS"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
PORT LITERAL SPOT VIOLATION
|
||||||
|
============================================================
|
||||||
|
A hardcoded TCP port literal was found in a source file
|
||||||
|
that should import from lib/constants.mjs instead.
|
||||||
|
|
||||||
|
Background: this rule exists because between 2026-05-08 and
|
||||||
|
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
|
||||||
|
and scripts/doctor.mjs cascaded into downstream OpenClaw
|
||||||
|
config writes, taking out the OpenClaw Telegram agent.
|
||||||
|
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
|
||||||
|
|
||||||
|
Required action:
|
||||||
|
1. Import DEFAULT_PORT (or related constant) from
|
||||||
|
lib/constants.mjs instead of hardcoding the literal.
|
||||||
|
2. If the file genuinely cannot import .mjs (e.g. bash
|
||||||
|
script), add it to EXEMPT_REGEX in this workflow and
|
||||||
|
add a `keep in sync with lib/constants.mjs` comment
|
||||||
|
at the reference.
|
||||||
|
3. For test files that intentionally pin historical ports
|
||||||
|
(test-features.mjs), the regex already exempts them.
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Port SPOT scan clean."
|
||||||
|
|
||||||
commit-citation:
|
commit-citation:
|
||||||
name: commit message citation (soft check)
|
name: commit message citation (soft check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+66
-4
@@ -8,10 +8,14 @@
|
|||||||
|
|
||||||
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
||||||
|
|
||||||
|
This Core Principle applies in full to **Class A** endpoints (the `cli.js`-mirror surface). A second class of endpoint — **Class B**, the OCP-owned compatibility surface — has its own scope discipline anchored to its own specification authority. See "Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)" below and ADR 0006.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
The following Rules apply to **Class A operations** (the `cli.js`-mirror surface — the inbound `/v1/messages` forwarding route, the outbound `/v1/messages` wire call used by `handleUsage()` for rate-limit-header extraction, the OAuth bearer machinery, and any future operations OCP forwards from `cli.js` to Anthropic). For the Class B mapping of each rule, see the Class B section below.
|
||||||
|
|
||||||
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
||||||
|
|
||||||
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
||||||
@@ -68,20 +72,78 @@ On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint f
|
|||||||
|
|
||||||
## Unalignable Policy
|
## Unalignable Policy
|
||||||
|
|
||||||
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
|
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function (Class A) or to a specific OpenAI specification section AND an authorizing ADR (Class B).
|
||||||
|
|
||||||
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
||||||
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
||||||
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
||||||
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
|
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` (Class A) or into OpenAI's spec (Class B) or to move it out of OCP into a separate tool. OCP does not retain it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)
|
||||||
|
|
||||||
|
OCP has two classes of endpoint. Rules 1–5 above were drafted in the aftermath of the 2026-04-11 forwarding drift and are written in the language of a one-to-one proxy; they apply verbatim to **Class A** endpoints. **Class B** endpoints — the OCP-owned compatibility surface where `cli.js` is not the wire authority — have their own scope discipline, anchored to their own specification authority. The full rationale lives in **ADR 0006 (OpenAI Shim Scope)**.
|
||||||
|
|
||||||
|
**Class A** — `cli.js`-mirror endpoints. The endpoint exists because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 above apply verbatim. Citation format: `cli.js:NNNN` or `cli.js vE4 <functionName>`.
|
||||||
|
|
||||||
|
**Class B** — OCP-owned compatibility endpoints. The endpoint exists because OCP itself surfaces it, with no `cli.js` analogue. Two sub-buckets: **B.1** (OpenAI-compatibility surface — protocol authority is OpenAI's `/v1/chat/completions` specification) and **B.2** (OCP-administrative surface — authority is the ADR that authorized the endpoint's existence).
|
||||||
|
|
||||||
|
### Grandfather provision for existing B.2 inventory
|
||||||
|
|
||||||
|
ADR 0006 retroactively authorizes the B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time provision; it does not extend to new B.2 endpoints or to B.1 endpoints. Any change to the contract (request shape, response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization request and requires either a behaviour-preserving refactor PR or its own ADR. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||||
|
|
||||||
|
### Current Class B inventory
|
||||||
|
|
||||||
|
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||||
|
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||||
|
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
|
||||||
|
**Hybrid note.** `/usage` is a hybrid endpoint: the underlying call to `api.anthropic.com/v1/messages` (used to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment block at `server.mjs` line 845–849) is Class A and requires the standard `cli.js` citation; the local synthesis layer that adds `proxy:` stats and `models:` snapshot is Class B and is authorized by ADR 0006. A PR touching only the wire-call layer is Class A; a PR touching only the synthesis layer is Class B; a PR touching both must satisfy both citation requirements.
|
||||||
|
|
||||||
|
### Class B citation requirement
|
||||||
|
|
||||||
|
Class B PRs cite **the relevant specification section + the authorizing ADR**, in place of `cli.js:NNNN`. Examples:
|
||||||
|
|
||||||
|
- B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006."
|
||||||
|
- B.2 (grandfathered): "Authorized by ADR 0006 (grandfathered as of v3.16.4)."
|
||||||
|
- B.2 (with its own ADR): "Authorized by ADR 00NN (the ADR that originally authorized the endpoint)."
|
||||||
|
|
||||||
|
### Rule mapping for Class B
|
||||||
|
|
||||||
|
| Class A rule | Class B mapping |
|
||||||
|
|---|---|
|
||||||
|
| Rule 1 (Grep First) | Read the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) before writing code. Record the spec URL and ADR number in the PR body. |
|
||||||
|
| Rule 2 (No Invention) | OCP must not introduce fields or behaviour not present in OpenAI's spec for the endpoint (B.1) or outside the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, "scope" is the v3.16.4 behaviour snapshot. |
|
||||||
|
| Rule 3 (Match the Implementation) | Match OpenAI's spec wire-format (B.1) or the ADR's specified behaviour (B.2). |
|
||||||
|
| Rule 4 (Unalignable Features Are Deleted) | A Class B endpoint that maps to nothing in OpenAI's spec **and** lacks an authorizing ADR (including not being in the grandfather inventory) is unalignable and is deleted on the same terms as a Class A unalignable feature. |
|
||||||
|
| Rule 5 (Cite Line Numbers in Commits) | Cite the OpenAI spec section URL + authorizing ADR number in the commit body (B.1) or the authorizing ADR number alone (B.2). |
|
||||||
|
|
||||||
|
### New Class B endpoint procedure
|
||||||
|
|
||||||
|
Any new Class B endpoint, or any new method on an existing Class B endpoint (including grandfathered ones), requires its own ADR before merge. An "ADR-less" new Class B endpoint is itself an alignment finding under Rule 4.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Annual Alignment Audit
|
## Annual Alignment Audit
|
||||||
|
|
||||||
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
||||||
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
- **Scope (Class A):** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
||||||
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
|
- **Scope (Class B):** Audit B.1 endpoints against OpenAI's current `/v1/chat/completions` specification snapshot. Audit B.2 endpoints against their authorizing ADR — for grandfathered endpoints, verify the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, verify behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (created alongside the first B.1 audit; not required for ADR 0006 to land).
|
||||||
|
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the Class A pin and (once `docs/openai-compat-pin.md` exists) the B.1 pin.
|
||||||
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+263
@@ -1,5 +1,268 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## v3.17.1 — 2026-05-31
|
||||||
|
|
||||||
|
### Fix — code-audit P1/P2 hardening
|
||||||
|
|
||||||
|
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||||
|
|
||||||
|
**Availability / correctness (P1):**
|
||||||
|
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||||
|
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||||
|
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||||
|
|
||||||
|
**Security gates / cache integrity (P2):**
|
||||||
|
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||||
|
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||||
|
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||||
|
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||||
|
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.17.0 — 2026-05-31
|
||||||
|
|
||||||
|
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||||
|
|
||||||
|
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||||
|
|
||||||
|
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
|
||||||
|
|
||||||
|
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
|
||||||
|
|
||||||
|
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
|
||||||
|
|
||||||
|
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
|
||||||
|
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
|
||||||
|
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Model — add claude-opus-4-8
|
||||||
|
|
||||||
|
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.16.4 — 2026-05-13
|
||||||
|
|
||||||
|
### Refactor — port-literal SPOT + CI guardrail
|
||||||
|
|
||||||
|
Closes the structural side of the port-drift cascade addressed by v3.16.2
|
||||||
|
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
|
||||||
|
3456 line-by-line, but the underlying invitation to drift — a hardcoded
|
||||||
|
port literal scattered across six source files — was still intact.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- **New `lib/constants.mjs`** — single source of truth for shared literals.
|
||||||
|
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
|
||||||
|
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
|
||||||
|
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
|
||||||
|
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
|
||||||
|
all replaced with imports from `lib/constants.mjs`. Behavior is
|
||||||
|
identical; the literal `3456` now exists in exactly one place per
|
||||||
|
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
|
||||||
|
bash, `test-features.mjs` for pinned historical-port tests).
|
||||||
|
- **`.github/workflows/alignment.yml`** — extended the path filter to
|
||||||
|
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
|
||||||
|
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
|
||||||
|
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
|
||||||
|
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
|
||||||
|
workflow itself). Any future PR re-introducing a hardcoded port
|
||||||
|
literal will be blocked at CI before it can cascade.
|
||||||
|
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
|
||||||
|
banner reworded so the literal `3456` no longer appears as
|
||||||
|
documentation text (CI grep is intentionally aggressive — it does not
|
||||||
|
parse comments — so doc strings reference `DEFAULT_PORT from
|
||||||
|
lib/constants.mjs` instead).
|
||||||
|
|
||||||
|
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
|
||||||
|
the runtime override; the only difference is the unset-env fallback
|
||||||
|
now flows through one shared constant.
|
||||||
|
|
||||||
|
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
|
||||||
|
import + one literal swap, mechanical). No cli.js operation changed;
|
||||||
|
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
|
||||||
|
is the entire motivation.
|
||||||
|
|
||||||
|
## v3.16.3 — 2026-05-13
|
||||||
|
|
||||||
|
### Fixes — completes v3.16.2 port-drift revert
|
||||||
|
|
||||||
|
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
|
||||||
|
plist back to `3456` (the historical source default since `593d0dc`), but
|
||||||
|
missed three places in `scripts/` that still defaulted to `3478`. Those
|
||||||
|
three lines were the residual cascade source: every time `ocp doctor` or
|
||||||
|
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
|
||||||
|
`3478`, reported "OCP not responding" against a healthy 3456 instance,
|
||||||
|
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
|
||||||
|
re-introduced 3478 into downstream config.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- `scripts/upgrade.mjs:137` — default port `3478` → `3456`.
|
||||||
|
- `scripts/doctor.mjs:84` — default port `3478` → `3456`.
|
||||||
|
- `scripts/doctor.mjs:205` — default port `3478` → `3456`.
|
||||||
|
|
||||||
|
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
|
||||||
|
still takes precedence. The fix only affects the unset-env fallback,
|
||||||
|
which now matches `server.mjs:126` and the rest of the codebase.
|
||||||
|
|
||||||
|
Test plan: existing `test-features.mjs` cases that pin
|
||||||
|
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
|
||||||
|
the default.
|
||||||
|
|
||||||
|
## v3.16.2 — 2026-05-12
|
||||||
|
|
||||||
|
### Fixes — corrects v3.16.1
|
||||||
|
|
||||||
|
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
|
||||||
|
|
||||||
|
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
|
||||||
|
What is actually true:
|
||||||
|
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
|
||||||
|
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
|
||||||
|
|
||||||
|
This release:
|
||||||
|
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
|
||||||
|
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
|
||||||
|
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
|
||||||
|
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. 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.
|
||||||
|
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
|
||||||
|
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
|
||||||
|
|
||||||
|
### Fixes (as shipped — note erratum above)
|
||||||
|
|
||||||
|
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
|
||||||
|
## v3.16.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
|
||||||
|
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
|
||||||
|
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
|
||||||
|
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
|
||||||
|
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
|
||||||
|
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
|
||||||
|
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
|
||||||
|
at the end of the full path; light path does not (no snapshot created there).
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
|
||||||
|
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
|
||||||
|
|
||||||
|
## v3.15.1 — 2026-05-10
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install 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 version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
|
||||||
|
|
||||||
|
## v3.15.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging.
|
||||||
|
`--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim
|
||||||
|
and `human_required[]` for steps requiring the user (typically only OAuth).
|
||||||
|
- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14),
|
||||||
|
`ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from
|
||||||
|
PR #90) → service restart → post-flight `/health` + `/v1/models` verification.
|
||||||
|
Same-patch updates retain the existing light path; users see no change for routine
|
||||||
|
patch bumps.
|
||||||
|
- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot.
|
||||||
|
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never auto-deleted.
|
||||||
|
- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install
|
||||||
|
flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude
|
||||||
|
Code's credential store; users do not re-OAuth unless their token was independently broken.
|
||||||
|
- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each
|
||||||
|
start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive
|
||||||
|
install / setup / upgrade through their existing AI assistant.
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- `ocp update` may take 10–30s longer when a cross-minor jump triggers the full path
|
||||||
|
(snapshot + post-flight). Patch bumps are unchanged.
|
||||||
|
- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or
|
||||||
|
half-migrating.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
- Depends on PR #90 (plist env merge bug fix; merged before this release).
|
||||||
|
|
||||||
|
## v3.14.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features (security hardening)
|
||||||
|
|
||||||
|
- **Per-key session isolation** (PR #86, S1) — the `sessions` Map in `server.mjs` is now keyed by `${keyName}|${conversationId}` instead of bare `conversationId`. Before this fix, two clients using distinct API keys but the same `session_id` value (e.g. both defaulting to `"default"`) would share the same `cli.js` subprocess and conversation history, creating a cross-tenant leak path. Post-fix each (key, session) pair is isolated end-to-end, extending the per-key cache isolation shipped in v3.13.0 D1 to the session layer.
|
||||||
|
- **On-disk credential file modes 0700/0600** (PR #87, S2) — `setup.mjs` now creates `~/.ocp` at mode 0700 and both `admin-key` and `ocp.db` at mode 0600. An idempotent `reconcileFileModes()` call in `server.mjs` startup tightens any existing installation to these modes automatically on every launch, so existing prod boxes fix themselves without manual `chmod`. Before this fix, all three files were created at the process's default umask (typically world-readable 0644 / 0755), leaving plaintext credentials readable by other local users.
|
||||||
|
- **`/api/usage` default scope = self; admin all-keys requires `?all=true`** (PR #88, S3) — the usage endpoint now applies a least-privilege default: anonymous callers receive only their own rows, non-admin authenticated callers receive only their own rows, and admin callers receive only their own rows unless they explicitly pass `?all=true`. When `?all=true` is used, an audit log line is emitted. Before this fix, any admin-token holder could silently enumerate usage data for every key on the server.
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- **Breaking change for admin tooling**: `/api/usage` no longer returns all-keys data by default. Existing cron jobs, dashboards, or scripts that rely on the admin token seeing all-keys output must add `?all=true` to their request URL after upgrading to v3.14.0.
|
||||||
|
- **File mode reconcile at server startup** logs a one-line notice per path when mode is tightened (e.g. `[security] tightened ~/.ocp/ocp.db → 0600`). No action is required from the operator; the reconcile is idempotent and silent when modes are already correct.
|
||||||
|
- **`sessions` Map key is now `${keyName}|${conversationId}` internally.** No client-visible wire change — the `session_id` field in request/response is unchanged.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- Stress-test pass: 11/11 phases including S1/S2/S3 security regression checks (Phase E, I, J). 35-minute sustained run, 60 calls, 0 errors, 0 timeouts. RSS dropped 51→47 MB across the window. Per-key cache isolation, singleflight, cache_control bypass, quota enforcement, file-mode reconcile, and scope guard against escalation all verified against running code.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- All three PRs (#86, #87, #88) include the explicit `cli.js`-citation-not-applicable disclaimer (per PR #75 pattern) since they are OCP-internal access-control, session-state, and file-permission changes with no corresponding `cli.js` operation to cite.
|
||||||
|
|
||||||
|
### No new env vars / no public API surface change beyond the documented breaking change
|
||||||
|
|
||||||
|
This release adds no new env vars or endpoints. The only externally visible change is the `/api/usage` scope guard (breaking for admin all-keys consumers; see Behavior changes above).
|
||||||
|
|
||||||
|
## v3.13.0 — 2026-05-07
|
||||||
|
|
||||||
|
### Features (cache layer hardening)
|
||||||
|
|
||||||
|
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
|
||||||
|
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
|
||||||
|
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
|
||||||
|
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
|
||||||
|
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
|
||||||
|
|
||||||
|
### No new env vars / no public API surface change
|
||||||
|
|
||||||
|
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
|
||||||
|
|
||||||
## v3.12.0 — 2026-04-25
|
## v3.12.0 — 2026-04-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# OCP — Open Claude Proxy
|
# OCP — Open Claude Proxy
|
||||||
|
|
||||||
|
[](LICENSE) [](https://github.com/dtzp555-max/ocp/releases) [](https://buymeacoffee.com/dtzp555)
|
||||||
|
|
||||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||||
|
|
||||||
|
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||||
|
|
||||||
|
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
||||||
|
|
||||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -16,14 +22,15 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
|||||||
|
|
||||||
## Why OCP?
|
## Why OCP?
|
||||||
|
|
||||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** Concretely:
|
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||||
|
|
||||||
- **SSE heartbeat on streaming** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in via `CLAUDE_HEARTBEAT_INTERVAL`). Long Claude reasoning or tool-use pauses can sit silent for minutes; downstream load balancers and reverse proxies often kill the connection at 60s idle. OCP emits an SSE comment frame during silent windows so the connection stays alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
||||||
- **Alignment constitution + CI guardrail.** [`ALIGNMENT.md`](./ALIGNMENT.md) is the binding spec: every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. The [`alignment.yml`](./.github/workflows/alignment.yml) workflow auto-blocks PRs that introduce known-hallucinated tokens (`api/oauth/usage`, `api/usage`, etc). Hard to drift, even with LLM-assisted contributions.
|
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. No more drift between server and installer. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||||
- **Multi anonymous-key distribution** (v3.7.0). Share one OCP instance with friends/family/devices without exposing your OAuth session — each gets a per-user key, with usage tracking and revocation.
|
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||||
- **Per-key quota + response cache** (v3.8.0). Daily/weekly/monthly request limits per key. Optional SHA-256 prompt cache for development loops. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||||
- **`ocp-connect` IDE auto-config.** Detects Claude Code, Cursor, Cline, Continue.dev, opencode, and OpenClaw on the client machine and configures them in one command.
|
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
||||||
|
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||||
|
|
||||||
### Comparison
|
### Comparison
|
||||||
|
|
||||||
@@ -62,6 +69,20 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt to Claude Code / Cursor / Copilot:
|
||||||
|
|
||||||
|
```
|
||||||
|
Install OCP for me. Read README §Manual Installation and follow it.
|
||||||
|
Tell me when I need to run `claude auth login`.
|
||||||
|
```
|
||||||
|
|
||||||
|
The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you
|
||||||
|
when to OAuth.
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -78,13 +99,96 @@ OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Quick install with AI assistance
|
||||||
|
|
||||||
|
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
|
||||||
|
|
||||||
|
**Single-machine use** — install OCP for IDEs on this same machine only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this machine to use my Claude Pro/Max subscription
|
||||||
|
as an OpenAI-compatible API for local IDEs.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Server Setup" → "Single-machine use" path:
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
|
||||||
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
|
using my system's package manager.
|
||||||
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
|
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
|
||||||
|
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||||
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
|
Before each step, tell me what you'll run and wait for confirmation.
|
||||||
|
On any error, diagnose first — don't auto-retry.
|
||||||
|
```
|
||||||
|
|
||||||
|
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this device as a LAN server so my family and other
|
||||||
|
devices on the network can share my Claude Pro/Max subscription.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Server Setup" → "LAN mode" path:
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
|
||||||
|
22.5+, git, Claude CLI installed and authenticated.
|
||||||
|
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
|
||||||
|
I'll need it to manage per-user keys later.
|
||||||
|
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
|
||||||
|
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
|
||||||
|
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||||
|
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||||
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
|
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
|
||||||
|
|
||||||
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client connect** — configure this device to use an existing OCP server on your LAN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
|
||||||
|
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude
|
||||||
|
Code, OpenClaw).
|
||||||
|
|
||||||
|
Server IP: <SERVER_IP>
|
||||||
|
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
"Client Setup" path:
|
||||||
|
|
||||||
|
1. Download ocp-connect:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
chmod +x ocp-connect
|
||||||
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||||
|
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||||
|
|
||||||
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you'd rather do everything manually, the **Server Setup** and **Client Setup** sections below have the same steps in handbook form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Server Setup
|
### Server Setup
|
||||||
|
|
||||||
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
|
||||||
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
||||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
- `git`
|
||||||
|
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
|
||||||
|
```bash
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
|
||||||
|
```
|
||||||
|
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone and run setup
|
# 1. Clone and run setup
|
||||||
@@ -97,7 +201,8 @@ The setup script will:
|
|||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Start the proxy on port 3456
|
2. Start the proxy on port 3456
|
||||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
|
||||||
|
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
||||||
|
|
||||||
**Single-machine use** — just set your IDE to use the proxy:
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
```bash
|
```bash
|
||||||
@@ -112,7 +217,9 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
|||||||
|
|
||||||
Then create API keys for each person/device:
|
Then create API keys for each person/device:
|
||||||
```bash
|
```bash
|
||||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
# Generate a strong admin key (one-time — save it for later key management):
|
||||||
|
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
|
||||||
|
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
|
||||||
|
|
||||||
ocp keys add wife-laptop
|
ocp keys add wife-laptop
|
||||||
# ✓ Key created for "wife-laptop"
|
# ✓ Key created for "wife-laptop"
|
||||||
@@ -128,9 +235,25 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
|||||||
**Verify:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
curl http://127.0.0.1:3456/v1/models
|
||||||
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Headless install notes
|
||||||
|
|
||||||
|
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
|
||||||
|
|
||||||
|
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
|
||||||
|
|
||||||
|
**Option 2 — long-lived token (auth once, no re-prompts).**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude setup-token # subscription-backed long-lived token
|
||||||
|
```
|
||||||
|
|
||||||
|
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
|
||||||
|
|
||||||
|
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Uninstall
|
### Uninstall
|
||||||
@@ -147,6 +270,8 @@ Removes the launchd (macOS) or systemd (Linux) auto-start entry. Handles both le
|
|||||||
### Client Setup
|
### Client Setup
|
||||||
|
|
||||||
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||||
|
>
|
||||||
|
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
|
||||||
|
|
||||||
**One-command setup** — download the lightweight `ocp-connect` script:
|
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||||
|
|
||||||
@@ -189,7 +314,7 @@ OCP Connect v1.3.0
|
|||||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (4 models available)
|
✓ API accessible (5 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -219,6 +344,7 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
• ocp/claude-sonnet-4-6
|
• ocp/claude-sonnet-4-6
|
||||||
@@ -281,17 +407,23 @@ ocp keys revoke son-ipad # Revoke a key
|
|||||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||||
|
|
||||||
|
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
||||||
|
|
||||||
### Anonymous Access (optional)
|
### Anonymous Access (optional)
|
||||||
|
|
||||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||||
|
|
||||||
**Enable**:
|
**Enable**:
|
||||||
|
|
||||||
|
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||||
ocp start # or however you start the server
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||||
|
|
||||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||||
|
|
||||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||||
@@ -345,6 +477,7 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
|||||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||||
- Admin key is required for key management API endpoints
|
- Admin key is required for key management API endpoints
|
||||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||||
|
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
|
||||||
|
|
||||||
## Built-in Usage Monitoring
|
## Built-in Usage Monitoring
|
||||||
|
|
||||||
@@ -383,6 +516,7 @@ ocp keys List all API keys (multi mode)
|
|||||||
ocp keys add <name> Create a new API key
|
ocp keys add <name> Create a new API key
|
||||||
ocp keys revoke <name> Revoke an API key
|
ocp keys revoke <name> Revoke an API key
|
||||||
ocp connect <ip> One-command LAN client setup
|
ocp connect <ip> One-command LAN client setup
|
||||||
|
ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents.
|
||||||
ocp lan Show LAN connection info & IP
|
ocp lan Show LAN connection info & IP
|
||||||
ocp settings View tunable settings
|
ocp settings View tunable settings
|
||||||
ocp settings <k> <v> Update a setting at runtime
|
ocp settings <k> <v> Update a setting at runtime
|
||||||
@@ -409,17 +543,57 @@ ocp --help
|
|||||||
|
|
||||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||||
|
|
||||||
### Self-Update
|
## Upgrading
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Upgrade my OCP. Run `ocp update` and follow whatever it says.
|
||||||
|
If it tells me to run `claude auth login`, I'll do that.
|
||||||
|
```
|
||||||
|
|
||||||
|
What `ocp update` does:
|
||||||
|
|
||||||
|
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`):
|
||||||
|
light path (git pull + npm install + restart).
|
||||||
|
- **Cross-minor** (e.g. `v3.10 → v3.14`):
|
||||||
|
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
|
||||||
|
service restart, post-flight `/health` and `/v1/models` verification.
|
||||||
|
- **Old version** (< v3.4.0):
|
||||||
|
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
|
||||||
|
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
|
||||||
|
preserved; you do not need to re-OAuth unless your token expired
|
||||||
|
separately.
|
||||||
|
|
||||||
|
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
|
||||||
|
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
|
||||||
|
you're confident the upgrade is stable.
|
||||||
|
|
||||||
|
### Manual upgrade — same command, no AI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if a new version is available
|
ocp update # smart-pick path
|
||||||
ocp update --check
|
ocp update --check # show available updates, don't apply
|
||||||
|
ocp update --dry-run # preview plan
|
||||||
# Pull latest, sync plugin, restart proxy — one command
|
ocp update --target v3.13.0 # pin a specific version
|
||||||
ocp update
|
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
|
||||||
|
ocp update --rollback --list # list snapshots, no mutation
|
||||||
|
ocp update --rollback --dry-run # preview rollback plan
|
||||||
```
|
```
|
||||||
|
|
||||||
`ocp update` runs (in order): `git pull` → `npm install` → plugin sync → **OpenClaw model registry sync** (v3.11.0+) → proxy restart → health check.
|
### When upgrade fails
|
||||||
|
|
||||||
|
`ocp update` prints a recovery line on failure. To restore from the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp update --rollback --yes # --yes confirms the destructive restore
|
||||||
|
ocp doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ocp doctor` still reports problems after rollback, open a GitHub issue
|
||||||
|
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
|
||||||
|
|
||||||
### OpenClaw Auto-Sync (v3.11.0+)
|
### OpenClaw Auto-Sync (v3.11.0+)
|
||||||
|
|
||||||
@@ -472,17 +646,20 @@ ocp settings cacheTTL 300000
|
|||||||
```
|
```
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
- Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
|
||||||
|
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
|
||||||
- Cache hits return instantly — no Claude CLI process spawned
|
- Cache hits return instantly — no Claude CLI process spawned
|
||||||
- Works for both streaming and non-streaming requests
|
- **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta — incremental render preserved
|
||||||
|
- **`cache_control` bypass** — if a request carries an Anthropic `cache_control` annotation (top-level or nested in `content[]`), OCP skips its own cache entirely so it doesn't interfere with Anthropic-side prompt caching
|
||||||
|
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses to the leader's call. Non-streaming path only (streaming-path singleflight is a known TODO)
|
||||||
- Multi-turn conversations (with `session_id`) are never cached
|
- Multi-turn conversations (with `session_id`) are never cached
|
||||||
- Expired entries are cleaned up automatically every 10 minutes
|
- Expired entries are cleaned up automatically every 10 minutes
|
||||||
|
|
||||||
**Management:**
|
**Management:**
|
||||||
```bash
|
```bash
|
||||||
# View cache stats
|
# View cache stats (now includes singleflight in-flight counts)
|
||||||
curl http://127.0.0.1:3456/cache/stats
|
curl http://127.0.0.1:3456/cache/stats
|
||||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
|
||||||
|
|
||||||
# Clear all cached responses
|
# Clear all cached responses
|
||||||
curl -X DELETE http://127.0.0.1:3456/cache
|
curl -X DELETE http://127.0.0.1:3456/cache
|
||||||
@@ -493,20 +670,23 @@ ocp settings cacheTTL 0
|
|||||||
|
|
||||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
||||||
|
|
||||||
|
**Hash format upgrade in v3.13.0:** legacy `v1` cache rows from earlier versions don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window. No migration script required.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
```
|
```
|
||||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
|
||||||
```
|
```
|
||||||
|
|
||||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-6` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
@@ -537,7 +717,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||||
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
||||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`); returns self only by default — pass `?all=true` (admin only) for all-keys data |
|
||||||
| `/cache/stats` | GET | Cache statistics (admin only) |
|
| `/cache/stats` | GET | Cache statistics (admin only) |
|
||||||
| `/cache` | DELETE | Clear response cache (admin only) |
|
| `/cache` | DELETE | Clear response cache (admin only) |
|
||||||
|
|
||||||
@@ -587,6 +767,52 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
|
||||||
|
anything that needs human input.
|
||||||
|
```
|
||||||
|
|
||||||
|
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
|
||||||
|
the agent runs verbatim) and `human_required[]` (steps that need you,
|
||||||
|
typically just OAuth).
|
||||||
|
|
||||||
|
### Manual debugging
|
||||||
|
|
||||||
|
### Setup fails with "claude: command not found"
|
||||||
|
|
||||||
|
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
||||||
|
|
||||||
|
### Setup fails with "EADDRINUSE: port 3456 already in use"
|
||||||
|
|
||||||
|
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -nP -iTCP:3456 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
If it's an old OCP process, stop it before re-running setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp stop # if the CLI is on PATH
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback
|
||||||
|
sudo systemctl stop ocp-proxy # Linux systemd fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup fails with "node: command not found" or version error
|
||||||
|
|
||||||
|
OCP requires Node.js 22.5+. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node # macOS
|
||||||
|
# Linux: see https://nodejs.org/en/download for current install commands
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm with `node --version` (should be ≥ v22.5).
|
||||||
|
|
||||||
### Requests fail or agents stuck
|
### Requests fail or agents stuck
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -631,7 +857,8 @@ Future `ocp update` invocations sync automatically.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
|
||||||
|
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
|
||||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||||
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||||
@@ -647,6 +874,11 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||||
|
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||||
|
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
|
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||||
|
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||||
|
|
||||||
### Streaming heartbeat
|
### Streaming heartbeat
|
||||||
|
|
||||||
@@ -658,6 +890,73 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
|
|||||||
|
|
||||||
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
||||||
|
|
||||||
|
## Subscription-pool (TUI) mode
|
||||||
|
|
||||||
|
> **SECURITY — read before enabling.**
|
||||||
|
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
|
||||||
|
|
||||||
|
### What it is and why
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| Launch method | `cc_entrypoint` | Billing pool |
|
||||||
|
|---------------|-----------------|-------------|
|
||||||
|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
|
||||||
|
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
|
||||||
|
|
||||||
|
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||||
|
|
||||||
|
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
|
||||||
|
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
|
||||||
|
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
|
||||||
|
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
|
||||||
|
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
|
||||||
|
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
|
||||||
|
|
||||||
|
### Enabling TUI-mode (opt-in)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites
|
||||||
|
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||||
|
# tmux must be installed: brew install tmux / apt install tmux
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# Optionally tune:
|
||||||
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
|
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart OCP. At boot you will see:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||||
|
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### What changes / what doesn't
|
||||||
|
|
||||||
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
|
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||||
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset CLAUDE_TUI_MODE
|
||||||
|
# restart OCP
|
||||||
|
```
|
||||||
|
|
||||||
|
The stream-json path is restored immediately. No other change is needed.
|
||||||
|
|
||||||
|
### Architecture and design decisions
|
||||||
|
|
||||||
|
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
Top-level files a contributor or operator may need to know:
|
Top-level files a contributor or operator may need to know:
|
||||||
@@ -701,6 +1000,24 @@ OCP runs under a small set of binding documents so contributions stay aligned wi
|
|||||||
|
|
||||||
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
|
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
|
||||||
|
|
||||||
|
## Support OCP
|
||||||
|
|
||||||
|
OCP has been **open source from day one** — not a freemium tool, not a commercial product turned open, just open. It will stay that way forever. No paid tiers, no premium features, no "Pro" version locked behind a paywall.
|
||||||
|
|
||||||
|
I built it because my family and I needed it. We use OCP every day across our own machines and IDEs — keeping one Claude Pro/Max subscription powering everything, saving the per-token API cost we'd otherwise pay. It's been quietly heartwarming to hear from users online who say OCP has saved them money the same way it saves ours. That's the whole point.
|
||||||
|
|
||||||
|
Behind every version are hundreds of hours that don't show up in commits: building it from scratch, adding new features as the Claude Code ecosystem evolves, debugging across Mac / Windows / Linux machines, validating against half a dozen IDEs (Claude Code, Cursor, Cline, OpenCode, Aider, Continue.dev, OpenClaw), tracking down `cli.js` drift, OAuth refresh edge cases, SSE streaming quirks, concurrency leaks, and the occasional incident that turns into a multi-day investigation (the [2026-04-11 alignment drift](./docs/adr/0002-alignment-constitution.md), the [v3.11.1 concurrency leak](./CHANGELOG.md), the v3.12 SSE replay regression).
|
||||||
|
|
||||||
|
**The commitment**: this project will keep being updated, keep getting new features, and will stay open source as long as I'm able to maintain it.
|
||||||
|
|
||||||
|
**Please try it.** If something breaks or could be better, [open an issue](https://github.com/dtzp555-max/ocp/issues) — feedback is genuinely what keeps the project moving.
|
||||||
|
|
||||||
|
And if OCP saves you (or your team, or your family) real money and you'd like to chip in toward the next debugging session:
|
||||||
|
|
||||||
|
- ☕ **[Buy me a coffee](https://buymeacoffee.com/dtzp555)**
|
||||||
|
|
||||||
|
Donations directly fund the time it takes to keep OCP saving the community money.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [`LICENSE`](LICENSE).
|
MIT — see [`LICENSE`](LICENSE).
|
||||||
|
|||||||
+21
-2
@@ -55,7 +55,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Usage by Key</h2>
|
<div class="flex" style="justify-content: space-between; align-items: center;">
|
||||||
|
<h2 style="margin: 0;">Usage by Key</h2>
|
||||||
|
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
||||||
|
<span>Show all keys</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<table id="key-usage-table">
|
<table id="key-usage-table">
|
||||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@@ -170,7 +176,8 @@ async function refreshStatus() {
|
|||||||
|
|
||||||
async function refreshUsage() {
|
async function refreshUsage() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/usage");
|
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||||
|
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -204,6 +211,8 @@ async function refreshKeys() {
|
|||||||
try {
|
try {
|
||||||
const data = await api("/api/keys");
|
const data = await api("/api/keys");
|
||||||
document.getElementById("key-mgmt-section").style.display = "";
|
document.getElementById("key-mgmt-section").style.display = "";
|
||||||
|
// Admin-only "Show all keys" toggle for /api/usage scope.
|
||||||
|
document.getElementById("usage-scope-toggle").style.display = "flex";
|
||||||
const tbody = document.querySelector("#keys-table tbody");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -240,6 +249,16 @@ async function refreshAll() {
|
|||||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
||||||
|
(function setupUsageScopeToggle() {
|
||||||
|
const cb = document.getElementById("usage-show-all");
|
||||||
|
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||||
|
cb.addEventListener("change", () => {
|
||||||
|
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
||||||
|
refreshUsage();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 0006 — OpenAI Shim Scope: Class A vs Class B Endpoints
|
||||||
|
|
||||||
|
- **Date**: 2026-05-20
|
||||||
|
- **Status**: Proposed — owner reviewing
|
||||||
|
- **Authors**: project maintainer (with AI drafting assistance)
|
||||||
|
- **Related**: `ALIGNMENT.md` (the constitution); ADR 0002 (Alignment Constitution provenance, PR #20, commit 2853088); PR #99 by external contributor (triggering incident — OpenAI `response_format` honoring on `/v1/chat/completions`)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`ALIGNMENT.md` was drafted in the aftermath of the 2026-04-11 drift (commit `b87992f` — fabricated `/api/oauth/usage` endpoint) and ratified in PR #20 / commit 2853088. Its five Rules are written in the language of a one-to-one proxy: Rule 1 (Grep First), Rule 2 (No Invention), Rule 3 (Match the Implementation), Rule 4 (Unalignable Features Are Deleted), Rule 5 (Cite Line Numbers in Commits). All five anchor explicitly on `cli.js` as the golden reference. This is correct and binding for the endpoints OCP was originally designed to forward — `/v1/messages`, `/api/oauth/*`, and the rate-limit-header extraction path that backs `/usage` — because for those, `cli.js` is the literal wire authority and any deviation is a drift risk.
|
||||||
|
|
||||||
|
OCP also exposes a second class of endpoint that the constitution does not currently distinguish: **OpenAI-compatible surface** that exists so non-Claude-Code clients (Honcho, OpenWebUI, OpenAI SDK consumers, BYO scripts) can talk to claude via OCP. The flagship is `/v1/chat/completions`, which translates between OpenAI's request/response schema and `cli.js`'s native protocol. `cli.js` never speaks OpenAI's wire format — by construction it cannot, because OpenAI and Anthropic are different vendors with different protocols. There is no `cli.js:NNNN` to cite for OpenAI's `messages[].role` field handling, OpenAI's streaming `delta` shape, OpenAI's `stop` event names, or OpenAI's `response_format` parameter. The protocol authority for these is OpenAI's published specification, not `cli.js`.
|
||||||
|
|
||||||
|
The structural gap surfaced when PR #99 (external contributor `jaekwon-park`) added support for the OpenAI `response_format` request field on `/v1/chat/completions`. A strict reading of Rule 2 ("OCP must not introduce request fields that are not present in `cli.js`") blocks the PR. But the same strict reading also blocks the existence of `/v1/chat/completions` itself — every OpenAI-shaped field on that endpoint is, by definition, not in `cli.js`. The endpoint has been in OCP since before the constitution was written and is used by real downstream consumers. The constitution and the endpoint cannot both be correct under the current reading.
|
||||||
|
|
||||||
|
The 2026-04-11 drift remains the cautionary tale that drove the constitution and remains binding. The drift was not "OCP exposed an endpoint that wasn't in `cli.js`" — it was specifically "OCP claimed to forward `cli.js`'s `/api/oauth/usage` call when no such call exists in `cli.js`." That is a Class A failure mode: a forwarding endpoint that lied about what it was forwarding. The fix to that failure mode (Rules 1, 2, 3, 5; CI blacklist; reviewer gate) was correct then and is correct now. This ADR does not relitigate that decision and does not soften Rules 1–5 for the class of endpoint they were designed to discipline.
|
||||||
|
|
||||||
|
What this ADR does is acknowledge that OCP has two classes of endpoint, and that the discipline that fits Class A does not fit Class B without distortion. Class B needs its own anchor (OpenAI's specification) and its own authorization gate (an ADR per endpoint), so contributors know exactly which rule set applies to their PR and so Class B never becomes a backdoor for "OCP can do anything OpenAI-shaped."
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce an explicit two-class taxonomy of OCP endpoints:
|
||||||
|
|
||||||
|
- **Class A — `cli.js`-mirror endpoints.** Endpoints that exist because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 of `ALIGNMENT.md` apply verbatim. The citation requirement is `cli.js:NNNN` (or `cli.js vE4 <functionName>`).
|
||||||
|
|
||||||
|
- **Class B — OCP-owned compatibility endpoints.** Endpoints that exist because OCP itself surfaces them, with no `cli.js` analogue. They fall into two sub-buckets:
|
||||||
|
- **B.1 — OpenAI-compatibility surface.** Endpoints implementing OpenAI's published API contract so non-Anthropic clients can use OCP. The protocol authority is OpenAI's specification.
|
||||||
|
- **B.2 — OCP-administrative surface.** Endpoints that exist purely to operate the proxy itself (health, dashboard, key management, cache control). The authority for these is the ADR that authorized the endpoint's existence.
|
||||||
|
|
||||||
|
For Class B endpoints, the citation requirement shifts from `cli.js:NNNN` to **(a)** the relevant specification section (OpenAI spec section for B.1, or the authorizing ADR for B.2) **and (b)** the ADR that authorized the endpoint's existence in the first place.
|
||||||
|
|
||||||
|
### Grandfather provision for existing B.2 inventory
|
||||||
|
|
||||||
|
ADR 0006 retroactively authorizes the existing B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time grandfather provision intended to avoid a 12-ADR back-fill burden for endpoints that have existed in OCP since before any constitutional governance was written.
|
||||||
|
|
||||||
|
The grandfather provision is narrowly scoped:
|
||||||
|
|
||||||
|
- It covers only the B.2 endpoints enumerated in the inventory table as of this ADR's merge date.
|
||||||
|
- It freezes those endpoints at their **current behaviour**. Any change to the request shape, response shape, or semantics of a grandfathered B.2 endpoint is treated as a new authorization request and requires either (a) a behaviour-preserving refactor PR with no contract change, or (b) its own ADR.
|
||||||
|
- It does **not** authorize new B.2 endpoints. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||||
|
- It does **not** extend to B.1 (OpenAI-compat) endpoints. B.1 endpoints are bounded by OpenAI's published specification, not by a behaviour snapshot — there is no grandfather equivalent for them.
|
||||||
|
|
||||||
|
The structural intent is: take the one-time hit of declaring "current B.2 surface is authorized" cleanly, then make every future addition pay the ADR-per-endpoint cost. This prevents Class B from becoming a backdoor for general OCP-owned-surface invention while not blocking the present ADR on twelve back-fill PRs.
|
||||||
|
|
||||||
|
### Current Class B inventory (enumerated from `server.mjs`)
|
||||||
|
|
||||||
|
The following endpoints exist today in `server.mjs` and are Class B (no `cli.js` analogue):
|
||||||
|
|
||||||
|
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||||
|
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||||
|
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
|
||||||
|
For Class A reference, the current Class A inventory is `/v1/messages` (forwarded directly to `api.anthropic.com/v1/messages`) and the OAuth bearer / rate-limit-header machinery used by `handleUsage()` (which calls `https://api.anthropic.com/v1/messages` to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment at line 845–849). The `GET /usage` endpoint surface itself is Class B (administrative augmentation: it adds `proxy:` and `models:` blocks not present in any upstream API), but the data fetch underlying it is Class A — see "Hybrid endpoints" below.
|
||||||
|
|
||||||
|
### Hybrid endpoints
|
||||||
|
|
||||||
|
`/usage` is a hybrid: the wire call out to `api.anthropic.com/v1/messages` is Class A and must continue to cite `cli.js`; the local synthesis on top (`proxy:` stats block, `models:` snapshot, response shape) is Class B and is authorized by this ADR. Any future change strictly to the wire-call layer is Class A; any change strictly to the synthesis layer is Class B. A PR touching both must satisfy both citation requirements.
|
||||||
|
|
||||||
|
## What does NOT change
|
||||||
|
|
||||||
|
The following continue to apply verbatim and are not weakened by this ADR:
|
||||||
|
|
||||||
|
- **Rules 1, 2, 3, 4, 5 of `ALIGNMENT.md`** for all Class A endpoints. The 2026-04-11 drift discipline is unchanged. Class A PRs still require `cli.js:NNNN` citations, still must match `cli.js`'s wire format byte-for-byte, and still face the Unalignable Policy if the citation cannot be produced.
|
||||||
|
- **CI blacklist** (`.github/workflows/alignment.yml`). The known-hallucinated token list (currently `api.anthropic.com/api/oauth/usage`) continues to be greppable-and-failable on every PR.
|
||||||
|
- **Reviewer gate** (CLAUDE.md hard requirements + Iron Rule 10). Implementation author may not self-approve; a fresh-context reviewer opens `cli.js` at the cited lines for Class A PRs.
|
||||||
|
- **Annual Alignment Audit** on 11 April. The Class A audit (re-verify each `server.mjs` Class A reference against the pinned `cli.js` SHA-256) continues unchanged.
|
||||||
|
- **Unalignable Policy.** A Class A endpoint that cannot be traced to a `cli.js` reference is still deleted, not deprecated.
|
||||||
|
- **Historical Lesson section in `ALIGNMENT.md`.** The 2026-04-11 drift remains the named cautionary incident, with commit SHAs intact.
|
||||||
|
|
||||||
|
## What additionally applies to Class B
|
||||||
|
|
||||||
|
The following are new and apply only to Class B endpoints:
|
||||||
|
|
||||||
|
1. **OpenAI specification as protocol authority (B.1).** The OpenAI compatibility surface follows OpenAI's published `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create) — not OCP imagination, not "OpenAI probably does X," not generalization from adjacent OpenAI endpoints. The same anti-invention discipline that Rule 2 imposes for `cli.js` applies, with OpenAI's spec substituted as the reference.
|
||||||
|
|
||||||
|
2. **ADR-authorized endpoint existence.** Any new Class B endpoint, or any new Class B endpoint method, requires its own ADR before merge. The grandfather provision above covers existing B.2 inventory only. An "ADR-less" Class B endpoint added after this ADR merges is itself an alignment finding and is subject to deletion under a Class B equivalent of the Unalignable Policy (see Rule 4 mapping in `ALIGNMENT.md`'s new section).
|
||||||
|
|
||||||
|
3. **Class B citation format.** Class B PRs cite (a) the relevant specification section and (b) the authorizing ADR. Example for B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006." Example for B.2: "Authorized by ADR 0006 (grandfathered)" for grandfathered endpoints, or "Authorized by ADR 00NN" for endpoints with their own ADR.
|
||||||
|
|
||||||
|
4. **Class B audit cadence.** Class B endpoints are audited annually alongside the Class A audit. B.1 endpoints are audited against OpenAI's current `/v1/chat/completions` specification snapshot. B.2 endpoints (grandfathered or ADR-specific) are audited against their authorizing ADR — for grandfathered endpoints, the audit verifies the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, the audit verifies behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (to be created alongside the first B.1 audit; not a prerequisite for this ADR to land).
|
||||||
|
|
||||||
|
5. **Reviewer expectation.** The fresh-context reviewer for a Class B PR opens the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) instead of opening `cli.js`. The "I am not the commit author" rule and the "explicit approval comment naming the verified reference" rule continue.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- PR #99 becomes mergeable with a one-line scope declaration ("Class B — extends `/v1/chat/completions` per ADR 0006") plus the existing alignment-evidence section adapted to Class B citation format. The structural ambiguity that blocked it is removed.
|
||||||
|
- Future Class B contributors have a clear template and a defensible scope: "extend `/v1/chat/completions` for an OpenAI-spec field that's already in OpenAI's spec" is a well-formed PR; "add a new OCP-invented field that looks OpenAI-shaped" is not, and the same anti-invention discipline that protects Class A protects Class B.
|
||||||
|
- The Class A surface is structurally unchanged. Reviewers reading the new `ALIGNMENT.md` see a clean Class A regime with all five Rules intact, plus an enumerated and explicitly scoped Class B carve-out.
|
||||||
|
- The administrative endpoint surface (B.2) is no longer in a "is this even allowed under the constitution?" limbo. The grandfather provision cleanly authorizes the current inventory; new B.2 endpoints must earn their ADR.
|
||||||
|
|
||||||
|
**Negative**
|
||||||
|
|
||||||
|
- OCP now maintains a second alignment surface. OpenAI's `/v1/chat/completions` specification is also a moving target (OpenAI ships changes more than once per year, including breaking ones), so the B.1 audit has real work attached.
|
||||||
|
- The grandfather provision freezes the current B.2 behaviour. If a grandfathered B.2 endpoint has a latent bug or undesirable behaviour, "fixing" it is a contract change and now requires an ADR (or a behaviour-preserving refactor). This is intentional friction to prevent silent contract drift.
|
||||||
|
- Contributors must now choose Class A or Class B on every PR. Some will misclassify. The PR template's required Class A/B radio (see PR template update) and the reviewer's spec-or-cli verification step are the structural counter-measures.
|
||||||
|
|
||||||
|
**Mitigations**
|
||||||
|
|
||||||
|
- The Class B inventory is small (currently 14 endpoints) and is enumerated explicitly in `ALIGNMENT.md`. New entries require an ADR per item 2 above, so the inventory cannot grow silently.
|
||||||
|
- Anthropic-side change frequency (which drives Class A audit cost) is structurally higher than OpenAI's `chat/completions` shape, which has been stable across multiple OpenAI API versions. The marginal B.1 audit cost is low. The grandfathered B.2 audit cost is also low — most of those endpoints have not changed in months.
|
||||||
|
- The B.1 specification pin in `docs/openai-compat-pin.md` lets the audit anchor on a specific OpenAI spec snapshot, the same way the Class A pin anchors on a specific `cli.js` SHA-256. Drift detection then works the same way for both classes.
|
||||||
|
|
||||||
|
## Historical Lesson — explicit non-relitigation
|
||||||
|
|
||||||
|
This ADR does not relitigate the 2026-04-11 drift. The drift commit `b87992f` was Class A — it claimed `cli.js` forwarded a call that `cli.js` did not in fact make. The fix (constitution + CI blacklist + reviewer gate) was correct and remains binding for Class A. This ADR carves out Class B because the discipline that fits Class A does not fit a class of endpoint where `cli.js` is not the wire authority — not because the discipline was wrong, and not because the drift lesson is any less load-bearing.
|
||||||
|
|
||||||
|
A reviewer or future maintainer reading this ADR should not infer: "OCP relaxed its alignment rules." The Class A regime is structurally identical to the version that shipped in PR #20. What changed is that the constitution now names the scope of that regime precisely (the class of endpoint for which `cli.js` is the wire authority) instead of implicitly applying it to every endpoint, including ones the regime was never designed for.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**(a) Refuse Class B as a category; close PR #99 and delete `/v1/chat/completions`.** This would resolve the structural ambiguity by enforcing Rule 2 maximally — if `cli.js` doesn't speak OpenAI's protocol, neither does OCP. Rejected: there is an existing user base on the OpenAI-compat surface, the surface is genuinely useful (it is OCP's bridge to non-Claude-Code agents), and deletion would be a load-bearing user-facing breakage in service of a doctrinal point that the constitution was never designed to make. The constitution was a response to the 2026-04-11 forwarding drift, not a charter against any OCP-owned surface.
|
||||||
|
|
||||||
|
**(b) Soften Rule 2 to "OCP must not introduce surface area not present in `cli.js` OR not authorized by an ADR."** This is the obvious diff and would unblock PR #99 with the smallest possible textual change. Rejected because it loses the precision that Class A needs. A combined Rule 2 means a Class A reviewer has to read the PR description twice to figure out which authority applies. The Class A/B split makes the question explicit at the PR-template level (the author picks the class) and at the reviewer level (the reviewer opens the appropriate reference). The cost of the split is one new section in `ALIGNMENT.md`; the benefit is no ambiguity in either class.
|
||||||
|
|
||||||
|
**(c) Move `/v1/chat/completions` and all OpenAI-compat surface out of OCP into a separate "ocp-openai-shim" repository.** This would cleanly resolve the scope question by moving Class B out of OCP entirely. Rejected as premature: the maintainer is one person, the OpenAI-compat surface today is a single endpoint plus its support, and the operational cost of two repositories (separate releases, separate CI, separate version coordination) exceeds the cost of one constitution with two named classes. If the OpenAI-compat surface ever grows to the size where a separate repo is justified, ADR 0006 is the natural pivot point — at that future date, the carve-out becomes a separation.
|
||||||
|
|
||||||
|
**(d) Twelve-ADR back-fill for the existing B.2 inventory before this ADR can merge.** Considered and rejected on cost grounds. Each back-fill ADR would be a short paragraph explaining what an existing endpoint does and why it's allowed; the educational value is low and the merge friction is high (12 PRs through the reviewer gate). The grandfather provision above achieves the same authorization outcome in one paragraph, while still requiring an ADR for any future B.2 endpoint. The trade-off: grandfathered endpoints are not individually documented to ADR-depth. Mitigation: the inventory table in `ALIGNMENT.md` lists every grandfathered endpoint by path and method, so the audit surface remains explicit.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
||||||
|
**Deciders:** project maintainer
|
||||||
|
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| `cc_entrypoint` value | Billing pool |
|
||||||
|
|-----------------------|-------------|
|
||||||
|
| `cli` | Pro/Max subscription pool |
|
||||||
|
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
|
||||||
|
|
||||||
|
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
|
||||||
|
|
||||||
|
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
|
||||||
|
|
||||||
|
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
|
||||||
|
|
||||||
|
### Source-verified entrypoint mechanism (PR-4 amendment)
|
||||||
|
|
||||||
|
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
|
||||||
|
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
|
||||||
|
|
||||||
|
```
|
||||||
|
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
|
||||||
|
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
|
||||||
|
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
|
||||||
|
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
|
||||||
|
drawing from the metered pool.
|
||||||
|
|
||||||
|
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
|
||||||
|
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
|
||||||
|
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
|
||||||
|
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
|
||||||
|
labelled as such.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
|
||||||
|
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
|
||||||
|
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
|
||||||
|
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
|
||||||
|
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
|
||||||
|
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||||
|
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
|
||||||
|
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
|
||||||
|
inherited value first** so a stray env var from OCP's own parent process can never leak in and
|
||||||
|
mislabel the billing header. Then:
|
||||||
|
|
||||||
|
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|
||||||
|
|----------------------|-----------|
|
||||||
|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
|
||||||
|
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
|
||||||
|
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
|
||||||
|
|
||||||
|
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
|
||||||
|
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
|
||||||
|
guarantee a PTY), it fails/drops the request — it does not force the signal.*
|
||||||
|
|
||||||
|
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
|
||||||
|
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
|
||||||
|
|
||||||
|
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
|
||||||
|
|
||||||
|
### 2026-06-15 verification protocol
|
||||||
|
|
||||||
|
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
|
||||||
|
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
|
||||||
|
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
|
||||||
|
escalate spoofing.
|
||||||
|
|
||||||
|
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
|
||||||
|
major cli.js upgrade.
|
||||||
|
|
||||||
|
### Default behaviour is unchanged
|
||||||
|
|
||||||
|
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||||
|
|
||||||
|
### Home strategy (real-home default)
|
||||||
|
|
||||||
|
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
||||||
|
|
||||||
|
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||||
|
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
|
||||||
|
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
|
||||||
|
|
||||||
|
### MCP hard-disable
|
||||||
|
|
||||||
|
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
|
||||||
|
|
||||||
|
### Session namespace
|
||||||
|
|
||||||
|
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECURITY — PROMINENT WARNING
|
||||||
|
|
||||||
|
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
|
||||||
|
|
||||||
|
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
|
||||||
|
|
||||||
|
- A guest prompt would run `claude` with the **operator's** filesystem access.
|
||||||
|
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
|
||||||
|
|
||||||
|
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
|
||||||
|
|
||||||
|
The B-path (multi-tenant isolation) requires:
|
||||||
|
1. `--tools ""` (no built-in tools)
|
||||||
|
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
|
||||||
|
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
|
||||||
|
|
||||||
|
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
|
||||||
|
- Kill-switch is immediate (unset env var + restart); zero code change required.
|
||||||
|
- Default stream-json path is untouched — no regression risk for existing deployments.
|
||||||
|
|
||||||
|
### Negative / trade-offs
|
||||||
|
|
||||||
|
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
|
||||||
|
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
|
||||||
|
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
|
||||||
|
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
|
||||||
|
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
### Coexistence
|
||||||
|
|
||||||
|
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||||
@@ -22,6 +22,7 @@ New ADRs increment from the highest existing number. Filenames are
|
|||||||
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
|
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
|
||||||
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
|
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
|
||||||
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
|
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
|
||||||
|
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 1–5 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
|
||||||
|
|
||||||
## When to write a new ADR
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 366 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
# OpenAI Compatibility Pin (Class B.1)
|
||||||
|
|
||||||
|
**Status:** Placeholder — populated at first B.1 audit per ADR 0006 §"Class B audit cadence".
|
||||||
|
|
||||||
|
This file is the Class B.1 counterpart to the Class A `cli.js` audit pin in `ALIGNMENT.md` §"Golden Reference". When the first annual alignment audit covers Class B (per `ALIGNMENT.md` §"Annual Alignment Audit"), this file will be populated with:
|
||||||
|
|
||||||
|
- The OpenAI `/v1/chat/completions` specification snapshot date being audited against (and the source URL the snapshot was taken from).
|
||||||
|
- The list of B.1 endpoints (currently `/v1/chat/completions`, `/v1/models`) and, for each one, the specific OpenAI spec fields and behaviours it honors.
|
||||||
|
- Drift detection notes for any OpenAI spec changes since the previous audit, and any OCP code changes required to track those changes.
|
||||||
|
|
||||||
|
Until populated, this file's existence is only a forward reference so that the link in `ALIGNMENT.md` does not 404. The actual audit procedure is defined in `ALIGNMENT.md` §"Annual Alignment Audit" (Class B scope) and ADR 0006 §"Class B audit cadence".
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
|
||||||
|
|
||||||
|
**Status:** Forward-looking planning doc (not yet a decision)
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Audience:** future OCP maintainer / session picking up multi-tenant security work
|
||||||
|
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this doc exists
|
||||||
|
|
||||||
|
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
|
||||||
|
|
||||||
|
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
|
||||||
|
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
|
||||||
|
|
||||||
|
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
|
||||||
|
|
||||||
|
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The multi-tenant gap (OCP-specific)
|
||||||
|
|
||||||
|
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
|
||||||
|
|
||||||
|
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
|
||||||
|
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
|
||||||
|
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
|
||||||
|
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
|
||||||
|
|
||||||
|
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
|
||||||
|
|
||||||
|
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
|
||||||
|
|
||||||
|
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
|
||||||
|
|
||||||
|
### 3.1 Anthropic's design doesn't expect external sandboxing
|
||||||
|
|
||||||
|
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
|
||||||
|
|
||||||
|
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
|
||||||
|
|
||||||
|
### 3.2 `~/.claude.json` upstream status is "closed not planned"
|
||||||
|
|
||||||
|
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
|
||||||
|
|
||||||
|
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
|
||||||
|
|
||||||
|
### 3.3 The threat model doesn't justify the cost
|
||||||
|
|
||||||
|
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
|
||||||
|
|
||||||
|
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
|
||||||
|
|
||||||
|
### 3.4 OLP-specific reason that does NOT apply to OCP
|
||||||
|
|
||||||
|
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.1–3.3) are sufficient on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Three viable approaches for OCP
|
||||||
|
|
||||||
|
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
|
||||||
|
|
||||||
|
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
|
||||||
|
|
||||||
|
Per-spawn setup:
|
||||||
|
|
||||||
|
```
|
||||||
|
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
|
||||||
|
mkdir -p $ephemeralRoot/.claude
|
||||||
|
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
|
||||||
|
HOME=$ephemeralRoot claude -p --output-format stream-json ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Mechanics:
|
||||||
|
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
|
||||||
|
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
|
||||||
|
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
|
||||||
|
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
|
||||||
|
|
||||||
|
Threat coverage:
|
||||||
|
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
|
||||||
|
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
|
||||||
|
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
|
||||||
|
|
||||||
|
5-minute spike before adopting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
|
||||||
|
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
|
||||||
|
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
|
||||||
|
```
|
||||||
|
|
||||||
|
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
|
||||||
|
|
||||||
|
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
|
||||||
|
|
||||||
|
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
bwrap \
|
||||||
|
--ro-bind / / \
|
||||||
|
--tmpfs /home/<ocp-user> \
|
||||||
|
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
|
||||||
|
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
|
||||||
|
--dev /dev --proc /proc --tmpfs /tmp \
|
||||||
|
claude -p ...
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
|
||||||
|
|
||||||
|
Threat coverage:
|
||||||
|
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
|
||||||
|
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
|
||||||
|
- ✅ `~/.ssh` and similar identity material absent from sandbox.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- bwrap dependency: install `bubblewrap` apt package on host.
|
||||||
|
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
|
||||||
|
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
|
||||||
|
|
||||||
|
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
|
||||||
|
|
||||||
|
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
|
||||||
|
|
||||||
|
```
|
||||||
|
mount -t overlay overlay \
|
||||||
|
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
|
||||||
|
/tmp/ocp-spawn/<reqId>/merged-claude
|
||||||
|
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
|
||||||
|
# After spawn: umount + rm -rf
|
||||||
|
```
|
||||||
|
|
||||||
|
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
|
||||||
|
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
|
||||||
|
|
||||||
|
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
|
||||||
|
|
||||||
|
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cross-key isolation orthogonal layer
|
||||||
|
|
||||||
|
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
|
||||||
|
|
||||||
|
For that, two options compose with any of A/B/C:
|
||||||
|
|
||||||
|
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
|
||||||
|
|
||||||
|
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
|
||||||
|
|
||||||
|
```
|
||||||
|
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
|
||||||
|
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
|
||||||
|
filesystem: {
|
||||||
|
denyRead: [
|
||||||
|
...otherKeysWorkspaces, // all keys except current
|
||||||
|
'/home/<ocp-user>/.ssh',
|
||||||
|
'/home/<ocp-user>/.gnupg',
|
||||||
|
'/home/<ocp-user>/.aws',
|
||||||
|
],
|
||||||
|
allowWrite: [ephemeralRoot, '/tmp'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
|
||||||
|
|
||||||
|
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
|
||||||
|
|
||||||
|
### 5.2 Per-OS-user OCP spawning
|
||||||
|
|
||||||
|
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
|
||||||
|
|
||||||
|
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- Setup script complexity (one-time per key).
|
||||||
|
- Linux-only.
|
||||||
|
- Doesn't fit Mac mini deployment.
|
||||||
|
|
||||||
|
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trust model framing
|
||||||
|
|
||||||
|
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
|
||||||
|
|
||||||
|
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
|
||||||
|
|
||||||
|
| Tier | Trust Model | Sandbox requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
|
||||||
|
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
|
||||||
|
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
|
||||||
|
|
||||||
|
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recommendation if/when this work starts
|
||||||
|
|
||||||
|
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
|
||||||
|
- ~50 LOC, no apt deps, works on Mac mini + Linux
|
||||||
|
- Solves the EROFS upgrade tax structurally
|
||||||
|
- Closes cross-key OAuth-credential-file lateral read
|
||||||
|
- Cost-effective hygiene improvement
|
||||||
|
|
||||||
|
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
|
||||||
|
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
|
||||||
|
- Default off (preserves Mac mini family deployment)
|
||||||
|
- Operator opts in on Linux cloud deployments
|
||||||
|
- Apt prereq documented in deployment guide
|
||||||
|
|
||||||
|
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
|
||||||
|
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
|
||||||
|
- Treat as separate ADR amendment with its own threat-model evidence
|
||||||
|
|
||||||
|
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Authority citations
|
||||||
|
|
||||||
|
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
|
||||||
|
|
||||||
|
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
|
||||||
|
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
|
||||||
|
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
|
||||||
|
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
|
||||||
|
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
|
||||||
|
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
|
||||||
|
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
|
||||||
|
- [Bubblewrap README](https://github.com/containers/bubblewrap)
|
||||||
|
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
|
||||||
|
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
|
||||||
|
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
|
||||||
|
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
|
||||||
|
|
||||||
|
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
|
||||||
|
|
||||||
|
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
|
||||||
|
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
|
||||||
|
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What this doc is NOT
|
||||||
|
|
||||||
|
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
|
||||||
|
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
|
||||||
|
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
|
||||||
|
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
# TUI-mode (OCP-first) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse` → `completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
|
||||||
|
|
||||||
|
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1–S6, T1–T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why OCP-first / scope decisions (read before coding)
|
||||||
|
|
||||||
|
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
|
||||||
|
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
|
||||||
|
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
|
||||||
|
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
|
||||||
|
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
|
||||||
|
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
|
||||||
|
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | New/Modified |
|
||||||
|
|------|----------------|--------------|
|
||||||
|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
|
||||||
|
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
|
||||||
|
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
|
||||||
|
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018`–`:1023` helpers, `:1467` dispatch, boot block) |
|
||||||
|
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
|
||||||
|
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
|
||||||
|
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
|
||||||
|
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
|
||||||
|
|
||||||
|
### Task 0: Harvest real fixtures from PI231
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
|
||||||
|
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
|
||||||
|
|
||||||
|
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
|
||||||
|
|
||||||
|
Run (on PI231):
|
||||||
|
```bash
|
||||||
|
SID=$(uuidgen)
|
||||||
|
mkdir -p ~/.ocp-tui/work
|
||||||
|
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
|
||||||
|
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
|
||||||
|
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
|
||||||
|
```
|
||||||
|
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
|
||||||
|
|
||||||
|
Run (from the workstation):
|
||||||
|
```bash
|
||||||
|
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
|
||||||
|
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
|
||||||
|
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
|
||||||
|
# material before committing:
|
||||||
|
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
|
||||||
|
```
|
||||||
|
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the fixtures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
|
||||||
|
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs` (new Suite "TUI transcript")
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `test-features.mjs`:
|
||||||
|
```js
|
||||||
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
|
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
|
||||||
|
|
||||||
|
test("encodeCwd replaces every slash incl. leading", () => {
|
||||||
|
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
|
||||||
|
});
|
||||||
|
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||||
|
assertEqual(
|
||||||
|
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||||
|
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
|
||||||
|
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/transcript.mjs`:
|
||||||
|
```js
|
||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Project-dir encoding: every "/" -> "-" (including the leading slash).
|
||||||
|
export function encodeCwd(cwd) {
|
||||||
|
return cwd.replace(/\//g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPath(home, cwd, sessionId) {
|
||||||
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
|
||||||
|
Expected: PASS for both cases.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||||
|
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||||
|
assertEqual(evs.length, 2);
|
||||||
|
assertEqual(evs[1].b, 2);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on turn_duration", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||||
|
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on ordinary assistant/text lines", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||||
|
});
|
||||||
|
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
|
{ type: "user", message: { content: "..." } },
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
|
||||||
|
];
|
||||||
|
assertEqual(extractLatestAssistantText(evs), "AB");
|
||||||
|
});
|
||||||
|
test("real complete fixture yields non-empty text and is terminal", () => {
|
||||||
|
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||||
|
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
|
||||||
|
Expected: FAIL — exports not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when it is the turn_duration system
|
||||||
|
// event, or an assistant message that stopped to hand off to a tool.
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||||
|
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||||
|
return sr === "tool_use";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, [
|
||||||
|
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||||
|
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||||
|
assertEqual(out, "hello world");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
|
||||||
|
assertEqual(out, "partial");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/missing.jsonl`; // file never appears
|
||||||
|
let threw = false;
|
||||||
|
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||||
|
catch { threw = true; }
|
||||||
|
assert(threw, "must throw on empty timeout");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: FAIL — export not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||||
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||||
|
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||||
|
// partial text; on cap with no text at all, throws.
|
||||||
|
//
|
||||||
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||||
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
|
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(p, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
if (events.some(isTerminalLine)) return lastText;
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
if (lastText) return lastText;
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: all 3 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
|
||||||
|
|
||||||
|
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
|
const killed = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assertEqual(SESSION_PREFIX, "ocp-tui-");
|
||||||
|
assertEqual(n, 2);
|
||||||
|
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: FAIL — module/export missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/session.mjs`:
|
||||||
|
```js
|
||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
|
||||||
|
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
let killed = 0;
|
||||||
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
|
||||||
|
if (process.env.OCP_TUI_LIVE === "1") {
|
||||||
|
test("runTuiTurn drives a real interactive turn and returns text", async () => {
|
||||||
|
const { runTuiTurn } = await import("./lib/tui/session.mjs");
|
||||||
|
const out = await runTuiTurn({
|
||||||
|
prompt: "Reply with exactly the word PONG and nothing else.",
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
|
||||||
|
home: process.env.HOME,
|
||||||
|
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||||
|
wallclockMs: 120000,
|
||||||
|
});
|
||||||
|
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: FAIL — `runTuiTurn` not exported yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
|
||||||
|
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
|
||||||
|
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||||
|
|
||||||
|
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||||
|
return [
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--disallowedTools", shq("mcp__*"),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt, model, claudeBin, home, cwd,
|
||||||
|
wallclockMs = 120000, tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||||
|
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
if (home) env.HOME = home;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
|
||||||
|
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId)], { env });
|
||||||
|
await sleep(BOOT_MS);
|
||||||
|
|
||||||
|
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
|
||||||
|
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
|
||||||
|
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "1"]);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
await sleep(DIALOG_MS);
|
||||||
|
|
||||||
|
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
|
||||||
|
// touches the command line (no shell injection from prompt text), then a
|
||||||
|
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
|
||||||
|
// does not submit; the Enter key event does).
|
||||||
|
spawnSync("sh", ["-c",
|
||||||
|
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||||
|
{ env, encoding: "utf8" });
|
||||||
|
await sleep(PASTE_SETTLE_MS);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 4. Read the answer from the native transcript.
|
||||||
|
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
|
||||||
|
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** (PI231, live)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"` → `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-3 — Wiring into `server.mjs`
|
||||||
|
|
||||||
|
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
|
||||||
|
|
||||||
|
### Task 6: env consts + `streamStringAsSSE` DRY refactor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524`–`:1539` into a helper near `:1023`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258`–`:275`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
|
||||||
|
// Authority: docs/adr/0007-tui-interactive-mode.md.
|
||||||
|
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||||
|
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
||||||
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||||
|
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
|
||||||
|
function streamStringAsSSE(res, id, model, content) {
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||||
|
const CHUNK = 80;
|
||||||
|
const codepoints = Array.from(content);
|
||||||
|
for (let i = 0; i < codepoints.length; i += CHUNK) {
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||||
|
}
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524`–`:1539`) at the helper** (DRY — behavior identical)
|
||||||
|
|
||||||
|
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, cached.response);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full suite to verify no regression**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: `callClaudeTui` + dispatch gates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// TUI-mode upstream: drive an interactive claude session, return the assistant
|
||||||
|
// text as a string — same contract as callClaude(), so all downstream
|
||||||
|
// (singleflight, cache write-back, completionResponse) is unchanged.
|
||||||
|
// System messages are rendered inline as [System] blocks by messagesToPrompt;
|
||||||
|
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
|
||||||
|
// flag that could perturb cc_entrypoint classification.
|
||||||
|
function callClaudeTui(model, messages, conversationId, keyName) {
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
|
recordModelRequest(cliModel, prompt.length);
|
||||||
|
return runTuiTurn({
|
||||||
|
prompt, model: cliModel, claudeBin: CLAUDE,
|
||||||
|
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
|
}).then((text) => {
|
||||||
|
recordModelSuccess(cliModel, 0);
|
||||||
|
return text;
|
||||||
|
}).catch((err) => {
|
||||||
|
recordModelError(cliModel, false);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563`–`:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
|
||||||
|
|
||||||
|
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
|
||||||
|
```js
|
||||||
|
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||||
|
```
|
||||||
|
Then change `await callClaude(model, messages, conversationId, req._authKeyName)` → `await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551`–`:1553`, branch TUI streaming to buffer-then-replay:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
if (TUI_MODE) {
|
||||||
|
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
|
||||||
|
const t0Usage = Date.now();
|
||||||
|
try {
|
||||||
|
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||||
|
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||||
|
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
|
}
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, content);
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
|
||||||
|
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||||
|
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default: real stream-json streaming, unchanged.
|
||||||
|
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
|
||||||
|
|
||||||
|
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
|
||||||
|
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
|
||||||
|
|
||||||
|
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
|
||||||
|
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
|
||||||
|
```
|
||||||
|
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
|
||||||
|
- Create: `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
- Modify: `README.md`, `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (TUI_MODE) {
|
||||||
|
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
|
||||||
|
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write ADR 0007** — `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
|
||||||
|
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format` ⇒ `sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
|
||||||
|
|
||||||
|
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
|
||||||
|
|
||||||
|
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
|
||||||
|
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration & canary (post-implementation, on PI231)
|
||||||
|
|
||||||
|
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
|
||||||
|
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
|
||||||
|
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
|
||||||
|
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
|
||||||
|
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (against spec + the OCP-first execution review)
|
||||||
|
|
||||||
|
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 6–7), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
|
||||||
|
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
|
||||||
|
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
|
||||||
|
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 4–5 match imports in Task 6–8; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 6–7; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
|
||||||
|
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { randomBytes, createHash } from "node:crypto";
|
import { randomBytes, createHash } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync, chmodSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
const OCP_DIR = join(homedir(), ".ocp");
|
const OCP_DIR = join(homedir(), ".ocp");
|
||||||
mkdirSync(OCP_DIR, { recursive: true });
|
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Tighten the directory mode in case it already existed with broader permissions.
|
||||||
|
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
@@ -18,6 +20,8 @@ export function getDb() {
|
|||||||
db.exec("PRAGMA journal_mode = WAL");
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
initSchema();
|
initSchema();
|
||||||
|
// Tighten mode on the DB file (0600) after creation / first open.
|
||||||
|
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
@@ -371,6 +375,36 @@ export function getCacheStats() {
|
|||||||
return { entries: total, totalHits, sizeBytes };
|
return { entries: total, totalHits, sizeBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Singleflight stampede protection ──
|
||||||
|
|
||||||
|
// In-memory singleflight Map: hash → { promise, requesters }
|
||||||
|
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
|
||||||
|
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||||
|
const inflightMap = new Map();
|
||||||
|
|
||||||
|
export function singleflight(hash, fn) {
|
||||||
|
const existing = inflightMap.get(hash);
|
||||||
|
if (existing) {
|
||||||
|
existing.requesters++;
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||||
|
const promise = Promise.resolve().then(fn).finally(() => {
|
||||||
|
inflightMap.delete(hash);
|
||||||
|
});
|
||||||
|
inflightMap.set(hash, { promise, requesters: 1 });
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInflightStats() {
|
||||||
|
let totalRequesters = 0;
|
||||||
|
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
|
||||||
|
return {
|
||||||
|
inflight: inflightMap.size,
|
||||||
|
requesters: totalRequesters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Find a key by id or name (returns { id, name } or null)
|
// Find a key by id or name (returns { id, name } or null)
|
||||||
export function findKey(idOrName) {
|
export function findKey(idOrName) {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* OCP shared constants — single source of truth.
|
||||||
|
*
|
||||||
|
* Any literal that appears in more than one place across server.mjs, setup.mjs,
|
||||||
|
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
|
||||||
|
*
|
||||||
|
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||||
|
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
|
||||||
|
* cascaded into every downstream config write, ultimately taking out the
|
||||||
|
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
|
||||||
|
*
|
||||||
|
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
|
||||||
|
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
|
||||||
|
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
|
||||||
|
* at the shell-script reference; CI grep prevents drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
|
||||||
|
// at runtime; this is the fallback when env is unset.
|
||||||
|
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
|
||||||
|
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
|
||||||
|
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
|
||||||
|
export const DEFAULT_PORT = 3456;
|
||||||
|
|
||||||
|
// Localhost bind for client-side fetches (curl, health checks).
|
||||||
|
export const LOCAL_HOST = "127.0.0.1";
|
||||||
|
|
||||||
|
// OpenAI-compatible API base path appended to the proxy URL.
|
||||||
|
export const OPENAI_API_BASE = "/v1";
|
||||||
|
|
||||||
|
// Convenience: full local URL the OCP proxy listens on by default.
|
||||||
|
// scripts that want to probe locally can use this directly.
|
||||||
|
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,247 @@
|
|||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
//
|
||||||
|
// Trust handling: rather than answer the trust-folder dialog interactively (which
|
||||||
|
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
|
||||||
|
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
|
||||||
|
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const defaultTmux = (args, opts = {}) =>
|
||||||
|
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
let killed = 0;
|
||||||
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
if (name.startsWith(SESSION_PREFIX)) {
|
||||||
|
tmux(["kill-session", "-t", name]);
|
||||||
|
killed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
||||||
|
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Single-quote escaper for sh -c arguments.
|
||||||
|
function shq(s) {
|
||||||
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
|
||||||
|
// the trust-folder dialog never appears. Verified-live trust shape:
|
||||||
|
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
|
||||||
|
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
|
||||||
|
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
|
||||||
|
// Must run BEFORE the session boots so claude reads the trusted record at startup.
|
||||||
|
export function ensureTuiCwdTrusted(home, cwd) {
|
||||||
|
if (!home || !cwd) return;
|
||||||
|
const path = `${home}/.claude.json`;
|
||||||
|
let j, mode;
|
||||||
|
try {
|
||||||
|
j = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
mode = statSync(path).mode & 0o777;
|
||||||
|
} catch { return; }
|
||||||
|
j.projects = j.projects || {};
|
||||||
|
const entry = j.projects[cwd] || {};
|
||||||
|
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
|
||||||
|
entry.hasTrustDialogAccepted = true;
|
||||||
|
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
|
||||||
|
j.projects[cwd] = entry;
|
||||||
|
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
|
||||||
|
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
|
||||||
|
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
|
||||||
|
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
|
||||||
|
// onboarding completion is an A-path precondition (the host already runs claude).
|
||||||
|
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
|
||||||
|
// a file we fully own, removing the real-config-mutation concern entirely.
|
||||||
|
try {
|
||||||
|
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
|
||||||
|
renameSync(tmp, path);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the HOME claude runs under. Two modes:
|
||||||
|
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||||
|
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
||||||
|
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
||||||
|
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
||||||
|
// the user's project history; trusts only the scratch cwd) and its own
|
||||||
|
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
||||||
|
//
|
||||||
|
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
||||||
|
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
||||||
|
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
||||||
|
// refresh in the scratch home can invalidate the token the user's real-home claude
|
||||||
|
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
||||||
|
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
||||||
|
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
||||||
|
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
||||||
|
// corrupts. Run BEFORE the session boots.
|
||||||
|
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
||||||
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||||
|
try {
|
||||||
|
const claudeDir = `${tuiHome}/.claude`;
|
||||||
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||||
|
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
||||||
|
const link = `${claudeDir}/.credentials.json`;
|
||||||
|
if (!existsSync(link)) {
|
||||||
|
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
||||||
|
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
||||||
|
const seedPath = `${tuiHome}/.claude.json`;
|
||||||
|
if (!existsSync(seedPath)) {
|
||||||
|
let base = {};
|
||||||
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||||
|
base.hasCompletedOnboarding = true;
|
||||||
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||||
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
|
||||||
|
ensureTuiCwdTrusted(tuiHome, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Billing-classifier labeling ─────────────────────────────────────────
|
||||||
|
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
||||||
|
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
||||||
|
// leak into / mislabel the billing header). Then:
|
||||||
|
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
||||||
|
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
||||||
|
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
||||||
|
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
||||||
|
// observe/diagnose the real TTY-derived value.
|
||||||
|
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
||||||
|
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||||
|
if (mode === "off") return env;
|
||||||
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||||
|
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||||
|
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||||
|
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||||
|
return [
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--disallowedTools", shq("mcp__*"),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full per-request TUI lifecycle:
|
||||||
|
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||||
|
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||||
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
|
||||||
|
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
|
||||||
|
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
|
||||||
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||||
|
// marker or wall-clock cap.
|
||||||
|
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
claudeBin,
|
||||||
|
home,
|
||||||
|
realHome,
|
||||||
|
cwd,
|
||||||
|
wallclockMs = 120000,
|
||||||
|
entrypointMode = "cli",
|
||||||
|
tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||||
|
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||||
|
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||||
|
|
||||||
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||||
|
// cwd — before claude boots.
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
prepareTuiHome(rhome, ehome, cwd);
|
||||||
|
|
||||||
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
||||||
|
// env vars that might interfere with interactive-mode classification.
|
||||||
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||||
|
delete env.CLAUDECODE;
|
||||||
|
delete env.ANTHROPIC_API_KEY;
|
||||||
|
delete env.ANTHROPIC_BASE_URL;
|
||||||
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
||||||
|
resolveTuiEntrypointEnv(env, entrypointMode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||||
|
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
||||||
|
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
||||||
|
// into a non-existent session or issue a billing request without a verified
|
||||||
|
// interactive context. The finally teardown is still harmless (kill-session
|
||||||
|
// is a no-op when the session never existed).
|
||||||
|
const spawnResult = tmux(
|
||||||
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId)],
|
||||||
|
{ env },
|
||||||
|
);
|
||||||
|
if (!spawnResult || spawnResult.status !== 0) {
|
||||||
|
throw new Error("tui_spawn_failed: tmux session not created");
|
||||||
|
}
|
||||||
|
await sleep(BOOT_MS);
|
||||||
|
|
||||||
|
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
||||||
|
// then settle, then send a SEPARATE Enter key event to submit the line.
|
||||||
|
//
|
||||||
|
// The `-l` (literal) flag is required on the paste send-keys call so that
|
||||||
|
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
||||||
|
// is typed literally as text rather than being interpreted as a key binding.
|
||||||
|
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
||||||
|
// real keypress (carriage return) to submit the prompt line.
|
||||||
|
spawnSync(
|
||||||
|
"sh",
|
||||||
|
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
||||||
|
{ env, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
await sleep(PASTE_SETTLE_MS);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
||||||
|
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 4. Teardown — always, even on throw.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
||||||
|
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
||||||
|
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
||||||
|
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
||||||
|
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
||||||
|
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
||||||
|
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
||||||
|
export function encodeCwd(cwd) {
|
||||||
|
return cwd.replace(/[/.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPath(home, cwd, sessionId) {
|
||||||
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
||||||
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
||||||
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
||||||
|
export function findTranscriptPath(home, sessionId) {
|
||||||
|
if (!home || !sessionId) return null;
|
||||||
|
const root = `${home}/.claude/projects`;
|
||||||
|
let dirs;
|
||||||
|
try { dirs = readdirSync(root); } catch { return null; }
|
||||||
|
for (const d of dirs) {
|
||||||
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when it is the turn_duration system
|
||||||
|
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
||||||
|
//
|
||||||
|
// Why tool_use is NOT a terminal marker:
|
||||||
|
// In interactive claude, when the model decides to call a tool (stop_reason=
|
||||||
|
// "tool_use"), claude handles the tool call internally and then continues
|
||||||
|
// generating — the turn is NOT complete. The transcript advances to another
|
||||||
|
// assistant entry after the tool result. Only {type:"system",
|
||||||
|
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
||||||
|
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
return obj.type === "system" && obj.subtype === "turn_duration";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
||||||
|
//
|
||||||
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
||||||
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
||||||
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
||||||
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
||||||
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
||||||
|
// author must add user-line scoping here. See spec §7.2.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
||||||
|
// or null if absent. Lets callers assert the subscription-classified path.
|
||||||
|
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
||||||
|
export function verifyEntrypoint(events) {
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
||||||
|
return ev.entrypoint != null ? ev.entrypoint : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the session transcript is terminal (turn_duration) or
|
||||||
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||||
|
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||||
|
// partial text; on cap with no text at all, throws.
|
||||||
|
//
|
||||||
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||||
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
|
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
||||||
|
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
||||||
|
// file does not exist until the turn starts, so resolution happens inside the loop.
|
||||||
|
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const resolved = p || findTranscriptPath(home, sessionId);
|
||||||
|
if (resolved && existsSync(resolved)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
if (events.some(isTerminalLine)) return lastText;
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
if (lastText) return lastText;
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "claude-opus-4-8",
|
||||||
|
"displayName": "Claude Opus 4.8",
|
||||||
|
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||||
|
"reasoning": true,
|
||||||
|
"contextWindow": 200000,
|
||||||
|
"maxTokens": 16384
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-7",
|
"id": "claude-opus-4-7",
|
||||||
"displayName": "Claude Opus 4.7",
|
"displayName": "Claude Opus 4.7",
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-7",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,21 +8,18 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROXY="http://127.0.0.1:3456"
|
PROXY="http://127.0.0.1:3456"
|
||||||
|
|
||||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||||
_AUTH_HEADER=""
|
# Using a bash array preserves word boundaries — no eval needed.
|
||||||
|
_AUTH_ARGS=()
|
||||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
||||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wrapper: curl with optional auth
|
# Wrapper: curl with optional auth
|
||||||
_curl() {
|
_curl() {
|
||||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
curl "${_AUTH_ARGS[@]}" "$@"
|
||||||
eval curl "$_AUTH_HEADER" "$@"
|
|
||||||
else
|
|
||||||
curl "$@"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||||
@@ -695,25 +692,35 @@ for e in d.get('errors', []):
|
|||||||
# ── update ──────────────────────────────────────────────────────────────
|
# ── update ──────────────────────────────────────────────────────────────
|
||||||
cmd_update_help() {
|
cmd_update_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
ocp update — Update OCP to the latest version
|
ocp update — Smart upgrade dispatcher
|
||||||
|
|
||||||
Pulls the latest code from GitHub, restarts the proxy service,
|
Runs `ocp doctor` internally to choose the right path:
|
||||||
and optionally syncs the plugin to the OpenClaw extensions directory.
|
• Patch bump (same minor): light path (git pull + npm install + restart)
|
||||||
|
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
|
||||||
|
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ocp update Pull latest and restart
|
ocp update Smart auto-pick path
|
||||||
ocp update --check Check for updates without applying
|
ocp update --check Show available updates, don't apply
|
||||||
|
ocp update --dry-run Preview the plan, don't mutate
|
||||||
|
ocp update --target v3.13.0 Pin a specific version
|
||||||
|
ocp update --yes Skip y/N prompts (AI agents pass this)
|
||||||
|
ocp update --rollback Restore the most recent upgrade snapshot
|
||||||
|
ocp update --rollback --list List available snapshots
|
||||||
|
ocp update --rollback <path> Restore a specific snapshot
|
||||||
|
ocp update --rollback --dry-run Preview rollback plan
|
||||||
|
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
||||||
|
ocp update --rollback --gc --dry-run Preview what would be deleted
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
local script_dir self
|
local script_dir self
|
||||||
self="${BASH_SOURCE[0]}"
|
self="${BASH_SOURCE[0]}"
|
||||||
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
|
||||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||||
|
|
||||||
# Check-only mode
|
# Pass through --check fast path (existing behaviour)
|
||||||
if [[ "${1:-}" == "--check" ]]; then
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
git fetch origin main --quiet 2>/dev/null || true
|
git fetch origin main --quiet 2>/dev/null || true
|
||||||
@@ -730,71 +737,104 @@ cmd_update() {
|
|||||||
echo " Status: ✓ Up to date"
|
echo " Status: ✓ Up to date"
|
||||||
else
|
else
|
||||||
echo " Status: $behind commit(s) behind"
|
echo " Status: $behind commit(s) behind"
|
||||||
echo ""
|
|
||||||
echo " Run 'ocp update' to apply."
|
echo " Run 'ocp update' to apply."
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updating OCP..."
|
# Rollback path
|
||||||
echo ""
|
if [[ "${1:-}" == "--rollback" ]]; then
|
||||||
|
shift
|
||||||
|
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Pull latest
|
# Doctor-driven path selection
|
||||||
|
local kind
|
||||||
|
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
case "$kind" in
|
||||||
|
noop)
|
||||||
|
echo "Already at latest. Nothing to do."
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
_cmd_update_light "$script_dir"
|
||||||
|
;;
|
||||||
|
upgrade|fresh_install)
|
||||||
|
exec node "$script_dir/scripts/upgrade.mjs" "$@"
|
||||||
|
;;
|
||||||
|
fix_oauth|fix_service)
|
||||||
|
echo "Pre-upgrade check failed: $kind"
|
||||||
|
echo "Run \`ocp doctor\` for details and ai_executable steps."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
|
||||||
|
_cmd_update_light() {
|
||||||
|
local script_dir="$1"
|
||||||
|
echo "Updating OCP (light path)..."
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
local old_ver
|
local old_ver new_ver
|
||||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
echo " Pulling latest from GitHub..."
|
echo " Pulling latest from GitHub..."
|
||||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local new_ver
|
|
||||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||||
echo " ✓ Already at latest (v$new_ver)"
|
echo " ✓ Already at latest (v$new_ver)"
|
||||||
else
|
else
|
||||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Sync plugin to extensions dir
|
# Sync plugin (existing logic preserved)
|
||||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||||
echo ""
|
|
||||||
echo " Syncing OCP plugin..."
|
echo " Syncing OCP plugin..."
|
||||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||||
echo " ✓ Plugin synced to $ext_dir"
|
echo " ✓ Plugin synced"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Sync OpenClaw registry from models.json (non-fatal)
|
|
||||||
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
||||||
echo ""
|
|
||||||
echo " Syncing OpenClaw registry..."
|
echo " Syncing OpenClaw registry..."
|
||||||
if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then
|
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
|
||||||
echo " ⚠ OpenClaw sync failed (non-fatal, continuing)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Restart proxy
|
|
||||||
echo ""
|
|
||||||
echo " Restarting proxy..."
|
echo " Restarting proxy..."
|
||||||
cmd_restart > /dev/null 2>&1
|
cmd_restart > /dev/null 2>&1
|
||||||
sleep 2
|
}
|
||||||
|
|
||||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
cmd_doctor_help() {
|
||||||
local running_ver
|
cat <<'EOF'
|
||||||
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
ocp doctor — Health & upgrade-readiness check
|
||||||
echo " ✓ Proxy running (v$running_ver)"
|
|
||||||
else
|
|
||||||
echo " ⚠ Proxy not responding — check: ocp health"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
Runs a series of checks (Node version, git state, service health,
|
||||||
echo "Done."
|
OAuth token, plist customisation, OpenClaw provider) and emits either
|
||||||
|
human-readable PASS/WARN/FAIL output or a JSON next_action that
|
||||||
|
AI agents can execute.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ocp doctor Human-readable output
|
||||||
|
ocp doctor --json JSON for AI agents and ocp update internal use
|
||||||
|
ocp doctor --check oauth Fast path: OAuth check only
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_doctor() {
|
||||||
|
local script_dir self
|
||||||
|
self="${BASH_SOURCE[0]}"
|
||||||
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||||
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||||
|
exec node "$script_dir/scripts/doctor.mjs" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── help ─────────────────────────────────────────────────────────────────
|
# ── help ─────────────────────────────────────────────────────────────────
|
||||||
@@ -862,6 +902,7 @@ case "$subcmd" in
|
|||||||
lan) cmd_lan ;;
|
lan) cmd_lan ;;
|
||||||
connect) cmd_connect "$@" ;;
|
connect) cmd_connect "$@" ;;
|
||||||
restart) cmd_restart "${1:-}" ;;
|
restart) cmd_restart "${1:-}" ;;
|
||||||
update) cmd_update "${1:-}" ;;
|
doctor) cmd_doctor "$@" ;;
|
||||||
|
update) cmd_update "$@" ;;
|
||||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+12
-2
@@ -582,11 +582,19 @@ print(k if k else '')
|
|||||||
if [[ "${SHELL:-}" == */fish ]]; then
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
rc_files+=("$HOME/.bashrc")
|
rc_files+=("$HOME/.bashrc")
|
||||||
|
elif $is_mac; then
|
||||||
|
# macOS: default shell since Catalina (2019) is zsh.
|
||||||
|
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
||||||
|
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
||||||
|
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
||||||
|
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
||||||
|
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
||||||
|
rc_files+=("$HOME/.zshrc")
|
||||||
else
|
else
|
||||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
# Linux / other: write to whichever rc files already exist or match current shell
|
||||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||||
# If neither exists, create for current shell
|
# If neither exists, fall back to creating one for the current shell
|
||||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -705,7 +713,9 @@ PYEOF
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Done. Reload your shell to apply:"
|
echo " Done. Reload your shell to apply:"
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
echo " source $rc_file"
|
echo " source $rc_file"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+13
-3
@@ -1,9 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
||||||
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
|
* Calls the local claude-proxy and formats the response.
|
||||||
|
*
|
||||||
|
* Port resolution (in priority order):
|
||||||
|
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
|
||||||
|
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
|
||||||
|
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
|
||||||
|
*
|
||||||
|
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
|
||||||
|
* the OpenClaw launchd plist for that host must also inject the same
|
||||||
|
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
|
||||||
|
* 3456 and miss the server.
|
||||||
*/
|
*/
|
||||||
|
const PROXY = process.env.OCP_PROXY_URL
|
||||||
const PROXY = "http://127.0.0.1:3456";
|
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
|
||||||
|
|
||||||
// Wrap output in monospace code block for Telegram/Discord alignment
|
// Wrap output in monospace code block for Telegram/Discord alignment
|
||||||
function mono(text) { return "```\n" + text + "\n```"; }
|
function mono(text) { return "```\n" + text + "\n```"; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ocp",
|
"id": "ocp",
|
||||||
"name": "OCP Commands",
|
"name": "OCP Commands",
|
||||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||||
"version": "3.12.0",
|
"version": "3.16.2",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"proxyUrl": {
|
"proxyUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "http://127.0.0.1:3456",
|
"default": "http://127.0.0.1:3456",
|
||||||
"description": "URL of the Claude proxy"
|
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.12.0",
|
"version": "3.17.1",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"setup": "node setup.mjs",
|
"setup": "node setup.mjs",
|
||||||
"test": "node test-features.mjs",
|
"test": "node test-features.mjs"
|
||||||
"traffic": "node scripts/github-traffic.mjs"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openclaw",
|
"openclaw",
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/doctor.mjs — OCP health & upgrade-readiness check.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ocp doctor human-readable PASS/WARN/FAIL
|
||||||
|
* ocp doctor --json machine-readable JSON for AI agents + ocp update
|
||||||
|
* ocp doctor --check oauth fast path: only OAuth check
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 all PASS or WARN-only
|
||||||
|
* 1 any FAIL
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = "1";
|
||||||
|
|
||||||
|
function semverParts(v) {
|
||||||
|
const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function semverCompare(a, b) {
|
||||||
|
const A = semverParts(a), B = semverParts(b);
|
||||||
|
if (!A || !B) return 0;
|
||||||
|
if (A.major !== B.major) return A.major - B.major;
|
||||||
|
if (A.minor !== B.minor) return A.minor - B.minor;
|
||||||
|
return A.patch - B.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDoctor(opts = {}) {
|
||||||
|
const checks = [];
|
||||||
|
const push = (id, level, message, extra = {}) =>
|
||||||
|
checks.push({ id, level, message, ...extra });
|
||||||
|
|
||||||
|
// --- fast path: --check oauth ---
|
||||||
|
if (opts.checkOnly === "oauth") {
|
||||||
|
return runOauthOnly(opts, checks, push);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- version detection ---
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
let currentVersion = opts.mockVersion;
|
||||||
|
if (!currentVersion) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8"));
|
||||||
|
currentVersion = `v${pkg.version}`;
|
||||||
|
} catch {
|
||||||
|
currentVersion = "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
|
||||||
|
// Falls back to current_version when network/git unavailable, so kind = noop instead
|
||||||
|
// of recommending a downgrade against a stale hardcoded value.
|
||||||
|
let latestVersion = opts.mockLatest;
|
||||||
|
if (!latestVersion) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
const remotePkg = JSON.parse(out);
|
||||||
|
latestVersion = `v${remotePkg.version}`;
|
||||||
|
} catch {
|
||||||
|
latestVersion = currentVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push("current_version", "PASS", `current=${currentVersion}`);
|
||||||
|
|
||||||
|
// --- from-version supported? ---
|
||||||
|
const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0;
|
||||||
|
push("from_version_supported", fromSupported ? "PASS" : "FAIL",
|
||||||
|
fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`);
|
||||||
|
|
||||||
|
// --- service health check (mockable) ---
|
||||||
|
let healthOk = true, oauthOk = true;
|
||||||
|
if (!opts.skipNetwork) {
|
||||||
|
let health;
|
||||||
|
if (opts.mockHealth !== undefined) {
|
||||||
|
health = opts.mockHealth;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
|
} catch (e) {
|
||||||
|
health = { error: String(e.message || e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (health.error || health.status !== 200) {
|
||||||
|
healthOk = false;
|
||||||
|
push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||||
|
} else if (!health.body || typeof health.body !== "object") {
|
||||||
|
healthOk = false;
|
||||||
|
push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||||
|
} else {
|
||||||
|
push("service_running", "PASS", "service responding on /health");
|
||||||
|
const authOk = health.body?.auth?.ok;
|
||||||
|
if (!authOk) {
|
||||||
|
oauthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||||
|
} else {
|
||||||
|
push("oauth_ok", "PASS", "OAuth token valid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) ---
|
||||||
|
let kind;
|
||||||
|
if (!fromSupported) {
|
||||||
|
kind = "fresh_install";
|
||||||
|
} else if (!opts.skipNetwork && !healthOk) {
|
||||||
|
kind = "fix_service";
|
||||||
|
} else if (!opts.skipNetwork && !oauthOk) {
|
||||||
|
kind = "fix_oauth";
|
||||||
|
} else {
|
||||||
|
const cur = semverParts(currentVersion), lat = semverParts(latestVersion);
|
||||||
|
if (!cur) {
|
||||||
|
kind = "fresh_install";
|
||||||
|
} else if (semverCompare(currentVersion, latestVersion) === 0) {
|
||||||
|
kind = "noop";
|
||||||
|
} else if (lat && cur.major === lat.major && cur.minor === lat.minor) {
|
||||||
|
kind = "update";
|
||||||
|
} else {
|
||||||
|
kind = "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- next_action shape ---
|
||||||
|
let next_action;
|
||||||
|
if (kind === "fresh_install") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`,
|
||||||
|
`rm -rf ${ocpDir}`,
|
||||||
|
`git clone https://github.com/dtzp555-max/ocp ${ocpDir}`,
|
||||||
|
`cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects PASS on all checks"
|
||||||
|
};
|
||||||
|
} else if (kind === "noop") {
|
||||||
|
next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" };
|
||||||
|
} else if (kind === "fix_oauth") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects oauth_ok=PASS",
|
||||||
|
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||||
|
};
|
||||||
|
} else if (kind === "fix_service") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects service_running=PASS"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [`${ocpDir}/ocp update --yes`],
|
||||||
|
verify: "ocp doctor expects PASS on all checks"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||||
|
const warn_count = checks.filter(c => c.level === "WARN").length;
|
||||||
|
return {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ready_to_upgrade: fail_count === 0,
|
||||||
|
current_version: currentVersion,
|
||||||
|
latest_version: latestVersion,
|
||||||
|
from_version_supported: fromSupported,
|
||||||
|
fail_count,
|
||||||
|
warn_count,
|
||||||
|
checks,
|
||||||
|
next_action
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runOauthOnly(opts, checks, push) {
|
||||||
|
let healthOk = true, oauthOk = true;
|
||||||
|
let health;
|
||||||
|
if (opts.mockHealth !== undefined) {
|
||||||
|
health = opts.mockHealth;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
|
} catch (e) {
|
||||||
|
health = { error: String(e.message || e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.error || health.status !== 200) {
|
||||||
|
healthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||||
|
} else if (!health.body || typeof health.body !== "object") {
|
||||||
|
healthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||||
|
} else if (!health.body?.auth?.ok) {
|
||||||
|
oauthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||||
|
} else {
|
||||||
|
push("oauth_ok", "PASS", "OAuth token valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
|
||||||
|
|
||||||
|
let next_action;
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
if (kind === "noop") {
|
||||||
|
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
|
||||||
|
} else if (kind === "fix_oauth") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor --check oauth`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor --check oauth expects PASS",
|
||||||
|
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor --check oauth`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor --check oauth expects service_running=PASS"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||||
|
// "skipped" = --check oauth fast path intentionally omits version detection.
|
||||||
|
// AI agents should NOT semver-compare against current_version/latest_version when
|
||||||
|
// either equals "skipped"; the full path provides those fields when needed.
|
||||||
|
return {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ready_to_upgrade: fail_count === 0,
|
||||||
|
current_version: opts.mockVersion || "skipped",
|
||||||
|
latest_version: opts.mockLatest || "skipped",
|
||||||
|
from_version_supported: true,
|
||||||
|
fail_count,
|
||||||
|
warn_count: 0,
|
||||||
|
checks,
|
||||||
|
next_action
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
||||||
|
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
function _isMain() {
|
||||||
|
if (!process.argv[1]) return false;
|
||||||
|
try {
|
||||||
|
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
if (_isMain()) {
|
||||||
|
const wantJson = process.argv.includes("--json");
|
||||||
|
const checkIdx = process.argv.indexOf("--check");
|
||||||
|
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
||||||
|
const result = await runDoctor({ checkOnly });
|
||||||
|
if (wantJson) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`);
|
||||||
|
for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`);
|
||||||
|
console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`);
|
||||||
|
console.log(`Next action: ${result.next_action.kind}`);
|
||||||
|
}
|
||||||
|
process.exit(result.fail_count === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// github-traffic.mjs — fetch GitHub Traffic Insights for this repo.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs # pretty report
|
|
||||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --json # raw JSON
|
|
||||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --save # write snapshot file
|
|
||||||
//
|
|
||||||
// Options:
|
|
||||||
// --owner=<user> Override repo owner (default: dtzp555-max)
|
|
||||||
// --repo=<name> Override repo name (default: ocp)
|
|
||||||
// --json Print raw JSON instead of a formatted report
|
|
||||||
// --save[=path] Append snapshot as JSONL to path (default: ./traffic-history.jsonl)
|
|
||||||
//
|
|
||||||
// Requires a token with push access to the repository. Traffic endpoints
|
|
||||||
// return the last 14 days of data — run daily to build a longer history.
|
|
||||||
|
|
||||||
const API = "https://api.github.com";
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const args = { owner: "dtzp555-max", repo: "ocp", json: false, save: null };
|
|
||||||
for (const a of argv.slice(2)) {
|
|
||||||
if (a === "--json") args.json = true;
|
|
||||||
else if (a === "--save") args.save = "traffic-history.jsonl";
|
|
||||||
else if (a.startsWith("--save=")) args.save = a.slice(7);
|
|
||||||
else if (a.startsWith("--owner=")) args.owner = a.slice(8);
|
|
||||||
else if (a.startsWith("--repo=")) args.repo = a.slice(7);
|
|
||||||
else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
|
|
||||||
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp() {
|
|
||||||
console.log(`github-traffic.mjs — fetch GitHub Traffic Insights
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GITHUB_TOKEN=<token> node scripts/github-traffic.mjs [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--owner=<user> Repo owner (default: dtzp555-max)
|
|
||||||
--repo=<name> Repo name (default: ocp)
|
|
||||||
--json Print raw JSON (for piping to jq)
|
|
||||||
--save[=path] Append snapshot as JSONL (default: ./traffic-history.jsonl)
|
|
||||||
-h, --help Show this help
|
|
||||||
|
|
||||||
Requires GITHUB_TOKEN with push access to the repository.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gh(path, token) {
|
|
||||||
const res = await fetch(API + path, {
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
|
||||||
"User-Agent": "ocp-traffic-script",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text();
|
|
||||||
throw new Error(`GET ${path} → ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAll(owner, repo, token) {
|
|
||||||
const base = `/repos/${owner}/${repo}`;
|
|
||||||
const [repoInfo, views, clones, referrers, paths] = await Promise.all([
|
|
||||||
gh(base, token),
|
|
||||||
gh(`${base}/traffic/views`, token),
|
|
||||||
gh(`${base}/traffic/clones`, token),
|
|
||||||
gh(`${base}/traffic/popular/referrers`, token),
|
|
||||||
gh(`${base}/traffic/popular/paths`, token),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
repo: {
|
|
||||||
full_name: repoInfo.full_name,
|
|
||||||
stars: repoInfo.stargazers_count,
|
|
||||||
watchers: repoInfo.subscribers_count,
|
|
||||||
forks: repoInfo.forks_count,
|
|
||||||
open_issues: repoInfo.open_issues_count,
|
|
||||||
pushed_at: repoInfo.pushed_at,
|
|
||||||
},
|
|
||||||
views, clones, referrers, paths,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bar(value, max, width = 20) {
|
|
||||||
if (!max) return "";
|
|
||||||
const n = Math.round((value / max) * width);
|
|
||||||
return "█".repeat(n) + "░".repeat(width - n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReport(data) {
|
|
||||||
const { repo, views, clones, referrers, paths } = data;
|
|
||||||
const lines = [];
|
|
||||||
lines.push(`\n📊 GitHub Traffic — ${repo.full_name}`);
|
|
||||||
lines.push(` ⭐ ${repo.stars} 👁 ${repo.watchers ?? "?"} 🍴 ${repo.forks} 🐛 ${repo.open_issues} open issues`);
|
|
||||||
lines.push(` Last push: ${repo.pushed_at}`);
|
|
||||||
lines.push(` Fetched: ${data.fetched_at}\n`);
|
|
||||||
|
|
||||||
lines.push(`── Views (last 14 days) ────────────────────────────────────`);
|
|
||||||
lines.push(` Total: ${views.count} Unique: ${views.uniques}`);
|
|
||||||
const maxV = Math.max(1, ...views.views.map(v => v.count));
|
|
||||||
for (const v of views.views) {
|
|
||||||
const day = v.timestamp.slice(0, 10);
|
|
||||||
lines.push(` ${day} ${String(v.count).padStart(4)} (${String(v.uniques).padStart(3)} uniq) ${bar(v.count, maxV)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`\n── Clones (last 14 days) ───────────────────────────────────`);
|
|
||||||
lines.push(` Total: ${clones.count} Unique: ${clones.uniques}`);
|
|
||||||
if (clones.clones.length) {
|
|
||||||
const maxC = Math.max(1, ...clones.clones.map(c => c.count));
|
|
||||||
for (const c of clones.clones) {
|
|
||||||
const day = c.timestamp.slice(0, 10);
|
|
||||||
lines.push(` ${day} ${String(c.count).padStart(4)} (${String(c.uniques).padStart(3)} uniq) ${bar(c.count, maxC)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines.push(` (no clones recorded)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`\n── Top Referrers ───────────────────────────────────────────`);
|
|
||||||
if (referrers.length) {
|
|
||||||
const maxR = Math.max(1, ...referrers.map(r => r.count));
|
|
||||||
for (const r of referrers) {
|
|
||||||
lines.push(` ${r.referrer.padEnd(28)} ${String(r.count).padStart(5)} views (${r.uniques} uniq) ${bar(r.count, maxR, 15)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines.push(` (no referrer data)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`\n── Popular Content ─────────────────────────────────────────`);
|
|
||||||
if (paths.length) {
|
|
||||||
const maxP = Math.max(1, ...paths.map(p => p.count));
|
|
||||||
for (const p of paths) {
|
|
||||||
lines.push(` ${String(p.count).padStart(5)} views ${String(p.uniques).padStart(4)} uniq ${p.path}`);
|
|
||||||
lines.push(` ${bar(p.count, maxP, 40)} ${p.title.slice(0, 60)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines.push(` (no popular content data)`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = parseArgs(process.argv);
|
|
||||||
const token = process.env.GITHUB_TOKEN;
|
|
||||||
if (!token) {
|
|
||||||
console.error("Error: GITHUB_TOKEN environment variable is required.");
|
|
||||||
console.error("Create a token with 'repo' scope: https://github.com/settings/tokens");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await fetchAll(args.owner, args.repo, token);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to fetch traffic: ${err.message}`);
|
|
||||||
if (err.message.includes("403")) {
|
|
||||||
console.error("Note: traffic endpoints require push access to the repository.");
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.save) {
|
|
||||||
const { writeFileSync, appendFileSync, existsSync } = await import("node:fs");
|
|
||||||
const line = JSON.stringify(data) + "\n";
|
|
||||||
if (existsSync(args.save)) appendFileSync(args.save, line);
|
|
||||||
else writeFileSync(args.save, line);
|
|
||||||
console.error(`Snapshot appended to ${args.save}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.json) {
|
|
||||||
console.log(JSON.stringify(data, null, 2));
|
|
||||||
} else {
|
|
||||||
console.log(formatReport(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// scripts/lib/plist-merge.mjs
|
||||||
|
//
|
||||||
|
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
|
||||||
|
//
|
||||||
|
// Rule:
|
||||||
|
// - keys present in NEW template → template value wins (template is source of truth)
|
||||||
|
// - keys ONLY in EXISTING (not in template) → preserved verbatim
|
||||||
|
//
|
||||||
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
|
// is stable enough for our hand-written templates in setup.mjs.
|
||||||
|
|
||||||
|
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||||
|
|
||||||
|
export function parsePlistEnv(plistContent) {
|
||||||
|
if (!plistContent) return {};
|
||||||
|
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
|
||||||
|
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
|
||||||
|
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
||||||
|
if (!envBlock) return {};
|
||||||
|
const out = {};
|
||||||
|
let m;
|
||||||
|
PLIST_KV_RE.lastIndex = 0;
|
||||||
|
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
|
||||||
|
out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePlistEnv(existing, template) {
|
||||||
|
if (!existing) return template;
|
||||||
|
const existingEnv = parsePlistEnv(existing);
|
||||||
|
const templateEnv = parsePlistEnv(template);
|
||||||
|
const KNOWN = new Set(Object.keys(templateEnv));
|
||||||
|
|
||||||
|
const preserved = {};
|
||||||
|
for (const [k, v] of Object.entries(existingEnv)) {
|
||||||
|
if (!KNOWN.has(k)) preserved[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(preserved).length === 0) return template;
|
||||||
|
|
||||||
|
const lines = Object.entries(preserved)
|
||||||
|
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Inject before the closing </dict> of EnvironmentVariables
|
||||||
|
return template.replace(
|
||||||
|
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
|
||||||
|
`$1\n${lines}$2`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
|
||||||
|
|
||||||
|
export function parseSystemdEnv(serviceContent) {
|
||||||
|
if (!serviceContent) return {};
|
||||||
|
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
|
||||||
|
const out = {};
|
||||||
|
let m;
|
||||||
|
SYSTEMD_KV_RE.lastIndex = 0;
|
||||||
|
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
|
||||||
|
out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSystemdEnv(existing, template) {
|
||||||
|
if (!existing) return template;
|
||||||
|
const existingEnv = parseSystemdEnv(existing);
|
||||||
|
const templateEnv = parseSystemdEnv(template);
|
||||||
|
const KNOWN = new Set(Object.keys(templateEnv));
|
||||||
|
|
||||||
|
const preservedLines = Object.entries(existingEnv)
|
||||||
|
.filter(([k]) => !KNOWN.has(k))
|
||||||
|
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||||
|
if (preservedLines.length === 0) return template;
|
||||||
|
|
||||||
|
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
|
||||||
|
// (In practice the OCP systemd template always has Environment= lines.)
|
||||||
|
if (!/^Environment=/m.test(template)) return template;
|
||||||
|
|
||||||
|
// Inject after the last existing Environment= line in the template
|
||||||
|
return template.replace(
|
||||||
|
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
|
||||||
|
`$1${preservedLines.join("\n")}\n$2`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
|
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||||
|
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
|
||||||
|
// Standard manifest files
|
||||||
|
writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n");
|
||||||
|
writeFileSync(join(root, "from-version.txt"), fromVersion + "\n");
|
||||||
|
writeFileSync(join(root, "to-version.txt"), toVersion + "\n");
|
||||||
|
|
||||||
|
// Optional captures (best-effort, never fatal)
|
||||||
|
const tryCopy = (src, dst) => {
|
||||||
|
try {
|
||||||
|
if (existsSync(src)) copyFileSync(src, dst);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
|
||||||
|
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
|
||||||
|
tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak"));
|
||||||
|
tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key"));
|
||||||
|
tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json"));
|
||||||
|
|
||||||
|
for (const { src, name } of extraFiles) tryCopy(src, join(root, name));
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSnapshot(snapshotPath) {
|
||||||
|
const read = (n) => {
|
||||||
|
try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; }
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
path: snapshotPath,
|
||||||
|
fromCommit: read("from-commit.txt"),
|
||||||
|
fromVersion: read("from-version.txt"),
|
||||||
|
toVersion: read("to-version.txt")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSnapshots(homeDir) {
|
||||||
|
const root = join(homeDir, ".ocp");
|
||||||
|
if (!existsSync(root)) return [];
|
||||||
|
return readdirSync(root)
|
||||||
|
.filter(name => name.startsWith("upgrade-snapshot-"))
|
||||||
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garbage-collect old upgrade snapshots.
|
||||||
|
*
|
||||||
|
* Retention rule (a snapshot is KEPT if any of these is true):
|
||||||
|
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
|
||||||
|
* - Its timestamp is within `keepDays` of `now`
|
||||||
|
* - It is the single most-recent snapshot (always-keep safety net)
|
||||||
|
*
|
||||||
|
* @param {string} homeDir - Root containing ~/.ocp/
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} [opts.keepCount=5] - Minimum count to keep
|
||||||
|
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
|
||||||
|
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
|
||||||
|
* @param {Date} [opts.now=new Date()] - Override clock for testing
|
||||||
|
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
|
||||||
|
*/
|
||||||
|
export function gcSnapshots(homeDir, opts = {}) {
|
||||||
|
const keepCount = opts.keepCount ?? 5;
|
||||||
|
const keepDays = opts.keepDays ?? 30;
|
||||||
|
const dryRun = !!opts.dryRun;
|
||||||
|
const now = opts.now || new Date();
|
||||||
|
|
||||||
|
const all = listSnapshots(homeDir); // sorted oldest→newest
|
||||||
|
if (all.length === 0) return { kept: [], removed: [], dryRun };
|
||||||
|
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
|
||||||
|
|
||||||
|
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
||||||
|
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
|
||||||
|
|
||||||
|
const kept = [], removed = [];
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
const s = all[i];
|
||||||
|
const isMostRecent = i === all.length - 1;
|
||||||
|
const isInLastN = lastN.has(s.path);
|
||||||
|
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
|
||||||
|
if (isMostRecent || isInLastN || isWithinDays) {
|
||||||
|
kept.push(s);
|
||||||
|
} else {
|
||||||
|
removed.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
for (const s of removed) {
|
||||||
|
try {
|
||||||
|
rmSync(s.path, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kept, removed, dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshotTimestamp(name) {
|
||||||
|
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||||
|
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||||
|
if (!m) return 0;
|
||||||
|
const t = Date.parse(m[1]);
|
||||||
|
return Number.isFinite(t) ? t : 0;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = join(__dirname, "..");
|
const REPO_ROOT = join(__dirname, "..");
|
||||||
@@ -70,7 +71,7 @@ if (!config.models.providers) config.models.providers = {};
|
|||||||
if (!config.models.providers[PROVIDER_NAME]) {
|
if (!config.models.providers[PROVIDER_NAME]) {
|
||||||
// First-time registration
|
// First-time registration
|
||||||
config.models.providers[PROVIDER_NAME] = {
|
config.models.providers[PROVIDER_NAME] = {
|
||||||
baseUrl: "http://127.0.0.1:3456/v1",
|
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
authHeader: false,
|
authHeader: false,
|
||||||
models: desiredModels,
|
models: desiredModels,
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/upgrade.mjs — OCP unified upgrade dispatcher.
|
||||||
|
*
|
||||||
|
* Paths:
|
||||||
|
* noop current == latest, exit 0
|
||||||
|
* light same major.minor, patch bump only (existing fast path; delegated to bash)
|
||||||
|
* full cross-minor (snapshot + setup.mjs + post-flight)
|
||||||
|
* fresh_install from-version < v3.4.0 (--yes required for non-interactive)
|
||||||
|
* rollback restore from snapshot
|
||||||
|
*/
|
||||||
|
import { runDoctor } from "./doctor.mjs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync, copyFileSync } from "node:fs";
|
||||||
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
export async function runUpgrade(opts = {}) {
|
||||||
|
const dryRun = !!opts.dryRun;
|
||||||
|
const yes = !!opts.yes;
|
||||||
|
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
||||||
|
const plan = [];
|
||||||
|
|
||||||
|
// --- rollback path (no doctor needed; snapshot is authoritative) ---
|
||||||
|
if (opts.rollback) {
|
||||||
|
return await runRollback(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- doctor pre-flight ---
|
||||||
|
const doctor = opts.mockDoctor || await runDoctor();
|
||||||
|
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
||||||
|
throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = doctor.next_action.kind;
|
||||||
|
plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`);
|
||||||
|
|
||||||
|
// --- noop ---
|
||||||
|
if (kind === "noop") {
|
||||||
|
plan.push(`[noop] already at latest (${doctor.latest_version})`);
|
||||||
|
return { path: "noop", executed: true, changed: false, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dry-run early exit ---
|
||||||
|
if (dryRun) {
|
||||||
|
plan.push(`[plan] would proceed with ${kind} path`);
|
||||||
|
if (kind === "upgrade") {
|
||||||
|
plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-<ts>/`);
|
||||||
|
plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`);
|
||||||
|
plan.push(`[plan] phase 3: node setup.mjs`);
|
||||||
|
plan.push(`[plan] phase 4: launchctl bootout/bootstrap`);
|
||||||
|
plan.push(`[plan] phase 5: post-flight /health + /v1/models`);
|
||||||
|
} else if (kind === "update") {
|
||||||
|
plan.push(`[plan] light path: git pull + npm install + restart`);
|
||||||
|
} else if (kind === "fresh_install") {
|
||||||
|
plan.push(`[plan] fresh-install ai_executable[]:`);
|
||||||
|
for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`);
|
||||||
|
}
|
||||||
|
return { path: kind, executed: false, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- non-dry-run paths ---
|
||||||
|
if (kind === "update") {
|
||||||
|
return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "upgrade") {
|
||||||
|
return await runFullUpgrade({ doctor, opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "fresh_install") {
|
||||||
|
return await runFreshInstall({ doctor, opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`path ${kind} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFullUpgrade({ doctor, opts }) {
|
||||||
|
const phases = [];
|
||||||
|
let snapshotPath = null;
|
||||||
|
const exec = (cmd, label) => {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
phases.push({ name: label, cmd, status: "ok" });
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.stderr?.toString().trim();
|
||||||
|
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`phase ${label} failed: ${detail || err.message}`),
|
||||||
|
{ phases, cmd }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// phase 1: pre-flight (doctor already passed; just record)
|
||||||
|
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
|
||||||
|
|
||||||
|
// phase 2: snapshot
|
||||||
|
const fromCommit = opts.mockExec
|
||||||
|
? "mock-commit"
|
||||||
|
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
|
||||||
|
snapshotPath = opts.mockExec
|
||||||
|
? "/tmp/mock-snapshot"
|
||||||
|
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
|
||||||
|
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
|
||||||
|
|
||||||
|
// phase 3: fetch + install
|
||||||
|
exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install");
|
||||||
|
exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install");
|
||||||
|
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install");
|
||||||
|
|
||||||
|
// phase 4: reconfigure
|
||||||
|
exec(`node ${ocpDir}/setup.mjs`, "reconfigure");
|
||||||
|
|
||||||
|
// phase 5: restart (heads-up note printed before invoking)
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||||
|
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||||
|
} else {
|
||||||
|
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
let ok = false;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||||
|
const body = JSON.parse(out);
|
||||||
|
if (body.auth?.ok === true) { ok = true; break; }
|
||||||
|
} catch { /* retry */ }
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
||||||
|
throw new Error("post-flight failed");
|
||||||
|
}
|
||||||
|
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||||
|
phases.push({ name: "post-flight", status: "ok" });
|
||||||
|
} else {
|
||||||
|
phases.push({ name: "post-flight", status: "skipped-mock" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
|
||||||
|
try {
|
||||||
|
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
|
||||||
|
if (gc.removed.length > 0) {
|
||||||
|
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||||
|
} catch (err) {
|
||||||
|
if (snapshotPath && !err.snapshotPath) {
|
||||||
|
Object.assign(err, {
|
||||||
|
snapshotPath,
|
||||||
|
phases,
|
||||||
|
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFreshInstall({ doctor, opts }) {
|
||||||
|
if (!opts.yes) {
|
||||||
|
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
|
||||||
|
}
|
||||||
|
const steps = [];
|
||||||
|
for (const cmd of doctor.next_action.ai_executable) {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
steps.push({ cmd, status: "skipped-mock" });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
execSync(cmd, { stdio: "inherit" });
|
||||||
|
steps.push({ cmd, status: "ok" });
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e.stderr?.toString().trim() || e.message;
|
||||||
|
steps.push({ cmd, status: "fail", error: String(detail) });
|
||||||
|
throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { path: "fresh_install", executed: true, changed: true, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRollback(opts) {
|
||||||
|
const homeDir = opts.homeDir || homedir();
|
||||||
|
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
||||||
|
|
||||||
|
if (opts.gc) {
|
||||||
|
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
|
||||||
|
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
return { path: "rollback-list", snapshots };
|
||||||
|
}
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = opts.snapshotPath
|
||||||
|
? snapshots.find(s => s.path === opts.snapshotPath)
|
||||||
|
: snapshots[snapshots.length - 1];
|
||||||
|
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
|
||||||
|
|
||||||
|
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
|
||||||
|
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
|
||||||
|
|
||||||
|
const phases = [];
|
||||||
|
if (opts.dryRun) {
|
||||||
|
return {
|
||||||
|
path: "rollback-dry-run",
|
||||||
|
executed: false,
|
||||||
|
target: target.path,
|
||||||
|
plan: [
|
||||||
|
`git checkout ${meta.fromCommit}`,
|
||||||
|
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
|
||||||
|
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
|
||||||
|
`launchctl bootout/bootstrap`,
|
||||||
|
`ocp doctor`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
|
||||||
|
|
||||||
|
const exec = (cmd, label) => {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
phases.push({ name: label, cmd, status: "ok" });
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.stderr?.toString().trim();
|
||||||
|
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
|
||||||
|
{ phases, target: target.path }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
|
||||||
|
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
const tryCopy = (src, dst) => {
|
||||||
|
try {
|
||||||
|
if (existsSync(src)) copyFileSync(src, dst);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
|
||||||
|
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
|
||||||
|
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
|
||||||
|
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
|
||||||
|
phases.push({ name: "restore-files", status: "ok" });
|
||||||
|
} else {
|
||||||
|
phases.push({ name: "restore-files", status: "skipped-mock" });
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
|
||||||
|
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||||
|
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||||
|
} else {
|
||||||
|
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
function _isMain() {
|
||||||
|
if (!process.argv[1]) return false;
|
||||||
|
try {
|
||||||
|
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
if (_isMain()) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes("--dry-run");
|
||||||
|
const yes = args.includes("--yes");
|
||||||
|
const rollback = args.includes("--rollback");
|
||||||
|
const list = args.includes("--list");
|
||||||
|
const gc = args.includes("--gc");
|
||||||
|
const targetIdx = args.indexOf("--target");
|
||||||
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||||
|
// First non-flag positional after --rollback is the snapshot path
|
||||||
|
let snapshotPath;
|
||||||
|
if (rollback) {
|
||||||
|
const rb = args.indexOf("--rollback");
|
||||||
|
const cand = args[rb + 1];
|
||||||
|
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
|
||||||
|
if (result.plan) for (const line of result.plan) console.log(line);
|
||||||
|
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
||||||
|
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||||
|
if (result.snapshots) {
|
||||||
|
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||||
|
for (const s of result.snapshots) console.log(` ${s.name}`);
|
||||||
|
}
|
||||||
|
if (result.removed && result.kept) {
|
||||||
|
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
|
||||||
|
for (const s of result.removed) console.log(` - ${s.name}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`✗ ${e.message}`);
|
||||||
|
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
||||||
|
if (e.target) console.error(` target: ${e.target}`);
|
||||||
|
if (e.hint) console.error(` hint: ${e.hint}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+704
-132
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@
|
|||||||
* OCP (Open Claude Proxy) setup
|
* OCP (Open Claude Proxy) setup
|
||||||
*
|
*
|
||||||
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
||||||
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
|
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
|
||||||
|
* (default port = DEFAULT_PORT from lib/constants.mjs)
|
||||||
*
|
*
|
||||||
* What it does:
|
* What it does:
|
||||||
* 1. Verifies claude CLI is installed and authenticated
|
* 1. Verifies claude CLI is installed and authenticated
|
||||||
@@ -12,11 +13,13 @@
|
|||||||
* 4. Creates start.sh for easy launch
|
* 4. Creates start.sh for easy launch
|
||||||
* 5. Optionally starts the proxy
|
* 5. Optionally starts the proxy
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
||||||
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
@@ -31,7 +34,7 @@ const opt = (name, fallback) => {
|
|||||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PORT = parseInt(opt("port", "3456"), 10);
|
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
||||||
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||||
const DRY_RUN = flag("dry-run");
|
const DRY_RUN = flag("dry-run");
|
||||||
const SKIP_START = flag("no-start");
|
const SKIP_START = flag("no-start");
|
||||||
@@ -39,6 +42,29 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|||||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||||
|
|
||||||
|
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
||||||
|
// These are read from the user's shell env at install time and written into
|
||||||
|
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
||||||
|
|
||||||
|
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
||||||
|
let CLAUDE_BIN_INJECT = null;
|
||||||
|
if (process.env.CLAUDE_BIN) {
|
||||||
|
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
||||||
|
if (detected && existsSync(detected)) {
|
||||||
|
CLAUDE_BIN_INJECT = detected;
|
||||||
|
}
|
||||||
|
} catch { /* which not available or claude not on PATH — omit */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
||||||
|
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||||
|
|
||||||
|
// PROXY_ANONYMOUS_KEY — same pattern
|
||||||
|
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||||
|
|
||||||
// ── Models: derived from models.json (single source of truth) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -107,77 +133,82 @@ try {
|
|||||||
warn("Make sure you're logged in: claude login");
|
warn("Make sure you're logged in: claude login");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check openclaw config
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||||
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
|
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
||||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
if (OPENCLAW_PRESENT) {
|
||||||
|
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||||
|
} else {
|
||||||
|
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
||||||
|
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
||||||
|
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
||||||
console.log("\n📝 Configuring OpenClaw...\n");
|
if (OPENCLAW_PRESENT) {
|
||||||
|
console.log("\n📝 Configuring OpenClaw...\n");
|
||||||
|
|
||||||
const config = readJSON(CONFIG_PATH);
|
const config = readJSON(CONFIG_PATH);
|
||||||
|
|
||||||
// Ensure models.providers exists
|
// Ensure models.providers exists
|
||||||
if (!config.models) config.models = {};
|
if (!config.models) config.models = {};
|
||||||
if (!config.models.providers) config.models.providers = {};
|
if (!config.models.providers) config.models.providers = {};
|
||||||
|
|
||||||
// Add/update claude-local provider
|
// Add/update claude-local provider
|
||||||
config.models.providers[PROVIDER_NAME] = {
|
config.models.providers[PROVIDER_NAME] = {
|
||||||
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
authHeader: false,
|
authHeader: false,
|
||||||
models: MODELS,
|
models: MODELS,
|
||||||
};
|
};
|
||||||
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
||||||
|
|
||||||
// Ensure auth profile in config
|
// Ensure auth profile in config
|
||||||
if (!config.auth) config.auth = {};
|
if (!config.auth) config.auth = {};
|
||||||
if (!config.auth.profiles) config.auth.profiles = {};
|
if (!config.auth.profiles) config.auth.profiles = {};
|
||||||
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
||||||
provider: PROVIDER_NAME,
|
provider: PROVIDER_NAME,
|
||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
};
|
};
|
||||||
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
||||||
|
|
||||||
// Add models to agents.defaults.models
|
// Add models to agents.defaults.models
|
||||||
if (!config.agents) config.agents = {};
|
if (!config.agents) config.agents = {};
|
||||||
if (!config.agents.defaults) config.agents.defaults = {};
|
if (!config.agents.defaults) config.agents.defaults = {};
|
||||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||||
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||||
config.agents.defaults.models[key] = val;
|
config.agents.defaults.models[key] = val;
|
||||||
}
|
}
|
||||||
log(`Model aliases added to agents.defaults.models`);
|
log(`Model aliases added to agents.defaults.models`);
|
||||||
|
|
||||||
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
||||||
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
||||||
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
||||||
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
||||||
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
||||||
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
||||||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
||||||
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
||||||
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
||||||
} else {
|
} else {
|
||||||
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(CONFIG_PATH, config);
|
writeJSON(CONFIG_PATH, config);
|
||||||
log(`Config saved`);
|
log(`Config saved`);
|
||||||
|
|
||||||
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
||||||
console.log("\n🔑 Configuring auth profiles...\n");
|
console.log("\n🔑 Configuring auth profiles...\n");
|
||||||
|
|
||||||
// Find all agent auth-profiles.json files
|
// Find all agent auth-profiles.json files
|
||||||
const agentsDir = join(OPENCLAW_DIR, "agents");
|
const agentsDir = join(OPENCLAW_DIR, "agents");
|
||||||
const agentDirs = existsSync(agentsDir)
|
const agentDirs = existsSync(agentsDir)
|
||||||
? readdirSync(agentsDir).filter((d) => {
|
? readdirSync(agentsDir).filter((d) => {
|
||||||
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
||||||
return existsSync(ap);
|
return existsSync(ap);
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
import { readdirSync } from "node:fs";
|
for (const agentId of agentDirs) {
|
||||||
|
|
||||||
for (const agentId of agentDirs) {
|
|
||||||
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
||||||
try {
|
try {
|
||||||
const ap = readJSON(apPath);
|
const ap = readJSON(apPath);
|
||||||
@@ -203,10 +234,11 @@ for (const agentId of agentDirs) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`Skipped agent "${agentId}": ${e.message}`);
|
warn(`Skipped agent "${agentId}": ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentDirs.length === 0) {
|
if (agentDirs.length === 0) {
|
||||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
||||||
@@ -238,40 +270,71 @@ if (!DRY_RUN) {
|
|||||||
log(`Launcher: ${startPath}`);
|
log(`Launcher: ${startPath}`);
|
||||||
|
|
||||||
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
||||||
console.log(`
|
const banner = [
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
`╔══════════════════════════════════════════════════════════════╗`,
|
||||||
║ Setup complete! ║
|
`║ Setup complete! ║`,
|
||||||
╠══════════════════════════════════════════════════════════════╣
|
`╠══════════════════════════════════════════════════════════════╣`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Provider: ${PROVIDER_NAME.padEnd(44)}║
|
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
||||||
║ Port: ${String(PORT).padEnd(44)}║
|
`║ Port: ${String(PORT).padEnd(44)}║`,
|
||||||
║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║
|
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
||||||
║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║
|
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Start proxy: ║
|
`║ Start proxy: ║`,
|
||||||
║ bash ${startPath.replace(HOME, "~").padEnd(50)}║
|
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Or directly: ║
|
`║ Or directly: ║`,
|
||||||
║ node ${serverPath.replace(HOME, "~").padEnd(49)}║
|
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
║ Set as default model in openclaw.json: ║
|
];
|
||||||
║ agents.defaults.model.primary = ║
|
if (OPENCLAW_PRESENT) {
|
||||||
║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║
|
banner.push(
|
||||||
║ ║
|
`║ Set as default model in openclaw.json: ║`,
|
||||||
║ Then restart gateway: ║
|
`║ agents.defaults.model.primary = ║`,
|
||||||
║ openclaw gateway restart ║
|
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
||||||
║ ║
|
`║ ║`,
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
`║ Then restart gateway: ║`,
|
||||||
`);
|
`║ openclaw gateway restart ║`,
|
||||||
|
`║ ║`,
|
||||||
// ── Step 6: Optionally start ────────────────────────────────────────────
|
);
|
||||||
if (!SKIP_START && !DRY_RUN) {
|
} else {
|
||||||
try {
|
banner.push(
|
||||||
execSync(`bash "${startPath}"`, { stdio: "inherit" });
|
`║ OpenClaw not detected — running in standalone mode. ║`,
|
||||||
} catch { /* ignore */ }
|
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
||||||
|
`║ Aider / OpenClaw) at: ║`,
|
||||||
|
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
||||||
|
`║ ║`,
|
||||||
|
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
||||||
|
`║ ║`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
||||||
|
console.log("\n" + banner.join("\n") + "\n");
|
||||||
|
|
||||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||||
|
|
||||||
|
// Log service-env injection plan (shown in both dry-run and live mode)
|
||||||
|
console.log("\n🔧 Service unit env vars to inject:\n");
|
||||||
|
if (CLAUDE_BIN_INJECT) {
|
||||||
|
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
||||||
|
} else {
|
||||||
|
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
||||||
|
}
|
||||||
|
if (OCP_ADMIN_KEY_INJECT) {
|
||||||
|
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
||||||
|
} else {
|
||||||
|
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
||||||
|
}
|
||||||
|
if (PROXY_ANON_KEY_INJECT) {
|
||||||
|
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
||||||
|
} else {
|
||||||
|
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log("\n [dry-run] would write service unit with above env vars\n");
|
||||||
|
}
|
||||||
|
|
||||||
if (!DRY_RUN) {
|
if (!DRY_RUN) {
|
||||||
console.log("\n🔄 Installing auto-start on login...\n");
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
@@ -344,7 +407,13 @@ if (!DRY_RUN) {
|
|||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${BIND_ADDRESS}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
<string>${AUTH_MODE_CONFIG}</string>
|
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||||
|
<key>CLAUDE_BIN</key>
|
||||||
|
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||||
|
<key>OCP_ADMIN_KEY</key>
|
||||||
|
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||||
|
<key>PROXY_ANONYMOUS_KEY</key>
|
||||||
|
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -358,8 +427,15 @@ if (!DRY_RUN) {
|
|||||||
</plist>
|
</plist>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
writeFileSync(plistPath, plistXml);
|
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
||||||
log(`Plist written: ${plistPath}`);
|
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
||||||
|
writeFileSync(plistPath, finalPlistXml);
|
||||||
|
chmodSync(plistPath, 0o600);
|
||||||
|
if (existingPlist && finalPlistXml !== plistXml) {
|
||||||
|
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
||||||
|
} else {
|
||||||
|
log(`Plist written: ${plistPath} (mode 600)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Bootout first (in case it was already loaded) then bootstrap
|
// Bootout first (in case it was already loaded) then bootstrap
|
||||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
@@ -382,7 +458,7 @@ After=network.target
|
|||||||
ExecStart=${nodeBin} ${serverPath}
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
@@ -392,8 +468,15 @@ StandardError=append:${logPath}
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
`;
|
`;
|
||||||
|
|
||||||
writeFileSync(servicePath, serviceUnit);
|
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
||||||
log(`Service file written: ${servicePath}`);
|
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
||||||
|
writeFileSync(servicePath, finalServiceUnit);
|
||||||
|
chmodSync(servicePath, 0o600);
|
||||||
|
if (existingService && finalServiceUnit !== serviceUnit) {
|
||||||
|
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
||||||
|
} else {
|
||||||
|
log(`Service file written: ${servicePath} (mode 600)`);
|
||||||
|
}
|
||||||
|
|
||||||
execSync(`systemctl --user daemon-reload`);
|
execSync(`systemctl --user daemon-reload`);
|
||||||
execSync(`systemctl --user enable ocp-proxy`);
|
execSync(`systemctl --user enable ocp-proxy`);
|
||||||
@@ -405,4 +488,52 @@ WantedBy=default.target
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
||||||
|
|
||||||
|
// ── Step 8: Post-install health verification ───────────────────────────
|
||||||
|
if (!SKIP_START) {
|
||||||
|
console.log("⏳ Waiting for server to bind...\n");
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
const healthUrl = `http://127.0.0.1:${PORT}/health`;
|
||||||
|
let verified = false;
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const res = await fetch(healthUrl, { signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
console.log(` ✓ Health check passed (${healthUrl})`);
|
||||||
|
console.log(` version: ${body.version ?? "unknown"}`);
|
||||||
|
console.log(` authMode: ${body.authMode ?? "unknown"}`);
|
||||||
|
|
||||||
|
// Verify bind socket
|
||||||
|
try {
|
||||||
|
const bindCheck = process.platform === "linux"
|
||||||
|
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
|
||||||
|
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
||||||
|
if (bindCheck) {
|
||||||
|
console.log(` bind: ${bindCheck.split("\n")[0]}`);
|
||||||
|
}
|
||||||
|
} catch { /* bind check is best-effort */ }
|
||||||
|
|
||||||
|
verified = true;
|
||||||
|
} else {
|
||||||
|
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
|
||||||
|
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
const logHint = process.platform === "linux"
|
||||||
|
? "journalctl --user -u ocp-proxy -n 50"
|
||||||
|
: `tail -n 100 ~/.ocp/logs/proxy.log`;
|
||||||
|
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
|
||||||
|
console.error(` Check service logs:\n ${logHint}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1187
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user