mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-27 16:05:07 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfab362fd9 | ||
|
|
a87523004d |
@@ -1,209 +0,0 @@
|
||||
name: flake hunt (#203)
|
||||
|
||||
# Manual only. This never runs on push or pull_request, so it costs nothing until asked for.
|
||||
#
|
||||
# #203 has been seen four times, always on Linux CI, never on macOS. This is the instrument
|
||||
# for reproducing it deliberately instead of by chance on someone else's PR.
|
||||
#
|
||||
# WHAT IS AND IS NOT KNOWN ABOUT THE NOISE FLOOR — read before interpreting any result.
|
||||
# An earlier Linux VM attempt was reported as invalidated by Node 22's `node:sqlite`
|
||||
# ExperimentalWarning. That attribution was WRONG and is corrected here so nobody acts on it:
|
||||
# the boot predicate is `buf.out.includes("listening on") || buf.exit != null` — stdout only.
|
||||
# `buf.err` appears in the assertion MESSAGE, never in the condition, so a stderr warning
|
||||
# cannot fail that assertion. It was visible in the failure text and mistaken for the cause.
|
||||
# The real noise floor was pre-#204 fixed ports: that branch's control arm measured 246
|
||||
# EADDRINUSE and only 42/200 clean runs on unmodified main. #204 removed it. Node 22 is
|
||||
# therefore a PERFECTLY USABLE arm — the suite passes 462/0 under it — and is offered below.
|
||||
#
|
||||
# Concurrency is the knob, not round count: test() is fire-and-forget for async bodies, so one
|
||||
# suite run peaks at ~11-12 concurrent server.mjs children (15 total spawns; the gate test's
|
||||
# 3 cases and the 2 epoch boots are awaited serially, so they never overlap). Several suites
|
||||
# at once is what multiplies cross-process contention. See AGENTS.md § "Testing: reaching
|
||||
# faults inside server.mjs".
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch, tag, or FULL 40-char SHA. Pre-#204 control: c180987376aecca9a90dcec3605cbe1abe48ebf0'
|
||||
default: ''
|
||||
node:
|
||||
description: 'Node major version'
|
||||
default: '24'
|
||||
type: choice
|
||||
options: ['24', '22', '25', '26']
|
||||
rounds:
|
||||
description: 'Rounds; each round runs <concurrency> suites at once and waits'
|
||||
default: '50'
|
||||
type: number
|
||||
concurrency:
|
||||
description: 'Concurrent suite processes per round (this is the knob that reproduces)'
|
||||
default: '4'
|
||||
type: number
|
||||
|
||||
# `ref` exists because a null result on current main is UNINTERPRETABLE. #204 may already have
|
||||
# fixed #203 — if it did, 0/200 on main cannot be told apart from "didn't hunt hard enough" or
|
||||
# "wrong configuration". The positive control is the commit BEFORE #204:
|
||||
#
|
||||
# c180987376aecca9a90dcec3605cbe1abe48ebf0 (7f15921^, i.e. #207)
|
||||
#
|
||||
# Reproducing there and not on main establishes both the mechanism and the fix. Hunt the
|
||||
# control first. NOTE: actions/checkout does NOT rev-parse — it takes a branch, a tag, or a
|
||||
# FULL 40-char SHA. `7f15921^` and even the abbreviated `7f15921` both fail to resolve
|
||||
# (`git ls-remote origin '7f15921^'` returns 0 rows), so the full SHA is spelled out above.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Deliberately NOT keyed on github.ref: the whole point is comparing a control arm against main,
|
||||
# and two hunts running at once contend for the same runner class — the one variable this
|
||||
# experiment exists to hold still. A bare group serializes dispatches from any branch.
|
||||
concurrency:
|
||||
group: flake-hunt
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hunt:
|
||||
name: 'hunt: node ${{ inputs.node }} x ${{ inputs.rounds }} x ${{ inputs.concurrency }}'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node }}
|
||||
|
||||
- name: Record the environment
|
||||
run: |
|
||||
set +e -u -o pipefail
|
||||
{
|
||||
echo "ref $(git rev-parse HEAD) ($(git log -1 --format=%s | cut -c1-60))"
|
||||
echo "node $(node --version)"
|
||||
echo "kernel $(uname -srm)"
|
||||
echo "cpus $(nproc)"
|
||||
echo "ephemeral $(cat /proc/sys/net/ipv4/ip_local_port_range)"
|
||||
} | tee env.txt
|
||||
|
||||
- name: Hunt
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
# Inputs go through env, never interpolated into the script body: GitHub documents
|
||||
# `inputs.*` as untrusted, and a free-text field spliced into shell is injectable.
|
||||
ROUNDS: ${{ inputs.rounds }}
|
||||
CONC: ${{ inputs.concurrency }}
|
||||
run: |
|
||||
set +e -u -o pipefail # a failing suite run is the DATA, not a step failure
|
||||
mkdir -p logs
|
||||
for r in $(seq 1 "$ROUNDS"); do
|
||||
for c in $(seq 1 "$CONC"); do
|
||||
# Per-run timeout: without it a single hung child blocks `wait` until the step
|
||||
# timeout and the whole hunt yields nothing.
|
||||
# -k follows SIGTERM with SIGKILL. Do NOT add --foreground: by default timeout
|
||||
# makes itself the process-group leader and signals the GROUP, so the node child
|
||||
# and the server.mjs grandchildren die with it; --foreground turns that off
|
||||
# ("children of COMMAND will not be timed out", coreutils). Measured, 3 runs each:
|
||||
# default -> 0 survivors; --foreground -> 2 survivors. Orphans would then keep
|
||||
# competing for the runner's 4 vCPUs and inflate the very contention this
|
||||
# experiment exists to hold still.
|
||||
timeout -k 10 300 npm test > "logs/r${r}c${c}.log" 2>&1 &
|
||||
done
|
||||
wait
|
||||
printf '.'
|
||||
done
|
||||
echo
|
||||
|
||||
- name: Classify
|
||||
if: always()
|
||||
run: |
|
||||
# `set +e` is load-bearing, NOT decoration. GitHub's default shell is `bash -e {0}`,
|
||||
# and `set -uo pipefail` does not turn errexit off — it only ADDS pipefail. Every
|
||||
# category that counts ZERO makes grep exit 1, pipefail propagates it, and errexit
|
||||
# kills the step before it writes any summary. The all-clean case — the one this
|
||||
# workflow most wants to report — dies first. Verified: identical script exits 1 with
|
||||
# 0 bytes of summary under `bash -e`, and 0 with a full summary under `set +e`.
|
||||
set +e -u -o pipefail
|
||||
|
||||
total=$(ls logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
clean=$(grep -l ', 0 failed ===' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
# A round killed by the step timeout leaves truncated logs with no Results line. Those
|
||||
# count in `total` but can never be clean, which silently depresses the clean rate and
|
||||
# looks like a worse flake than reality. Report completions separately.
|
||||
completed=$(grep -l '=== Results:' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
# #203's SIGNATURE, not just its test name. A CPU-starved runner (4 vCPU carrying
|
||||
# ~44 node processes at concurrency 4) can blow the 9s ltWait and produce the same
|
||||
# "✗ boot gate REFUSES" line with closed=false / (still open). That is contention,
|
||||
# not #203. The real one is: the child CLOSED, exited non-zero, and stderr was EMPTY.
|
||||
#
|
||||
# This pattern can only match a FUTURE reproduction. All four historic sightings predate
|
||||
# #204's ltDiag — their text is `expected a local-tools FATAL, got: ` with no `closed=`
|
||||
# and no `stderr(0B)` at all — so do NOT try to validate this grep against them and
|
||||
# conclude it is broken.
|
||||
# The `expected a local-tools FATAL` clause is not decoration: it pins the match to the
|
||||
# THIRD assertion in that test. Without it, `exit=0` is swallowed by `.*` and the SECOND
|
||||
# assertion ("must exit non-zero" — the gate did not refuse at all, the OPPOSITE failure)
|
||||
# scores as a #203 signature. Verified: the loose pattern matches both exit=0 and exit=1;
|
||||
# this one matches only exit=1.
|
||||
sig=$(grep -l '✗.*boot gate REFUSES.*expected a local-tools FATAL.*closed=true.*stderr(0B)' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
gate=$(grep -l '✗.*boot gate REFUSES' logs/*.log 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
{
|
||||
echo "## flake hunt — node ${{ inputs.node }}"
|
||||
echo
|
||||
echo '```'
|
||||
cat env.txt 2>/dev/null || echo "(env.txt missing — the Record step did not run)"
|
||||
echo '```'
|
||||
echo
|
||||
echo "| | runs |"
|
||||
echo "|---|---|"
|
||||
echo "| **clean (0 failed)** | **$clean / $completed completed** |"
|
||||
echo "| suites launched (a shortfall = step timeout, not a flake) | $total |"
|
||||
echo "| #203 **signature** (gate + closed=true + stderr 0B) | **$sig** |"
|
||||
echo "| gate test failed, any cause (includes contention) | $gate |"
|
||||
echo
|
||||
echo "\`sig\` is the number that answers #203. \`gate\` minus \`sig\` is runner contention."
|
||||
echo "A zero is not proof of absence — only a non-zero count is evidence."
|
||||
echo
|
||||
# Full histogram instead of a hand-maintained category list: a category table
|
||||
# silently drops every failure nobody thought to enumerate, and on a hunt the
|
||||
# unenumerated failure is exactly the interesting one.
|
||||
# Bucket on a fixed-width prefix of the test NAME, not on `sed 's/:.*//'` — test
|
||||
# names contain colons, so cutting at the first one collapses every
|
||||
# `localToolsSafetyError: <case>` into one useless bucket. 72 chars keeps the
|
||||
# cases apart while still stripping the per-run assertion detail.
|
||||
echo "### every failing assertion, by test"
|
||||
echo '```'
|
||||
grep -h '✗' logs/*.log 2>/dev/null | sed 's/^ *✗ //' | cut -c1-72 | sort | uniq -c | sort -rn | head -25
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Prefer a log carrying the actual signature; fall back to any failing log.
|
||||
# INVARIANT for anyone extending this step: the `&&` list below returns 1 when $first
|
||||
# is non-empty, so it must never be the LAST command in the script or the step exits
|
||||
# non-zero. Today the `if` that follows returns 0 and absorbs it.
|
||||
first=$(grep -l '✗.*boot gate REFUSES.*expected a local-tools FATAL.*closed=true.*stderr(0B)' logs/*.log 2>/dev/null | head -1)
|
||||
[ -z "$first" ] && first=$(grep -L ', 0 failed ===' logs/*.log 2>/dev/null | head -1)
|
||||
if [ -n "$first" ]; then
|
||||
{
|
||||
echo "### $first"
|
||||
echo '```'
|
||||
grep -h '✗' "$first" | head -20
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flake-hunt-node${{ inputs.node }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
logs/
|
||||
env.txt
|
||||
retention-days: 14
|
||||
|
||||
# This job does NOT fail on a reproduction. Reproducing is the goal, and a red X would
|
||||
# read as "the hunt is broken" rather than "the flake was caught".
|
||||
@@ -21,31 +21,24 @@ jobs:
|
||||
- name: Extract CHANGELOG section
|
||||
id: notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
NOTES=/tmp/release-notes.md
|
||||
# Extract section for this version from CHANGELOG.md
|
||||
# Pattern: "## v${VERSION}" through the next "## " or EOF
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found; using minimal release notes"
|
||||
# MUST write the file, not just an output: the create step consumes a FILE, so an
|
||||
# early exit here used to leave --notes-file pointing at a path that never existed,
|
||||
# turning "degrade to minimal notes" into a failed release job (#202).
|
||||
echo "Release v${VERSION}" > "$NOTES"
|
||||
echo "notes_file=$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "notes=Release v${VERSION}" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
awk -v ver="v${VERSION}" '
|
||||
$0 ~ "^## " ver { found=1; print; next }
|
||||
found && /^## v/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md > "$NOTES"
|
||||
if [ ! -s "$NOTES" ]; then
|
||||
' CHANGELOG.md > /tmp/release-notes.md
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes"
|
||||
echo "Release v${VERSION}" > "$NOTES"
|
||||
echo "Release v${VERSION}" > /tmp/release-notes.md
|
||||
fi
|
||||
echo "--- release notes (${VERSION}) ---"; cat "$NOTES"
|
||||
echo "notes_file=$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "notes_file=/tmp/release-notes.md" >> $GITHUB_OUTPUT
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -56,5 +49,5 @@ jobs:
|
||||
fi
|
||||
gh release create "v${{ steps.ver.outputs.version }}" \
|
||||
--title "v${{ steps.ver.outputs.version }}" \
|
||||
--notes-file "${{ steps.notes.outputs.notes_file }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
--latest
|
||||
|
||||
@@ -30,7 +30,6 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
|
||||
- `server.mjs` — the proxy itself; every request path lives here. Governed by `ALIGNMENT.md`.
|
||||
- `models.json` — single source of truth for model IDs, aliases, and context windows. See ADR 0003.
|
||||
- `models.schema.json` — the schema `models.json` declares in its `$schema`. CI validates the SPOT against it (`test-features.mjs`) using the repo's own `validateJsonSchema`, so a malformed entry fails the build instead of surfacing downstream in OpenClaw.
|
||||
- `setup.mjs` — first-time installer; reads `models.json` to derive bootstrap config.
|
||||
- `scripts/sync-openclaw.mjs` — idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004.
|
||||
- `ocp` — user-facing CLI (install, update, start, stop, status, logs, etc.).
|
||||
@@ -53,22 +52,6 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
|
||||
---
|
||||
|
||||
## Testing: reaching faults inside `server.mjs`
|
||||
|
||||
`test-features.mjs` cannot `import` `server.mjs` (it calls `server.listen()` at top level), and that has twice led to the wrong conclusion that a class of bug is untestable. It isn't. Read this before writing "no regression test is possible here".
|
||||
|
||||
**There is a real live-server fixture.** `ltBoot(env, dir, nodeArgs)` (around `test-features.mjs:990`) spawns the actual `server.mjs` as a child with a **fake `claude` binary**, so integration tests cost no quota. `ltPost` / `ltPostStatus` / `ltWait` / `ltFreePort` round it out. It already covers boot gates, cache-epoch invalidation across two boots sharing one SQLite store, and system-prompt capture.
|
||||
|
||||
**`--stack-size` is a fault lever.** `ltBoot`'s third argument passes V8 flags to the child, which puts recursion- and argument-count-limited failures in reach at a much smaller input. `#193` needed a *synchronous throw* deep inside `spawnClaudeProcess`; `buildCliArgs` does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and under `--stack-size=200` that spread throws at ~24k elements instead of ~124k — which is what brings the trigger under Linux's `MAX_ARG_STRLEN` (131072 bytes for a single env string) so the test runs in CI rather than skipping. **No production fault hook was needed.**
|
||||
|
||||
Three rules that made it hold up, all learned the hard way:
|
||||
|
||||
- **Discover the threshold in a child under the same flags**, never in the test process — the parent's stack is not the one that matters.
|
||||
- **Assert that the fault actually fired**, not just that the outcome looks right. `#193` asserts HTTP 500 *and* that the body carries `call stack size exceeded`; a control mutation (trigger neutered, bug still present) proves the test fails rather than passing vacuously.
|
||||
- **Wait for the thing you are about to assert**, not a proxy for it. Waiting on `listening on` and then asserting a different line is a race (`#199`); waiting for the process to *exit* and then reading its `stderr` is another, because a terminated child's pipes may still hold unread data (`#203` — wait for the stdio to close, not for the exit).
|
||||
|
||||
Allocate ports with `ltFreePort()`. Fixed ports have caused at least one flake here.
|
||||
|
||||
## Release protocol
|
||||
|
||||
OCP follows the machine-readable `release_kit:` overlay in `CLAUDE.md` (Iron Rule 5.5). Before any version bump or tag push, re-read that YAML block and walk every item in `new_feature_doc_expectations` and `bootstrap_quirk_policy`. Tag push triggers `.github/workflows/release.yml`, which creates the GitHub Release automatically — do not create the release manually.
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v3.26.0 — 2026-07-27
|
||||
|
||||
Maintenance release. One user-visible change — advertised `maxTokens` now tells the truth — and four rounds of repairs to the machinery that is supposed to catch mistakes: a schema for the model SPOT, a release-job bug that would have shipped an empty release body, and the integration-test harness that had been failing 4 runs in 5 without anyone noticing.
|
||||
|
||||
Every PR carried an independent fresh-context reviewer (Iron Rule 10). Two reviewers refuted the author's stated rationale while the change itself stood; in both cases the rationale was rewritten rather than the finding waved off, and one reviewer retracted its own earlier finding after the evidence was re-derived. `server.mjs` is untouched in this release, so no `cli.js` citation applies.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`maxTokens` now matches the CLI registry (#195).** The previous values were not uniform: six entries were 16384 and `claude-haiku-4-5-20251001` was 8192. Every Opus entry and `claude-sonnet-5` go to **64000**, `claude-sonnet-4-6` and `claude-haiku-4-5-20251001` to **32000** — the `max_output_tokens.default` each model declares in the compiled CLI 2.1.220 registry. Every model except `claude-sonnet-4-6` (1.95x) moves by the same 3.91x — haiku included, from its lower base. This corrects **advertised metadata only**: `models.json` is the SPOT (ADR 0003) and every value in it should be the truth about the model. **It changes nothing about how OCP behaves.** OCP never enforces `maxTokens` — `buildCliArgs` passes no output-token flag to the CLI at all — and OpenClaw addresses a local OCP over `openai-completions`, whose request field (`max_completion_tokens`) appears nowhere in this repo. The value is consumed only by clients that choose to honour it, via `setup.mjs` / `scripts/sync-openclaw.mjs` / `ocp-connect`. **Expect no change in answer length or quota burn.** `ocp-connect`'s independent family table moves to the floor over each family's current `models.json` members (opus 64000, sonnet 32000, haiku 32000), since prefixes cannot distinguish versions. Its unknown-id fallback stays at **8192** — the registry's global minimum, held by `claude-3-5-haiku` and `claude-3-5-sonnet`, which are the only real ids that reach it (`claude-3-5-*` matches no family prefix).
|
||||
|
||||
### Added
|
||||
|
||||
- **`models.schema.json` — the model SPOT now has a schema, enforced in CI (#196).** `models.json` declares it via `$schema`, and `test-features.mjs` validates the SPOT against it using the repo's **own** `validateJsonSchema` from `lib/structured-output.mjs` — no new dependency. A malformed entry now fails the build instead of surfacing downstream in OpenClaw. The schema's description names exactly which keywords that validator enforces and which it **silently ignores** (`minimum`, `maxLength`, `pattern`, `uniqueItems`, …), so nobody adds a constraint that buys nothing; those go in `test-features.mjs` instead. Guard tests cover 8 distinct corruptions plus uniqueness and whitespace on every name field.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`release.yml` would have produced an empty release body on any repo state without `CHANGELOG.md` (#202).** The no-changelog branch set an output pointing at a notes file it never wrote, and the create step then read a missing path. Rare, but it fails exactly when you least want it to — during a release. The branch now writes the file.
|
||||
- **The `ltBoot` integration harness was failing 4 runs in 5 (#199, #209).** Measured with a control arm — full suite × 4 concurrent × 50 rounds against unmodified `main` and against the fix — clean runs went **42/200 → 200/200**, with `EADDRINUSE` **246 → 0**. Four distinct races: two tests gated on a stdout marker and then asserted on a **stderr** line written 12 `console.log` calls later (different pipes, nothing ordering them); every fixed port replaced with `ltFreePort()` (the old 39321–39364 range sits *inside* Linux's default ephemeral range, so CI was more exposed than a Mac); `close` instead of `exit` where a test reads a buffer after termination; and retrying teardown against grandchildren still writing into the temp dir. **#203 is NOT fixed and remains open** — it has never reproduced on macOS (0 across both 200-run arms) and all four sightings are Linux CI, one of them on #205 inside this very release. #211 adds a manual `workflow_dispatch` harness to hunt it on Linux with a pre-#204 positive control. Diagnostics now print exit/signal/closed/elapsed/Node version plus a head+tail sample of both streams — sized so the sample provably reaches the one line that distinguishes "booted" from "refused", with a test pinning that budget so it cannot silently degrade.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Guard comments on the asymmetric cache-key construction (#200)** — `structuredHash` and `dedupKey` carry deliberately different guards, and the resemblance invites a "cleanup" that would collapse them. Now documented at the site.
|
||||
- **`AGENTS.md` § "Testing: reaching faults inside `server.mjs`" (#197)** — writes up the fault-injection method these fixes needed, including the `--stack-size` lever and why running a flaky scenario *in isolation* removes the very concurrency that causes it.
|
||||
- **`ocp-connect` documentation corrections.** Its family table is the floor over each family's *current* `models.json` members — not over the registry family — and its unknown-id fallback stays at 8192, the registry's global minimum. Both had comments asserting otherwise. Its model table and fallback remain **untested**; tracked as #210.
|
||||
- **Known gap, deliberately not fixed here: `contextWindow` does *not* match the registry (#213).** The same CLI 2.1.220 bundle declares `window:1e6, native_1m:true` for `claude-opus-5`/`-4-8`/`-4-7` and `claude-sonnet-5`, while `models.json` says 200000. Unlike `maxTokens`, this cannot be corrected as metadata: `derivePromptCharBudget` takes `max(contextWindow) × 3` across **all** entries, so one 1M model would raise `MAX_PROMPT_CHARS` from 600k to 3M for every model — including `claude-haiku-4-5-20251001`, which really is 200k native — turning clean OCP-side truncation into upstream API rejections. Fixing it needs per-model prompt budgets (ADR-level). Recorded so this release's "the SPOT tells the truth" claim is not read as covering it.
|
||||
|
||||
## v3.25.0 — 2026-07-27
|
||||
|
||||
Minor release. Headline: **Claude Opus 5** joins the model list and the `opus` alias now resolves to it. Alongside it, two `server.mjs` correctness fixes found by review rather than by users — a monotonic in-flight-counter leak, and cache keys that were hashing the alias string instead of the model it resolves to. The three TUI/prompt fixes that landed after v3.24.0 are included here too.
|
||||
|
||||
Every code PR carried an independent fresh-context reviewer (Iron Rule 10); #192 additionally went through the external codex gate, which is what surfaced the cache-key defect. No new endpoint, no new env var, no new `cli.js` wire behavior.
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Opus 5 (`claude-opus-5`) (#192).** New `models.json` entry — `/v1/models` goes 6 → 7 and OpenClaw picks it up on the next `ocp update` (via `scripts/sync-openclaw.mjs`). Verified against the installed CLI rather than assumed: the compiled `claude` 2.1.220 bundle carries `latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}`. Availability confirmed with a live `claude -p --model claude-opus-5` completion on the subscription pool. `claude-opus-4-8` is retained for pinning.
|
||||
- `contextWindow` is deliberately **200000**, not Opus 5's native 1M. Two reasons, both verified: (1) `MAX_PROMPT_CHARS` is a **single global** budget — `derivePromptCharBudget` takes `max(contextWindow) × 3` across *all* entries (`lib/prompt.mjs`), so a 1M entry would raise the truncation ceiling to 3,000,000 chars for `claude-haiku-4-5` too, which is genuinely 200k native, converting clean OCP-side truncation into an upstream API rejection; (2) OpenClaw scales its history budget linearly off this value (`contextWindow × maxHistoryShare × SAFETY_MARGIN` = `× 0.6`, plus an oversized-message threshold at `× 0.5`, per `compaction-planning` in OpenClaw 2026.7.1), and its own bundled registry hardcodes 200000 for Claude — the upstream request to raise it to 1M ([openclaw#22979](https://github.com/openclaw/openclaw/issues/22979)) was closed *not planned*. A new regression test pins the invariant so a future 1M entry has to be a deliberate, reviewed change. Raising it for real needs per-model budgets — tracked separately, ADR-level.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The `opus` alias now resolves to `claude-opus-5` instead of `claude-opus-4-8` (#192).** Every request that names `opus` — and OpenClaw's opus entry — moves to Opus 5 on upgrade. This mirrors what the CLI itself defaults to (`latest_per_family.opus` above), the same reasoning as #168's `sonnet` → `claude-sonnet-5` repoint in v3.23.0. **Pricing is unchanged** ($5/$25 per MTok; CLI registry `pricing:"tier_5_25"` for both Opus 4.8 and Opus 5), so this carries no cost change. Pin `claude-opus-4-8` explicitly to stay on the previous model.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cache keys hashed the alias, not the model it resolves to (#194).** `model` enters `cacheHash` exactly as the client sent it, so a request for `"opus"` was cached under the literal `"opus"` — and since `models.json` is read once at boot while the SQLite `response_cache` outlives a restart, repointing an alias kept serving the **old model's** answers under it until TTL expiry. That would have silently defeated this release's own `opus` repoint for anyone running with the cache on. All three call sites now hash `Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model`, which fixes the normal, structured **and** single-flight keys at once and covers `legacyAliases` for free, with precise invalidation (only the repointed alias's entries change key) rather than a whole-cache flush. (`hasOwn` rather than a bare lookup: `MODEL_MAP` is a plain object, so `MODEL_MAP["__proto__"]` would return a truthy *object* and not even fall through to the `|| model` guard.) **Also closes a latent gap from #177:** the structured and dedup keys never passed `configEpoch` at all, so a `CLAUDE_SYSTEM_PROMPT` change — the original #176 scenario — kept serving structured answers composed under the old config, live since #153. Found by the external codex review of #192.
|
||||
- **In-flight request counter leaked permanently on a pre-spawn throw (#193, reported by @konceptnet in #180).** `stats.activeRequests` was incremented ~40 lines before the child spawn, while its only decrement is reached through that process's events — so any synchronous throw in between (`buildCliArgs`, env assembly, the spawn decision, or `spawn()` itself) leaked `+1` forever, and `/health` and `/status` over-reported in-flight work monotonically. The increment now sits immediately after `activeProcesses.add(proc)`, structurally pairing it with the decrement; no reconciliation pass and no try/catch. Observability-only field, so no admission decision changes.
|
||||
- **TUI: the host `CLAUDE.md` could leak into proxied turns (#187, contributed by @sumlin).** The TUI pane now spawns with `--safe-mode`.
|
||||
- **TUI: `shift+tab to cycle` is accepted as an input-ready marker (#188, contributed by @sumlin).** Claude renders one of two ready-state footers depending on the build — the classic `? for shortcuts` hint, or `shift+tab to cycle` (as part of `⏵⏵ bypass permissions on (shift+tab to cycle)`) on newer 2.1.x. The matcher only knew the classic string, so on those builds it silently reported "never ready": every boot timed out with `tui_pane_not_ready`, and with the warm pool on, every pre-boot failed.
|
||||
- **`OCP_LOCAL_TOOLS` no longer hard-codes a tool list in its wrapper (#191, closes #185).** The positive wrapper claimed a fixed set of tools regardless of `CLAUDE_ALLOWED_TOOLS`, so a narrowed tool surface was described inaccurately to the model.
|
||||
|
||||
### Testing
|
||||
|
||||
- The response-cache and counter fixes ship with **mutation-proven integration tests** built on the existing `ltBoot` child-process fixture (real `server.mjs`, fake `claude` binary, so no quota cost). The counter test reaches a synchronous fault *inside* `spawnClaudeProcess` from environment alone — no production fault hook — by running the child under `--stack-size=200` to lower the spread-throw threshold enough to fit Linux's `MAX_ARG_STRLEN`. Suite: **447 → 457** across this release (per PR: #188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1).
|
||||
|
||||
## v3.24.0 — 2026-07-21
|
||||
|
||||
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
|
||||
|
||||
@@ -113,11 +113,11 @@ node setup.mjs
|
||||
|
||||
`setup.mjs` verifies the Claude CLI, starts the proxy on port 3456, and installs auto-start (launchd on macOS, systemd on Linux). The `ocp` CLI lands at `~/ocp/ocp` — symlink it onto your PATH (`sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp`, or `ln -sf ~/ocp/ocp ~/.local/bin/ocp`) or alias it (`alias ocp=~/ocp/ocp`); the rest of the docs assume `ocp` is on your PATH.
|
||||
|
||||
**Verify** — should list 7 models:
|
||||
**Verify** — should list 6 models:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
# claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
|
||||
@@ -169,15 +169,14 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
||||
|
||||
| Model ID | Notes |
|
||||
|----------|-------|
|
||||
| `claude-opus-5` | Most capable (default for `opus` alias) |
|
||||
| `claude-opus-4-8` | Previous Opus, retained for pinning |
|
||||
| `claude-opus-4-7` | Older Opus, retained for pinning |
|
||||
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
|
||||
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0, validated in CI against [`models.schema.json`](./models.schema.json). Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
|
||||
```bash
|
||||
# 1. Edit models.json — add an entry
|
||||
@@ -388,7 +387,7 @@ curl -X DELETE http://127.0.0.1:3456/cache # clear all cached responses
|
||||
ocp settings cacheTTL 0 # disable at runtime
|
||||
```
|
||||
|
||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Cache keys resolve model aliases as of v3.25.0:** a request for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) is now keyed on the canonical model it resolves to, not on the string the client sent. Two consequences, both one-time and self-healing: rows written before the upgrade don't match the new lookups, so they orphan and are reaped by the TTL cleanup interval within one window — no migration script required; and an alias now correctly shares a cache slot with its canonical id, since both produce an identical spawn. Scope: for the **normal** cache only alias-addressed rows rekey — rows keyed on a literal model id keep matching, *unless* you run `OCP_LOCAL_TOOLS=1`, in which case the whole normal cache rekeys once because v3.25.0 also reworded that wrapper and the wrapper text feeds the config epoch. **Every structured-output row rekeys regardless**, since the same change folds the config epoch into the structured key, which it previously omitted. This is what makes an alias repoint (such as v3.25.0's `opus` → `claude-opus-5`) take effect immediately instead of being masked by the cache until TTL expiry. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
||||
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 don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
||||
|
||||
## Structured Outputs (OpenAI `response_format`)
|
||||
|
||||
@@ -541,7 +540,6 @@ The simplest path: ask your AI — paste `Run `ocp doctor` and follow its `next_
|
||||
|
||||
- **A TUI session vanished right after upgrading OCP** — if a pre-3.21.1 and a post-3.21.1 instance ran on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance. Restart the affected session (`ocp restart` or re-run your TUI turn) and it returns under the new instance's port-scoped naming.
|
||||
- **OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)** — the running shell had the old `cmd_update` cached, so the sync hook doesn't fire on that single jump. Run once: `node ~/ocp/scripts/sync-openclaw.mjs && openclaw gateway restart`. Every future update syncs automatically.
|
||||
- **Response-cache hit rate drops once after upgrading to v3.25.0** — only if you run with the cache on (`CLAUDE_CACHE_TTL > 0`; it is off by default). v3.25.0 keys the cache on the resolved model instead of the string the client sent, so alias-addressed rows (and *all* structured-output rows) orphan and are reaped by the TTL cleanup within one window. **No action required.** Details: [docs/troubleshooting.md#cache-rekey-v3250](docs/troubleshooting.md#cache-rekey-v3250).
|
||||
|
||||
Full manual — setup failures, env-var-not-taking-effect-after-restart (launchd bootout+bootstrap vs `kickstart -k`), stuck sessions, "OpenClaw registry out of sync", and the two-layer TUI-mode 401 root cause + fix: **[docs/troubleshooting.md](docs/troubleshooting.md)**.
|
||||
|
||||
|
||||
+5
-6
@@ -78,7 +78,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||
**Verify:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# Returns: claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
### Headless install notes
|
||||
@@ -114,7 +114,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
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 7 models).
|
||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 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.
|
||||
|
||||
@@ -141,7 +141,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
||||
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 7 models.
|
||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
|
||||
|
||||
Tell me each step before running it. On error, diagnose before retrying.
|
||||
```
|
||||
@@ -163,7 +163,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
||||
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 7 models.
|
||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 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.
|
||||
@@ -216,7 +216,7 @@ OCP Connect v1.3.0
|
||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||
|
||||
Testing API access...
|
||||
✓ API accessible (7 models available)
|
||||
✓ API accessible (6 models available)
|
||||
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
@@ -246,7 +246,6 @@ OCP Connect v1.3.0
|
||||
✓ OpenClaw configured
|
||||
Provider: ocp
|
||||
Models:
|
||||
• ocp/claude-opus-5
|
||||
• ocp/claude-opus-4-8
|
||||
• ocp/claude-opus-4-7
|
||||
• ocp/claude-opus-4-6
|
||||
|
||||
@@ -116,22 +116,6 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
||||
|
||||
Future `ocp update` invocations sync automatically.
|
||||
|
||||
<a id="cache-rekey-v3250"></a>
|
||||
### Response-cache hit rate drops once after upgrading to v3.25.0
|
||||
|
||||
Only affects instances running with the response cache **on** (`CLAUDE_CACHE_TTL > 0`); it is off by default, so most installs see nothing.
|
||||
|
||||
v3.25.0 keys the cache on the **resolved** model rather than on the string the client sent, so rows written for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) no longer match. Those rows orphan and are reaped by the TTL cleanup interval within one window — **no migration script, no action required**; expect one window of extra misses and then normal hit rates.
|
||||
|
||||
Two different scopes, worth being precise about:
|
||||
|
||||
- **Normal cache** — only *alias-addressed* rows rekey. Rows written under a literal model id (`claude-sonnet-5`) keep matching — **unless the instance runs `OCP_LOCAL_TOOLS=1`**, in which case the entire normal cache rekeys once. That is a separate mechanism: v3.25.0 also reworded the local-tools wrapper, and the wrapper text is one of the four inputs to `CONFIG_EPOCH`, which every normal cache key folds in (established behavior since v3.23.0, not new here).
|
||||
- **Structured-output cache** — **every** row rekeys, alias or literal. The same change also folds the config epoch into the structured key, which it had never included; that gap meant a `CLAUDE_SYSTEM_PROMPT` change did not invalidate structured answers either. Structured caching only exists from v3.24.0, so there is at most one release worth of rows to orphan.
|
||||
|
||||
This is deliberate, and it is what makes an alias repoint take effect. Before v3.25.0, changing where an alias pointed (v3.25.0 itself repoints `opus` → `claude-opus-5`) left the cache serving the **old** model's answers under that alias until TTL expiry, because `models.json` is read once at boot while the SQLite cache survives the restart. If you were running with the cache on and repointed an alias in an earlier version, that is why it appeared not to take.
|
||||
|
||||
A side effect worth knowing: an alias and its canonical id now **share** a cache slot, since both produce an identical spawn. That is a small hit-rate improvement in steady state.
|
||||
|
||||
<a id="tui-401"></a>
|
||||
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode runs `claude` with `--safe-mode`, which disables all host customizations (CLAUDE.md, skills, plugins, hooks, MCP servers) while leaving auth, model selection, built-in tools, and permissions untouched — so a `CLAUDE.md` on the OCP host can never leak into a proxied turn and steer the answer, and the session still bills the subscription pool (`cc_entrypoint=cli`, unlike `--bare`). The `CLAUDE_CODE_DISABLE_CLAUDE_MDS` / `CLAUDE_CODE_DISABLE_AUTO_MEMORY` env vars are kept as a fallback (see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. **Exception:** the two opt-in modes that deliberately need a customization `--safe-mode` would strip — real streaming (`OCP_TUI_STREAM`, which registers a `MessageDisplay` **hook** via `--settings`) and `OCP_TUI_FULL_TOOLS` (which grants an **MCP** / skills surface) — run without `--safe-mode` and rely on the env-var suppression alone.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||
|
||||
+6
-41
@@ -182,13 +182,8 @@ function tuiCapturePane(tmux, tmuxName) {
|
||||
}
|
||||
|
||||
// True once claude's input bar is rendered and ready for keystrokes.
|
||||
// Two known ready-state footers across claude versions: the classic `? for shortcuts`
|
||||
// hint, and the `shift+tab to cycle` hint (part of "⏵⏵ bypass permissions on (shift+tab
|
||||
// to cycle)") that newer claude 2.1.x renders in its place. Match EITHER — a matcher that
|
||||
// only knew the classic string silently reported "never ready" on those builds, timing out
|
||||
// every boot (tui_pane_not_ready) and, with the warm pool on, failing every pre-boot.
|
||||
function tuiInputReady(pane) {
|
||||
return /\? for shortcuts|shift\+tab to cycle/.test(pane);
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
|
||||
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||
@@ -389,15 +384,9 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
||||
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||
// obeyed by the proxied turn until these flags were set, after which it was not. Mirrors the
|
||||
// -p path's CLAUDE_NO_CONTEXT vars. Harmless on hosts with no CLAUDE.md (they suppress nothing).
|
||||
//
|
||||
// These env vars stopped being sufficient on newer claude: they are present in the pane's
|
||||
// environment yet the host CLAUDE.md is still injected into the turn's context, so a proxied
|
||||
// turn again obeys the operator's private CLAUDE.md instead of the caller's prompt — a leak of
|
||||
// the operator's private context into API responses. The robust suppression is the --safe-mode
|
||||
// flag added to the argv below; these env vars are kept as belt-and-braces for the two argv
|
||||
// paths that cannot take --safe-mode (see the safeModeArgs note near the return).
|
||||
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||
const sets = [
|
||||
`HOME=${shq(ehome)}`,
|
||||
"DISABLE_AUTOUPDATER=1",
|
||||
@@ -488,32 +477,9 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
||||
// so the OFF argv is byte-for-byte the pre-streaming argv.
|
||||
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
|
||||
|
||||
// --safe-mode: disable ALL customizations (host CLAUDE.md, skills, plugins, hooks, MCP
|
||||
// servers, custom commands/agents, output styles, ...) while leaving auth, model selection,
|
||||
// built-in tools, and permissions untouched (claude 2.1.216 --help). This is the robust
|
||||
// replacement for the CLAUDE_CODE_DISABLE_CLAUDE_MDS / _AUTO_MEMORY env vars above, which no
|
||||
// longer suppress the host CLAUDE.md on newer claude — so the operator's private CLAUDE.md
|
||||
// could otherwise leak into a proxied API response. Unlike --bare, --safe-mode does NOT drop
|
||||
// the interactive session off the subscription pool (it keeps auth + model selection), so the
|
||||
// billing classifier stays cc_entrypoint=cli.
|
||||
//
|
||||
// NOT applied when the pane deliberately relies on a customization --safe-mode would strip,
|
||||
// because those would break SILENTLY:
|
||||
// - streaming (settingsArgs present): the MessageDisplay hook is a HOOK, which --safe-mode
|
||||
// disables — every streamed turn would then fire zero deltas (tui.streamZeroDeltaTurns)
|
||||
// and fall back to buffered, defeating OCP_TUI_STREAM.
|
||||
// - OCP_TUI_FULL_TOOLS=1: grants an MCP / skills / agent surface (--mcp-config, ...) that
|
||||
// --safe-mode disables wholesale.
|
||||
// Those two opt-in paths keep the env-var suppression above as their best-effort fallback.
|
||||
const safeModeArgs =
|
||||
settingsArgs.length === 0 && process.env.OCP_TUI_FULL_TOOLS !== "1"
|
||||
? ["--safe-mode"]
|
||||
: [];
|
||||
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
...safeModeArgs,
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
...toolArgs,
|
||||
@@ -649,9 +615,8 @@ export async function bootTuiPane({
|
||||
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
|
||||
// serves this one turn, and it is killed in the finally like any other pane.
|
||||
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
|
||||
// session in the scratch cwd, poll capture-pane until the input bar appears — see
|
||||
// tuiInputReady for the ready-state footers matched (bootTuiPane). BOOT_MS is the max
|
||||
// wait, not a fixed delay.
|
||||
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar
|
||||
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
|
||||
+7
-15
@@ -2,21 +2,13 @@
|
||||
"$schema": "./models.schema.json",
|
||||
"version": 1,
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-opus-5",
|
||||
"displayName": "Claude Opus 5",
|
||||
"openclawName": "Claude Opus 5 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"displayName": "Claude Opus 4.8",
|
||||
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-7",
|
||||
@@ -24,7 +16,7 @@
|
||||
"openclawName": "Claude Opus 4.7 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-6",
|
||||
@@ -32,7 +24,7 @@
|
||||
"openclawName": "Claude Opus 4.6 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-5",
|
||||
@@ -40,7 +32,7 @@
|
||||
"openclawName": "Claude Sonnet 5 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
@@ -48,7 +40,7 @@
|
||||
"openclawName": "Claude Sonnet 4.6 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 32000
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
@@ -56,11 +48,11 @@
|
||||
"openclawName": "Claude Haiku 4.5 (via CLI)",
|
||||
"reasoning": false,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 32000
|
||||
"maxTokens": 8192
|
||||
}
|
||||
],
|
||||
"aliases": {
|
||||
"opus": "claude-opus-5",
|
||||
"opus": "claude-opus-4-8",
|
||||
"sonnet": "claude-sonnet-5",
|
||||
"haiku": "claude-haiku-4-5-20251001"
|
||||
},
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/dtzp555-max/ocp/blob/main/models.schema.json",
|
||||
"title": "OCP models.json",
|
||||
"description": "Schema for models.json, the single source of truth for model metadata (ADR 0003). Enforced in CI by test-features.mjs, which validates models.json against this file using the repo's own validateJsonSchema (lib/structured-output.mjs) — no new dependency. ⚠ ONLY A SUBSET OF DRAFT 2020-12 IS ENFORCED: type, required, const, enum, additionalProperties (boolean and schema forms), items, minItems, maxItems, anyOf/allOf/oneOf, $ref, nullable. Anything else — minimum, maxLength, pattern, uniqueItems, propertyNames, not — is SILENTLY IGNORED by that validator, so adding one here buys nothing and misleads the next reader. Add such a constraint as a test in test-features.mjs instead. NOTE: referential integrity (every aliases/legacyAliases target must exist as a models[].id) is likewise not expressible here and is covered by separate tests.",
|
||||
"type": "object",
|
||||
"required": ["version", "models", "aliases", "legacyAliases"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "Relative path to this file. Editors use it for completion; CI does not read it."
|
||||
},
|
||||
"version": {
|
||||
"const": 1,
|
||||
"description": "Schema version of this document. Bump only alongside a shape change and an ADR."
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Every model OCP advertises on /v1/models, newest first. Order is load-bearing: ocp-connect uses model_ids[0] as a primary fallback.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "displayName", "openclawName", "reasoning", "contextWindow", "maxTokens"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Canonical CLI model id, passed verbatim to `claude --model`. Must match the id in the compiled CLI registry."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"description": "Human label. Also used as the OpenClaw agent alias by scripts/sync-openclaw.mjs."
|
||||
},
|
||||
"openclawName": {
|
||||
"type": "string",
|
||||
"description": "Label written into OpenClaw's model registry."
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether OpenClaw should treat the model as reasoning-capable."
|
||||
},
|
||||
"contextWindow": {
|
||||
"type": "integer",
|
||||
"description": "Advertised context window. GLOBAL side effect: MAX_PROMPT_CHARS derives from max(contextWindow) x 3 across ALL entries (lib/prompt.mjs derivePromptCharBudget), so raising this on ONE model raises the truncation ceiling for EVERY model. See ADR 0009. Also feeds OpenClaw's compaction budget."
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "integer",
|
||||
"description": "Advertised output cap. OCP does not enforce it: buildCliArgs passes no output-token flag to the CLI. It is propagated to OpenClaw (via setup.mjs / scripts/sync-openclaw.mjs) where it bounds REQUEST budgets only. It does NOT affect compaction: both the compaction trigger and the summarisation chunk size derive from contextWindow (minus reserveTokensFloor / softThreshold), never from this field. Verified against OpenClaw 2026.7.1."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"type": "object",
|
||||
"description": "Short name -> canonical models[].id. Client-addressable, so a repoint changes routing for every request using the alias. Cache keys resolve these before hashing (server.mjs cacheModel).",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"legacyAliases": {
|
||||
"type": "object",
|
||||
"description": "Retired ids kept resolvable for backward compatibility -> canonical models[].id. Also client-addressable and also resolved in cache keys.",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-25
@@ -129,25 +129,10 @@ provider = {
|
||||
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
|
||||
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
|
||||
# expose reasoning/maxTokens, so family classification stays here.)
|
||||
# maxTokens values are the floor of the CLI registry's max_output_tokens.default over the
|
||||
# family members CURRENTLY IN models.json (opus 64000; sonnet 32000, because sonnet-4-6 is
|
||||
# 32000 while sonnet-5 is 64000; haiku 32000) — NOT over the whole registry family. The
|
||||
# registry also holds claude-opus-4-0 / -4-5 at 32000, which this opus row would over-advertise
|
||||
# 2x. What keeps that unreachable is NOT "the ids come from models.json" — the ids reaching
|
||||
# get_model_meta have two sources that are not this checkout: /v1/models is fetched from a
|
||||
# user-supplied base_url, so it is the REMOTE server's models.json (possibly a different OCP
|
||||
# version or a fork), and on a JSON parse failure the ids come from the hardcoded list a few
|
||||
# lines above instead. The invariant that actually holds is that no released OCP has ever
|
||||
# served a legacy opus id. Adding one to models.json breaks it and this row must drop to 32000.
|
||||
# Family prefixes cannot distinguish versions, so a floor is used deliberately:
|
||||
# under-advertising caps a client lower than the model allows, which is safe, whereas
|
||||
# over-advertising would promise capacity a member of the family does not have.
|
||||
# models.json is authoritative per ADR 0003; this table only exists because /v1/models does
|
||||
# not expose maxTokens.
|
||||
model_meta = {
|
||||
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 64000},
|
||||
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 32000},
|
||||
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 32000},
|
||||
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
}
|
||||
|
||||
def get_model_meta(mid):
|
||||
@@ -155,13 +140,6 @@ def get_model_meta(mid):
|
||||
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
|
||||
if mid.startswith(prefix):
|
||||
return meta
|
||||
# Unknown id. 8192 is the GLOBAL MINIMUM max_output_tokens.default across all 17 records in
|
||||
# the CLI 2.1.220 registry (distinct values: 8192 / 32000 / 64000). The two records holding
|
||||
# it — claude-3-5-haiku and claude-3-5-sonnet, both default:8192, upper:8192 — are precisely
|
||||
# the ones that reach this line, because "claude-3-5-*" matches none of the family prefixes
|
||||
# above. Raising this to a family-table value would over-advertise those two by 4x. Keep 8192:
|
||||
# under-advertising caps a client lower than a model allows, which is safe; over-advertising
|
||||
# promises capacity that does not exist.
|
||||
return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
|
||||
|
||||
for mid in model_ids:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.26.0",
|
||||
"version": "3.24.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+5
-41
@@ -204,7 +204,7 @@ const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You
|
||||
// default wrapper above is byte-for-byte unchanged. Selecting the positive wrapper does NOT expand
|
||||
// the tool surface (governed independently by --allowedTools/--disallowedTools) — it only changes the
|
||||
// prompt — and is boot-gated below (multi/non-loopback/anon → refuse) mirroring OCP_TUI_FULL_TOOLS.
|
||||
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. Unlike the shared-gateway posture, you may use your available local tools to act on the operator's machine as the task requires. Use only the tools you actually have — do not assume filesystem, shell, or other access beyond the tool set provided to you in this session.`;
|
||||
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. You have full access to the local filesystem, working directory, and shell through your available tools (Bash, Read, Write, Edit, Glob, Grep, etc.). Use them as needed to complete the operator's requests.`;
|
||||
|
||||
// OCP_LOCAL_TOOLS is inert in TUI mode: the interactive (non-`-p`) path composes its own prompt via
|
||||
// callClaudeTui/messagesToPrompt and never calls extractSystemPrompt, so the wrapper is only ever
|
||||
@@ -1371,6 +1371,7 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
||||
promptChars = stdinPayload.length;
|
||||
}
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
stats.oneOffRequests++;
|
||||
if (conversationId) {
|
||||
@@ -1413,17 +1414,6 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||
activeProcesses.add(proc);
|
||||
// Counter drift (#180, reported by @konceptnet): increment ONLY after the spawn has
|
||||
// succeeded and the process is registered. Incrementing before the spawn (as this did) leaked
|
||||
// +1 permanently on any synchronous throw in between — buildCliArgs, env assembly, the spawn
|
||||
// decision, or spawn() itself — because nothing was yet attached that could undo it.
|
||||
//
|
||||
// cleanup() is the SOLE decrement site, but note how it is reached: only 'exit' is wired HERE
|
||||
// (below); 'close' and 'error' are wired by each CALLER (callClaude / callClaudeStreaming).
|
||||
// That caller wiring is REQUIRED, not belt-and-braces — a FAILED spawn emits 'error' and
|
||||
// 'close' but never 'exit', so without it a spawn failure would never decrement. A future
|
||||
// third caller of spawnClaudeProcess must wire them too.
|
||||
stats.activeRequests++;
|
||||
|
||||
const t0 = Date.now();
|
||||
let gotFirstByte = false;
|
||||
@@ -2814,20 +2804,6 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||
const model = parsed.model || modelsConfig.aliases.sonnet;
|
||||
// Cache keys must hash the RESOLVED model, never the string the client happened to use.
|
||||
// `model` is whatever was sent — a canonical id, an alias ("opus"), or a legacyAlias
|
||||
// ("claude-opus-4"). MODEL_MAP carries all three, and models.json is read once at boot, so
|
||||
// repointing an alias only takes effect on restart — while the SQLite response_cache outlives
|
||||
// it. Hashing the raw string would therefore keep serving the OLD model's answers under that
|
||||
// alias until TTL expiry, silently defeating the repoint (the #176 hazard, for aliases).
|
||||
// Resolving first also means "opus" and "claude-opus-5" correctly share one slot: identical
|
||||
// spawn, identical answer. Only the cache KEY is resolved — `model` is still echoed back to
|
||||
// the client verbatim, so the wire response is unchanged.
|
||||
// hasOwn, not a bare lookup: MODEL_MAP is a plain object, so `MODEL_MAP["constructor"]`
|
||||
// would return an inherited FUNCTION. Unreachable today (the VALID_MODELS gate below 400s
|
||||
// first, and it is built from Object.keys so it holds only own keys), but a bare lookup
|
||||
// would hand cacheHash a function the day anyone widens that gate or moves this binding.
|
||||
const cacheModel = Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model;
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Validate model against known models
|
||||
@@ -2923,18 +2899,8 @@ async function handleChatCompletions(req, res) {
|
||||
const t0s = Date.now();
|
||||
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
let structuredHash = null;
|
||||
// DO NOT collapse this with `dedupKey` below (#200). The two cacheHash calls take IDENTICAL
|
||||
// arguments and look like obvious duplicate work — they are not interchangeable, because
|
||||
// their GUARDS differ: this one additionally requires CACHE_TTL > 0. CLAUDE_CACHE_TTL
|
||||
// DEFAULTS TO 0, so in the default configuration structuredHash is null while dedupKey must
|
||||
// still be computed — it drives #153's single-flight stampede protection, which is
|
||||
// deliberately independent of whether response caching is on. `dedupKey = structuredHash`
|
||||
// would therefore silently disable stampede protection by default, in exactly the
|
||||
// concurrent-AI-Task case it exists to bound. The duplicate call is the honest price of the
|
||||
// asymmetry. If you do deduplicate it, compute once under the WEAKER guard and derive the
|
||||
// cache lookup under the stronger one — and add a stampede test before you do.
|
||||
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
||||
structuredHash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH });
|
||||
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
|
||||
try {
|
||||
const cached = getCachedResponse(structuredHash, CACHE_TTL);
|
||||
if (cached) {
|
||||
@@ -2952,10 +2918,8 @@ async function handleChatCompletions(req, res) {
|
||||
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
|
||||
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
|
||||
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
|
||||
// Note the guard here is deliberately WEAKER than structuredHash's — no CACHE_TTL check. See the
|
||||
// do-not-collapse comment above (#200).
|
||||
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
||||
? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH })
|
||||
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
|
||||
: null;
|
||||
const runStructured = async () => {
|
||||
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
|
||||
@@ -3004,7 +2968,7 @@ async function handleChatCompletions(req, res) {
|
||||
} else {
|
||||
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
|
||||
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
|
||||
const hash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
||||
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
||||
req._cacheHash = hash; // store for later write-back
|
||||
try {
|
||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||
|
||||
+34
-467
@@ -910,7 +910,7 @@ test("appendOperatorPrompt: operator value is trimmed before appending", () => {
|
||||
console.log("\nOCP_LOCAL_TOOLS wrapper + safety gate:");
|
||||
|
||||
const NEG = "You do NOT have access to any local filesystem";
|
||||
const POS = "you may use your available local tools";
|
||||
const POS = "You have full access to the local filesystem";
|
||||
|
||||
test("selectPromptWrapper: default (disabled) returns the negative wrapper BYTE-IDENTICAL", () => {
|
||||
// Mutation-proof: flip the ternary and the default path leaks the positive wrapper.
|
||||
@@ -959,8 +959,7 @@ test("localToolsSafetyError: multi is checked before loopback/anon (most severe
|
||||
// a request — and boot-gate refusals are asserted by the process exit code. Without these, the
|
||||
// wiring (extractSystemPrompt using SYSTEM_PROMPT_WRAPPER, the boot gate, the epoch fold) can be
|
||||
// silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
|
||||
import { spawn as _ltSpawn, execFileSync as _ltExecFile } from "node:child_process";
|
||||
import { createServer as _ltNetServer } from "node:net";
|
||||
import { spawn as _ltSpawn } from "node:child_process";
|
||||
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs";
|
||||
import { tmpdir as _ltTmp } from "node:os";
|
||||
import { fileURLToPath as _ltF2P } from "node:url";
|
||||
@@ -968,7 +967,7 @@ import { fileURLToPath as _ltF2P } from "node:url";
|
||||
const LT_SERVER = _ltF2P(new URL("./server.mjs", import.meta.url));
|
||||
const LT_POSIX = process.platform !== "win32"; // fake is a /bin/sh script; CI is POSIX
|
||||
const LT_NEG_MARK = "You do NOT have access to any local filesystem";
|
||||
const LT_POS_MARK = "you may use your available local tools";
|
||||
const LT_POS_MARK = "You have full access to the local filesystem";
|
||||
// Fake claude: record the --system-prompt it was spawned with, bump an optional spawn counter,
|
||||
// then emit a minimal valid stream-json response so the request completes (and caches).
|
||||
const LT_FAKE = `#!/bin/sh
|
||||
@@ -985,76 +984,23 @@ exit 0
|
||||
|
||||
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); }
|
||||
function ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
|
||||
function ltBoot(env, dir, nodeArgs = []) {
|
||||
const child = _ltSpawn(process.execPath, [...nodeArgs, LT_SERVER], {
|
||||
function ltBoot(env, dir) {
|
||||
const child = _ltSpawn(process.execPath, [LT_SERVER], {
|
||||
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1",
|
||||
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const buf = { out: "", err: "", exit: undefined, signal: undefined, closed: false, closeMs: undefined, spawnErr: null, t0: Date.now() };
|
||||
const buf = { out: "", err: "", exit: undefined };
|
||||
child.stdout.on("data", d => { buf.out += d; });
|
||||
child.stderr.on("data", d => { buf.err += d; });
|
||||
// 'exit' fires when the process terminates, but its stdio pipes may still hold unread data —
|
||||
// 'close' is the one that guarantees both are drained. A test that terminates the child and
|
||||
// then asserts on buf.err/buf.out must wait for `closed`, not `exit != null`, or it can read
|
||||
// an empty buffer.
|
||||
child.on("exit", (code, signal) => { buf.exit = code; buf.signal = signal; });
|
||||
child.on("close", () => { buf.closed = true; buf.closeMs = Date.now() - buf.t0; });
|
||||
// Without a listener, a spawn 'error' is re-thrown as an uncaught exception and takes down the
|
||||
// whole runner instead of failing one test.
|
||||
child.on("error", e => { buf.spawnErr = e; });
|
||||
child.on("exit", code => { buf.exit = code; });
|
||||
return { child, buf };
|
||||
}
|
||||
// child.kill("SIGKILL") kills server.mjs but NOT the fake `claude` grandchildren it spawned, and
|
||||
// those can still be writing sp.txt / spawns.txt into `dir` while rmSync walks it — which surfaced
|
||||
// as an intermittent ENOTEMPTY (4/200 in review). Node's own retry loop handles the window.
|
||||
function _ltRmRetry(dir) {
|
||||
try { _ltRm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }
|
||||
catch (e) {
|
||||
// Never throw: this runs in a finally, so a throw here would REPLACE the real assertion
|
||||
// error and make a flake look like a regression. LT_DEBUG surfaces it without that risk.
|
||||
if (process.env.LT_DEBUG) console.warn(` [ltRmRetry] ${dir}: ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
// Every ltBoot assertion failure should be self-diagnosing. The historical failure text was
|
||||
// `expected a local-tools FATAL, got: ` — an empty string, which says nothing about whether the
|
||||
// child never wrote, wrote to the other stream, died on a signal, or was never spawned.
|
||||
// stdout is sampled HEAD+TAIL, not tail-only. The decisive string for "it booted instead of
|
||||
// refusing" is "Local tools: ON", and it lives in the boot banner — a tail-only sample answered
|
||||
// the wrong question for exactly the tests this exists to diagnose.
|
||||
//
|
||||
// The head is sized to the BANNER, not picked round: measured on this tree, the banner runs 1118B
|
||||
// with "Local tools: ON" at byte 581, so 900 clears it with ~5 banner lines of margin. That sizing
|
||||
// is what makes it robust — the banner is emitted first and is bounded, so however much request
|
||||
// noise follows, byte 581 stays in the head. A head of 120 does NOT reach it (verified: the string
|
||||
// landed in the elided middle), which is why this is not the obvious small window.
|
||||
// stderr stays head-only: a fatal is the first thing it writes.
|
||||
function ltHeadTail(s, head = 900, tail = 160) {
|
||||
return s.length <= head + tail ? s : `${s.slice(0, head)}…[${s.length - head - tail}B]…${s.slice(-tail)}`;
|
||||
}
|
||||
function ltDiag(buf) {
|
||||
// closeMs disambiguates "died before reaching the gate" from "ran, then gated" — an exit=1
|
||||
// with empty stderr is equally consistent with both, and they have unrelated root causes.
|
||||
// node= is here because a Node-version-specific stderr warning (22's SQLite ExperimentalWarning)
|
||||
// once masqueraded as "server did not start" for ~23 of 50 runs on a Linux box.
|
||||
const ms = buf.closeMs !== undefined ? `${buf.closeMs}ms` : `${Date.now() - buf.t0}ms(still open)`;
|
||||
return `exit=${buf.exit} signal=${buf.signal} closed=${buf.closed} closeMs=${ms} node=${process.version}` +
|
||||
(buf.spawnErr ? ` spawnErr=${buf.spawnErr.code || buf.spawnErr.message}` : "") +
|
||||
` | stderr(${buf.err.length}B)=${JSON.stringify(buf.err.slice(0, 240))}` +
|
||||
` | stdout(${buf.out.length}B)=${JSON.stringify(ltHeadTail(buf.out))}`;
|
||||
}
|
||||
async function ltWait(cond, ms = 9000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
|
||||
return false;
|
||||
}
|
||||
async function ltFreePort() {
|
||||
const srv = _ltNetServer();
|
||||
await new Promise(r => srv.listen(0, "127.0.0.1", r));
|
||||
const p = srv.address().port;
|
||||
await new Promise(r => srv.close(r));
|
||||
return p;
|
||||
}
|
||||
async function ltPost(port, body) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
@@ -1068,31 +1014,29 @@ console.log("\nOCP_LOCAL_TOOLS integration (boot server.mjs):");
|
||||
test("integration: OCP_LOCAL_TOOLS=1 → the -p spawn receives the POSITIVE wrapper (kills the no-op mutation)", async () => {
|
||||
if (!LT_POSIX) return; // sh fake — skip on Windows CI
|
||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", SP_CAPTURE: cap }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
await ltPost(39321, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
|
||||
const sp = _ltRead(cap, "utf8");
|
||||
assert.ok(sp.includes(LT_POS_MARK), `expected POSITIVE wrapper in --system-prompt, got: ${sp.slice(0,90)}`);
|
||||
assert.ok(!sp.includes(LT_NEG_MARK), "positive wrapper must REPLACE the negative one, not append");
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
|
||||
await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
await ltPost(39322, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
|
||||
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
|
||||
const sp = _ltRead(cap, "utf8");
|
||||
// No system messages + no CLAUDE_SYSTEM_PROMPT → the wrapper is passed verbatim.
|
||||
assert.equal(sp, `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
|
||||
@@ -1104,47 +1048,25 @@ test("integration: boot gate REFUSES each unsafe config (multi / non-loopback /
|
||||
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
|
||||
];
|
||||
try {
|
||||
for (const c of cases) {
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), ...c.env }, dir);
|
||||
for (const [i, c] of cases.entries()) {
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(39330 + i), ...c.env }, dir);
|
||||
try {
|
||||
// Wait for `closed`, not `exit`: the assertion below reads buf.err, and stderr is only
|
||||
// guaranteed drained at 'close'. This is the ordering #203 was filed for.
|
||||
assert.ok(await ltWait(() => buf.closed || buf.spawnErr), `[${c.label}] process never closed — ${ltDiag(buf)}`);
|
||||
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero — ${ltDiag(buf)}`);
|
||||
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL — ${ltDiag(buf)}`);
|
||||
assert.ok(await ltWait(() => buf.exit != null), `[${c.label}] expected the process to exit`);
|
||||
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero`);
|
||||
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL, got: ${buf.err.slice(0,160)}`);
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
}
|
||||
} finally { _ltRmRetry(dir); }
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir); // loopback + none
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none
|
||||
try {
|
||||
// Same race as #199, one line over: "Local tools: ON" (server.mjs:3640) is written 12
|
||||
// console.log calls after "listening on" (:3627) — 10 of them in this env, since the
|
||||
// SYSTEM_PROMPT and MCP_CONFIG lines are conditional and unset here. Gating on the boot
|
||||
// marker and then asserting the announcement can therefore read a buffer holding only the
|
||||
// first chunk. Wait for the line actually under assertion. Measured by review at 8/200
|
||||
// before this change and 0/200 after — it was the suite's top flake.
|
||||
assert.ok(await ltWait(() => buf.out.includes("Local tools: ON") || buf.closed || buf.spawnErr),
|
||||
`startup must announce local tools when active — ${ltDiag(buf)}`);
|
||||
assert.ok(buf.out.includes("Local tools: ON"),
|
||||
`startup must announce local tools when active — ${ltDiag(buf)}`);
|
||||
// Nails ltHeadTail's head budget to the thing it exists to capture. Without this the
|
||||
// coupling is silent: every added banner line pushes "Local tools: ON" later (Models:
|
||||
// alone is ~18B per model), and the day it crosses 900 the diagnostic degrades back to
|
||||
// the exact blind spot this PR fixed — with no test going red. Measured offset here is
|
||||
// 581 of a 1118B banner, so the margin is ~17 more models.
|
||||
const _ltOffset = buf.out.indexOf("Local tools: ON");
|
||||
assert.ok(_ltOffset < 900,
|
||||
`ltHeadTail's head budget (900B) no longer reaches the local-tools announcement — it is ` +
|
||||
`now at byte ${_ltOffset}. The banner grew. Raise the head in ltHeadTail, or ltDiag will ` +
|
||||
`silently stop showing the one line that distinguishes "booted" from "refused".`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `safe config must boot, got: ${buf.err.slice(0,200)}`);
|
||||
assert.ok(buf.out.includes("Local tools: ON"), "startup must announce local tools when active");
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
|
||||
@@ -1152,22 +1074,12 @@ test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not ref
|
||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||
// Non-loopback would normally trip the local-tools gate; under TUI the flag is inert so the
|
||||
// gate must NOT fire on its behalf. Use loopback here to isolate TUI's own guards from ours.
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir);
|
||||
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir);
|
||||
try {
|
||||
// Wait for the line actually under assertion, not for a proxy signal. "listening on" and the
|
||||
// inert-flag warning are written independently, so gating on the former and then asserting
|
||||
// the latter is a race — the flake #199 was filed for. Still bounded by the same timeout, and
|
||||
// the boot markers are kept in the predicate so a failed boot ends the wait immediately
|
||||
// rather than burning it.
|
||||
const ready = await ltWait(() => /ignored in TUI mode/.test(buf.out + buf.err)
|
||||
|| buf.closed || buf.spawnErr);
|
||||
assert.ok(ready, `no inert-flag warning appeared — ${ltDiag(buf)}`);
|
||||
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err),
|
||||
`must warn that OCP_LOCAL_TOOLS is inert under TUI — ${ltDiag(buf)}`);
|
||||
assert.ok(!buf.out.includes("Local tools: ON"),
|
||||
`must NOT claim local tools are ON in TUI mode (the wrapper is unused there) — ${ltDiag(buf)}`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `did not start: ${buf.err.slice(0,200)}`);
|
||||
assert.ok(!buf.out.includes("Local tools: ON"), "must NOT claim local tools are ON in TUI mode (the wrapper is unused there)");
|
||||
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err), "must warn that OCP_LOCAL_TOOLS is inert under TUI");
|
||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => {
|
||||
@@ -1185,196 +1097,11 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
};
|
||||
try {
|
||||
const off = await bootOnce({}, await ltFreePort()); // caches "OK" under epoch(negative)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch(positive) → must MISS → re-spawn
|
||||
const off = await bootOnce({}, 39350); // caches "OK" under epoch(negative)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39351); // same DB, epoch(positive) → must MISS → re-spawn
|
||||
assert.equal(off, 1, "first request (cache empty) must spawn claude");
|
||||
assert.equal(on, 1, "after toggling the flag the identical request must NOT be served from the old cache (epoch differs → re-spawn)");
|
||||
} finally { _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
|
||||
// The counter used to be incremented ~40 lines before the spawn, while its only decrement
|
||||
// (cleanup()) is wired to that proc's events — so any SYNCHRONOUS throw in between leaked +1
|
||||
// permanently. Driving that fault needs no production hook and no test double: buildCliArgs
|
||||
// does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a spread of enough elements throws
|
||||
// RangeError synchronously, right inside the window.
|
||||
//
|
||||
// Getting there on Linux needs one more turn of the screw. The spread's cost is per ELEMENT,
|
||||
// so the naive form needs ~124k elements ≈ 250KB in one env var — and Linux caps a single env
|
||||
// string at MAX_ARG_STRLEN (32 * PAGE_SIZE = 131072 on x86-64), so execve rejects it (E2BIG).
|
||||
// Encoding around it fails too: empty items are 1 byte each, but `.filter(Boolean)`
|
||||
// (server.mjs:355) strips them, so ALLOWED_TOOLS ends up empty and the spread branch is never
|
||||
// entered at all.
|
||||
//
|
||||
// The lever is the stack: the throw threshold scales with it, and ltBoot spawns the server, so
|
||||
// the test owns its argv. Running the child under --stack-size=200 drops the threshold ~5x
|
||||
// (~24k elements ≈ 48KB), which fits Linux's limit with room to spare.
|
||||
//
|
||||
// The threshold is DISCOVERED, in a child under the SAME --stack-size (measuring it in this
|
||||
// process would report the parent's stack, which is not the one that matters), then taken with
|
||||
// 1.5x margin and hard-asserted under MAX_ARG_STRLEN. A hard-coded count would silently stop
|
||||
// triggering on another machine and the test would pass vacuously.
|
||||
const LT_STACK = 200; // child V8 stack (KB); lowers the spread-throw threshold
|
||||
const LT_MAX_ARG_STRLEN = 131072; // Linux, x86-64: 32 * 4096
|
||||
function ltSpreadThrowCount(stackKb) {
|
||||
// Binary-search the smallest element count whose spread throws, inside a child running with
|
||||
// the stack the server will actually use.
|
||||
const src = `const t=n=>{try{const a=[];a.push("--allowedTools",...Array(n).fill("x"));return false}catch{return true}};` +
|
||||
`let lo=500,hi=400000;if(!t(hi)){console.log(0)}else{while(lo<hi){const m=(lo+hi)>>1;t(m)?hi=m:lo=m+1}console.log(lo)}`;
|
||||
try {
|
||||
return Number(String(_ltExecFile(process.execPath, [`--stack-size=${stackKb}`, "-e", src], { encoding: "utf8" })).trim()) || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
async function ltPostStatus(port, body) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, text: await r.text() };
|
||||
} catch { return { status: 0, text: "" }; }
|
||||
}
|
||||
|
||||
console.log("\nactive-request counter pairing (#180 / #193):");
|
||||
|
||||
test("integration: a synchronous pre-spawn throw must not leak stats.activeRequests", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const thr = ltSpreadThrowCount(LT_STACK);
|
||||
assert.ok(thr > 0, `no spread-throw threshold found under --stack-size=${LT_STACK}`);
|
||||
const n = Math.ceil(thr * 1.5); // margin over the measured threshold
|
||||
const entry = Array(n).fill("x").join(",");
|
||||
const bytes = Buffer.byteLength(entry);
|
||||
// Hard gate: if this ever stops fitting, fail loudly rather than regress to an E2BIG skip.
|
||||
assert.ok(bytes <= LT_MAX_ARG_STRLEN,
|
||||
`env entry ${bytes}B exceeds MAX_ARG_STRLEN ${LT_MAX_ARG_STRLEN}B — lower LT_STACK`);
|
||||
const port = await ltFreePort();
|
||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
||||
const { child, buf } = ltBoot({
|
||||
CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_ALLOWED_TOOLS: entry,
|
||||
}, dir, [`--stack-size=${LT_STACK}`]);
|
||||
let spawnErr = null;
|
||||
child.on("error", e => { spawnErr = e; });
|
||||
try {
|
||||
const up = await ltWait(() => buf.out.includes("listening on") || spawnErr, 20000);
|
||||
assert.ok(up && !spawnErr, `did not start: ${spawnErr ? spawnErr.message : buf.err.slice(0, 300)}`);
|
||||
const req = { model: "haiku", messages: [{ role: "user", content: "leak-probe" }] };
|
||||
const res = [];
|
||||
for (let i = 0; i < 3; i++) res.push(await ltPostStatus(port, req));
|
||||
// Non-vacuous on two axes: the requests must actually fail, AND the failure must be the
|
||||
// stack overflow from the --allowedTools spread — not some unrelated 500 that a small
|
||||
// stack happened to produce. Without the second check a different fault would still leave
|
||||
// the counter at 0 and the test would "pass" for the wrong reason.
|
||||
assert.deepEqual(res.map(r => r.status), [500, 500, 500],
|
||||
`expected the pre-spawn throw to surface as 500s, got ${res.map(r => r.status)}`);
|
||||
assert.ok(res.every(r => /call stack size exceeded/i.test(r.text)),
|
||||
`500s must come from the spread's RangeError; got: ${res[0].text.slice(0, 200)}`);
|
||||
const r = await fetch(`http://127.0.0.1:${port}/status`);
|
||||
const active = (await r.json()).requests.active;
|
||||
assert.equal(active, 0,
|
||||
`3 requests threw before their spawn; the counter must be back to 0, got ${active} (this is the #180 leak)`);
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
|
||||
// models.json is read once at boot, so repointing an alias only takes effect on restart —
|
||||
// while the SQLite response_cache outlives it. Hashing the raw string would keep serving the
|
||||
// OLD model's answers under that alias until TTL expiry. Rather than mutate models.json
|
||||
// mid-suite, these assert the equivalent observable: an alias and its canonical target must
|
||||
// land on the SAME cache slot, which is true only if the key is resolved before hashing.
|
||||
// Mutation: change `cacheModel` back to `model` at the three cacheHash call sites in
|
||||
// server.mjs and both tests go red (2 spawns instead of 1).
|
||||
|
||||
// Fake that emits schema-valid JSON, so the structured path caches a VALIDATED result
|
||||
// (the stock LT_FAKE returns "OK", which fails validation → refusal → never cached).
|
||||
const LT_FAKE_JSON = `#!/bin/sh
|
||||
if [ -n "$SP_COUNTER" ]; then c=$(cat "$SP_COUNTER" 2>/dev/null || echo 0); echo $((c+1)) > "$SP_COUNTER"; fi
|
||||
printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"{\\"ok\\":true}"}]}}'
|
||||
printf '%s\\n' '{"type":"result"}'
|
||||
exit 0
|
||||
`;
|
||||
function ltFakeJson(dir) { const p = join(dir, "claude-json"); _ltWrite(p, LT_FAKE_JSON); _ltChmod(p, 0o755); return p; }
|
||||
const LT_SCHEMA = { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false };
|
||||
|
||||
console.log("\nCache key resolves the model alias (#194):");
|
||||
|
||||
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
|
||||
await ltPost(port, { model: "sonnet", messages: msgs }); // miss → spawn
|
||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
||||
await ltPost(port, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
||||
"the canonical id must hit the slot the alias populated — a 2nd spawn means the key still hashes the raw alias");
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: an alias and its canonical target share ONE cache slot (STRUCTURED path)", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
||||
const msgs = [{ role: "user", content: "structured-alias-probe" }];
|
||||
await ltPost(port, { model: "sonnet", messages: msgs, response_format: rf });
|
||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
||||
await ltPost(port, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
||||
"structured cache key must resolve the alias too — this is the path the epoch-only fix missed");
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
|
||||
// The three tests above all use `sonnet` (a plain alias); this pins the legacyAlias leg explicitly
|
||||
// rather than leaving it covered only by construction.
|
||||
test("integration: a legacyAlias shares ONE cache slot with its canonical target", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
||||
const port = await ltFreePort();
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
|
||||
await ltPost(port, { model: "claude-haiku-4-5", messages: msgs }); // legacyAlias
|
||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
||||
await ltPost(port, { model: "claude-haiku-4-5-20251001", messages: msgs }); // canonical
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
||||
"legacyAliases live in MODEL_MAP too — resolving must collapse them onto the canonical slot");
|
||||
} finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
|
||||
});
|
||||
|
||||
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
|
||||
if (!LT_POSIX) return;
|
||||
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
||||
const req = { model: "sonnet", messages: [{ role: "user", content: "structured-epoch-probe" }], response_format: rf };
|
||||
const bootOnce = async (env, port) => {
|
||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter, ...env }, dir);
|
||||
try {
|
||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
||||
_ltWrite(counter, "0");
|
||||
await ltPost(port, req);
|
||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
||||
return Number(_ltRead(counter, "utf8")) || 0;
|
||||
} finally { child.kill("SIGKILL"); }
|
||||
};
|
||||
try {
|
||||
const off = await bootOnce({}, await ltFreePort()); // caches under epoch(negative wrapper)
|
||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch differs → must re-spawn
|
||||
assert.equal(off, 1, "first structured request (cache empty) must spawn claude");
|
||||
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
|
||||
} finally { _ltRmRetry(dir); }
|
||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ── Upgrade Tests ──
|
||||
@@ -2482,32 +2209,10 @@ console.log("\nTUI command construction (proxy-purity / #4):");
|
||||
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||
// Primary mechanism is --safe-mode (env vars alone stopped suppressing on newer claude);
|
||||
// the env vars remain as belt-and-braces.
|
||||
assert.ok(/(^| )--safe-mode( |$)/.test(cmd), "default pane must pass --safe-mode (disables host CLAUDE.md/skills/plugins/hooks)");
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||
});
|
||||
|
||||
test("buildTuiCmd omits --safe-mode when a customization it would strip is in use", () => {
|
||||
const save = process.env.OCP_TUI_FULL_TOOLS;
|
||||
try {
|
||||
delete process.env.OCP_TUI_FULL_TOOLS;
|
||||
// streaming registers a MessageDisplay HOOK via --settings; --safe-mode would kill the hook
|
||||
// (zero deltas), so it must be omitted on the streaming pane.
|
||||
const streaming = buildTuiCmd("/usr/bin/claude", "m", "sid-s", "/home/u", "cli", { file: "/d/sid-s.jsonl", settings: "/d/s.json" });
|
||||
assert.ok(!/--safe-mode/.test(streaming), "streaming pane must NOT pass --safe-mode (would disable the MessageDisplay hook)");
|
||||
assert.ok(streaming.includes("--settings '/d/s.json'"), "streaming pane keeps its --settings hook");
|
||||
|
||||
// OCP_TUI_FULL_TOOLS grants an MCP/skills surface --safe-mode disables wholesale.
|
||||
process.env.OCP_TUI_FULL_TOOLS = "1";
|
||||
const full = buildTuiCmd("/usr/bin/claude", "m", "sid-f", "/home/u", "cli");
|
||||
assert.ok(!/--safe-mode/.test(full), "full-tools pane must NOT pass --safe-mode (would disable MCP/skills)");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.OCP_TUI_FULL_TOOLS; else process.env.OCP_TUI_FULL_TOOLS = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||
@@ -3799,7 +3504,7 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
|
||||
// Keep in sync with the definitions there.
|
||||
function _tuiInputReady(pane) {
|
||||
return /\? for shortcuts|shift\+tab to cycle/.test(pane);
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
function _tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
@@ -3817,12 +3522,7 @@ const TUI_READY_PANE = `❯ Try "how does <filepath> work?"
|
||||
const TUI_LANDED_PANE = `❯ Reply with exactly: PONG_TEST
|
||||
? for shortcuts · ← for agents`;
|
||||
|
||||
// Newer claude 2.1.x renders the input bar with a `shift+tab to cycle` footer instead of
|
||||
// `? for shortcuts` — the matcher must accept it too, or the pane reads as never-ready.
|
||||
const TUI_READY_PANE_SHIFT_TAB = `❯ Try "how does <filepath> work?"
|
||||
⏵⏵ bypass permissions on (shift+tab to cycle)`;
|
||||
|
||||
// Welcome splash shown before input bar is rendered — neither ready-state footer.
|
||||
// Welcome splash shown before input bar is rendered — no `? for shortcuts`.
|
||||
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`;
|
||||
|
||||
console.log("\nTUI readiness + paste-verify predicates (issue #130):");
|
||||
@@ -3833,9 +3533,6 @@ test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
|
||||
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
|
||||
});
|
||||
test("tuiInputReady(READY_PANE_SHIFT_TAB) === true (newer claude `shift+tab to cycle` footer)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_READY_PANE_SHIFT_TAB), true);
|
||||
});
|
||||
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
|
||||
});
|
||||
@@ -4340,10 +4037,6 @@ test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SP
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
|
||||
});
|
||||
|
||||
test("models.json aliases.opus === 'claude-opus-5' (opus-alias SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.opus, "claude-opus-5");
|
||||
});
|
||||
|
||||
// ── Referential integrity (PR #152 review) ──────────────────────────────────
|
||||
// The value-mirror assertions above only prove the alias equals a string literal —
|
||||
// they pass even if that literal points at a model that does not exist in
|
||||
@@ -4357,140 +4050,18 @@ test("models.json: claude-sonnet-5 is present in models[] (the entry this PR add
|
||||
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
|
||||
});
|
||||
|
||||
test("models.json: claude-opus-5 is present in models[] (the entry this PR adds)", () => {
|
||||
assert.ok(_spotModelIds.has("claude-opus-5"), "claude-opus-5 must exist as a models[].id");
|
||||
});
|
||||
|
||||
// The prompt-char budget is GLOBAL (max across every entry × 3 chars/token), not
|
||||
// per-model — see lib/prompt.mjs derivePromptCharBudget. An entry declaring a native 1M
|
||||
// window would therefore raise the truncation ceiling for claude-haiku-4-5 too (genuinely
|
||||
// 200k), turning OCP-side truncation into an upstream API rejection.
|
||||
//
|
||||
// Asserts the MAX, deliberately, not every entry: ADR 0009 states the budget "scales
|
||||
// automatically — no code change", so a future entry with a SMALLER window (say a 128k
|
||||
// model) must stay legal and must not fail this suite. Only raising the ceiling is the
|
||||
// hazard, and that is an ADR-level decision requiring per-model budgets first.
|
||||
test("models.json: max contextWindow is 200000 (global prompt-budget ceiling)", () => {
|
||||
const windows = _spotModels.models.map(m => m.contextWindow);
|
||||
assert.equal(Math.max(...windows), 200000,
|
||||
`max contextWindow re-scales MAX_PROMPT_CHARS for ALL models incl. the 200k-native haiku (see lib/prompt.mjs + ADR 0009)`);
|
||||
});
|
||||
|
||||
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.aliases)) {
|
||||
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
});
|
||||
|
||||
// maxTokens is ADVERTISED metadata, not an OCP-enforced limit (#195). OCP never reads it —
|
||||
// buildCliArgs passes no output-token flag to the CLI — and OpenClaw reaches a local OCP over
|
||||
// `openai-completions`, whose request field (max_completion_tokens) appears nowhere in this repo.
|
||||
// It is consumed only by clients that choose to honour it, via setup.mjs / sync-openclaw.mjs /
|
||||
// ocp-connect. So the invariant worth testing is simply that models.json tells the truth: each
|
||||
// value must equal the model's max_output_tokens.default in the CLI registry.
|
||||
//
|
||||
// Pinned per model deliberately. A threshold assertion would let every entry sit at some arbitrary
|
||||
// value above the bar and still call itself "registry-aligned" — which is the actual claim. Adding
|
||||
// a model means adding a row here, and that is the point: the row is where you record what the
|
||||
// registry said when you checked.
|
||||
// Keys are models.json ids; values are the CLI 2.1.220 registry's max_output_tokens.default,
|
||||
// each extracted id-anchored (grep 'id:"<id>"' + the following bytes) — never by bare-string
|
||||
// search, which matches cross-references inside OTHER models' records and silently attributes
|
||||
// the wrong number. ONE KEY IS NOT A REGISTRY ID: models.json carries the dated haiku id, but
|
||||
// the registry record is id:"claude-haiku-4-5" (the dated string appears only as that record's
|
||||
// provider_ids.first_party). Anchor the haiku row on the SHORT id; anchoring on the dated one
|
||||
// returns nothing, which is what tempts the next reader back into a bare-string search.
|
||||
const _spotRegistryMaxTokens = {
|
||||
"claude-opus-5": 64000, "claude-opus-4-8": 64000, "claude-opus-4-7": 64000, "claude-opus-4-6": 64000,
|
||||
"claude-sonnet-5": 64000, "claude-sonnet-4-6": 32000,
|
||||
"claude-haiku-4-5-20251001": 32000, // registry id: claude-haiku-4-5
|
||||
};
|
||||
test("models.json: every maxTokens equals the CLI registry's max_output_tokens.default (#195)", () => {
|
||||
for (const m of _spotModels.models) {
|
||||
const want = _spotRegistryMaxTokens[m.id];
|
||||
assert.ok(want !== undefined,
|
||||
`${m.id} has no recorded registry value — extract it id-anchored from the CLI binary and add a row`);
|
||||
assert.equal(m.maxTokens, want, `${m.id}: models.json says ${m.maxTokens}, CLI registry says ${want}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
|
||||
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── models.json validates against models.schema.json (#196) ─────────────────
|
||||
// models.json carried `"$schema": "./models.schema.json"` while that file had never been
|
||||
// committed, so the SPOT that ADR 0003 makes canonical had no structural validation at all —
|
||||
// a missing contextWindow or a typo'd openclawName would only surface downstream, in OpenClaw
|
||||
// or in a truncation budget. Validated with the repo's OWN validator (lib/structured-output.mjs,
|
||||
// shipped for #153) rather than a new dependency: zero deps added, and it exercises that
|
||||
// validator on a second real input.
|
||||
//
|
||||
// The schema deliberately does NOT try to express referential integrity (alias -> models[].id);
|
||||
// that is not a JSON Schema concept and is covered by the two tests directly above.
|
||||
import { validateJsonSchema as _spotValidate } from "./lib/structured-output.mjs";
|
||||
|
||||
const _spotSchema = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.schema.json"), "utf8"));
|
||||
|
||||
test("models.json: the $schema reference resolves to a committed file", () => {
|
||||
assert.equal(_spotModels.$schema, "./models.schema.json", "models.json must point at the schema");
|
||||
assert.ok(_ltExists(spotJoin(_spotDir, "models.schema.json")),
|
||||
"models.schema.json must exist — a dangling $schema is what #196 was filed for");
|
||||
});
|
||||
|
||||
test("models.json validates against models.schema.json (strict)", () => {
|
||||
const errors = _spotValidate(_spotModels, _spotSchema, "$", true);
|
||||
assert.deepEqual(errors, [], `models.json violates its own schema:\n ${errors.join("\n ")}`);
|
||||
});
|
||||
|
||||
// Three corruptions the SCHEMA structurally cannot catch, asserted directly instead of pretending
|
||||
// the schema covers them: the validator has no uniqueItems, no minLength, and no minimum. Adding
|
||||
// those keywords to the schema would be silently ignored (see its description), so they live here.
|
||||
test("models.json: ids/names are unique, untrimmed-free, and windows are positive (not schema-expressible)", () => {
|
||||
// Uniqueness applies to all three name fields, not just id. scripts/sync-openclaw.mjs maps
|
||||
// `claude-local/<id>` -> { alias: displayName } and writes openclawName as the registry label,
|
||||
// so a duplicate in EITHER collapses two models onto one OpenClaw entry — the same defect class
|
||||
// as a duplicate id, which is why review flagged covering only id as a half-fix.
|
||||
for (const f of ["id", "displayName", "openclawName"]) {
|
||||
const vals = _spotModels.models.map(m => m[f]);
|
||||
const dupes = vals.filter((v, i) => vals.indexOf(v) !== i);
|
||||
assert.equal(new Set(vals).size, vals.length, `duplicate models[].${f}: ${[...new Set(dupes)]}`);
|
||||
}
|
||||
for (const m of _spotModels.models) {
|
||||
for (const f of ["id", "displayName", "openclawName"]) {
|
||||
assert.ok(typeof m[f] === "string" && m[f].length > 0, `${m.id}: ${f} must be a non-empty string`);
|
||||
// === trim(), not trim().length: a padded id passes a trimmed check but is handed VERBATIM
|
||||
// to `claude --model`, so " claude-opus-5" would fail upstream rather than here.
|
||||
assert.equal(m[f], m[f].trim(), `${m.id}: ${f} has leading/trailing whitespace`);
|
||||
}
|
||||
assert.ok(m.contextWindow > 0, `${m.id}: contextWindow must be positive`);
|
||||
assert.ok(m.maxTokens > 0, `${m.id}: maxTokens must be positive`);
|
||||
}
|
||||
});
|
||||
|
||||
// Guards the guard: if the schema were vacuous (e.g. `{}` or a typo'd `properties`), the test
|
||||
// above would pass on anything. Each corruption below must be caught.
|
||||
test("models.schema.json actually rejects malformed entries (guard is not vacuous)", () => {
|
||||
const clone = () => JSON.parse(JSON.stringify(_spotModels));
|
||||
const cases = [
|
||||
["missing required field", m => { delete m.models[0].contextWindow; }],
|
||||
["wrong scalar type", m => { m.models[0].reasoning = "yes"; }],
|
||||
["non-integer window", m => { m.models[0].contextWindow = 200000.5; }],
|
||||
["unknown extra field", m => { m.models[0].tokensPerSecond = 42; }],
|
||||
["alias mapped to non-string", m => { m.aliases.opus = { id: "x" }; }],
|
||||
["empty models array", m => { m.models = []; }],
|
||||
["wrong document version", m => { m.version = 2; }],
|
||||
["unknown top-level key", m => { m.providers = {}; }],
|
||||
];
|
||||
for (const [label, corrupt] of cases) {
|
||||
const bad = clone(); corrupt(bad);
|
||||
const errs = _spotValidate(bad, _spotSchema, "$", true);
|
||||
assert.ok(errs.length > 0, `schema failed to reject: ${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
@@ -4952,17 +4523,13 @@ test("stream: sink path is keyed by session_id (concurrent panes cannot interlea
|
||||
assert.ok(A.endsWith("/aaaa-1111.jsonl"));
|
||||
});
|
||||
|
||||
test("stream: buildTuiCmd — streaming ON adds env + --settings and drops --safe-mode (hook survives)", () => {
|
||||
test("stream: buildTuiCmd — OFF is byte-for-byte the pre-streaming argv; ON adds only env + --settings", () => {
|
||||
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
|
||||
assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
|
||||
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
|
||||
assert.ok(off.includes("--safe-mode"), "the non-streaming pane carries --safe-mode");
|
||||
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
|
||||
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
|
||||
assert.ok(on.includes("--settings '/d/s.json'"));
|
||||
// --safe-mode would disable the MessageDisplay hook registered by --settings, so the
|
||||
// streaming pane must NOT carry it (it keeps the env-var suppression instead).
|
||||
assert.ok(!on.includes("--safe-mode"), "streaming pane omits --safe-mode so the hook fires");
|
||||
// must not regress the MCP wall or the pinned effort (#156)
|
||||
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
|
||||
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");
|
||||
|
||||
Reference in New Issue
Block a user