Compare commits

...
Author SHA1 Message Date
a17b6c0ad2 chore(release): v3.26.0 (#212)
* chore(release): v3.26.0 — maxTokens tells the truth, SPOT schema, release-job fix, ltBoot harness

Version 3.25.0 -> 3.26.0 (package.json; server.mjs reads VERSION from it, so /health and
/v1/models follow automatically). CHANGELOG's Unreleased section becomes v3.26.0.

Contents — five merged PRs, each with an independent fresh-context reviewer:

  #208  maxTokens aligned to the CLI registry (#195)      <- the only fleet-visible change
  #205  models.schema.json + CI validation (#196)
  #206  release.yml no-CHANGELOG path fix (#202)
  #204  ltBoot harness hardening + diagnostics (#199, #209)
  #207  cache-key guard comments (#200), AGENTS.md harness docs (#197)

The only user-visible change is advertised metadata: OpenClaw and other clients that read
maxTokens will see 64000/32000 instead of a uniform 16384. OCP's behavior is unchanged --
buildCliArgs passes no output-token flag, and max_completion_tokens appears nowhere in this
repo. No new endpoint, env var, CLI subcommand, or cli.js wire behavior. server.mjs is
untouched across the whole release, so ALIGNMENT.md requires no cli.js citation.

release_kit (Iron Rule 5.5) walked:
  - version_source package.json          bumped
  - changelog CHANGELOG.md               v3.26.0 section written
  - new file / SPOT / schema             models.schema.json documented in README
                                         (Available Models) and AGENTS.md (Key files)
  - Available Models table               no change needed; it carries no maxTokens column
  - Environment Variables / Endpoints    no additions this release
  - bootstrap_quirk_policy               no new one-time migration quirk

Remaining v3.25.0 mentions in README/docs are historical (describing what changed in that
release) and are correct as written; verified rather than bulk-replaced.

Suite: 462 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* docs(release): correct a false claim in the v3.26.0 notes and disclose three gaps

Release review found four problems, all in the CHANGELOG text — which for a release PR
IS the deliverable, since release.yml copies it verbatim into the GitHub Release body.

1. "instead of a uniform 16384" was FALSE.

    git show v3.25.0:models.json  ->  six entries 16384, claude-haiku-4-5-20251001 8192
    distinct prior values: [8192, 16384]     uniform? False

Issue #195's own title says "for every Opus/Sonnet entry" — it never claimed haiku. The
framing also understated haiku, which moved from a lower base. Corrected, and stated: every
model except claude-sonnet-4-6 (1.95x) moves by the same 3.91x, haiku included.

(Review called haiku "the largest change in the release". Verified: it is a TIE at 3.91x,
not the largest alone — 16384->64000 is the same multiplier. The entry says so.)

2. models.schema.json contradicted this release's own notes.

It shipped in this release (#205) saying maxTokens "bounds request and compaction budgets".
The CHANGELOG correctly says maxTokens does not affect behavior. The schema's compaction
half is wrong: in OpenClaw 2026.7.1 both the compaction trigger and the summarisation chunk
size derive from contextWindow (minus reserveTokensFloor / softThreshold), never from
maxTokens. Fixed the schema rather than the CHANGELOG, since the CHANGELOG was right.

This makes the release PR touch one non-version file. Deliberate: shipping two canonical
documents that contradict each other is worse than a slightly wider release diff, and the
schema is published in this same release.

3. #203 was omitted, and the ltBoot entry read as "harness flakiness fixed".

42/200 -> 200/200 is true and #203 is still OPEN — with four sightings, one of them on #205
INSIDE this release. The entry made exactly this disclosure for ocp-connect (#210) and not
for the bug the harness was built to chase. Now states it is not fixed, and points at #211.

4. contextWindow is wrong by the same registry this release cites.

    claude-opus-5 / -4-8 / -4-7 / claude-sonnet-5   registry window:1e6   models.json 200000

Publishing "the SPOT tells the truth" with a 5x understatement in the adjacent field is the
kind of thing that ages badly. Unlike maxTokens it is NOT a safe metadata fix —
derivePromptCharBudget takes max(contextWindow) x 3 across ALL entries, so one 1M model
takes MAX_PROMPT_CHARS from 600k to 3M for every model including a genuinely-200k haiku.
Filed as #213 (needs per-model budgets, ADR-level) and disclosed in the notes.

Also: `claude-haiku-4-5` appeared where the models.json id is `claude-haiku-4-5-20251001` —
the exact legacyAlias-vs-id confusion #208's test file exists to prevent. All three
occurrences now use the full id.

Verified: models.schema.json still parses; suite 462 passed, 0 failed; release.yml's awk
extraction on the corrected file yields 6348 bytes, correct section, terminating at v3.25.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* docs(release): state the correction as fact, not as edit history

Review's style point, taken. The v3.26.0 section has never been published — no tag
exists — so release.yml's copy into the GitHub Release body is the FIRST time any
reader sees this text. "as an earlier draft of this entry claimed" corrects a claim
they never saw; it is internal editing history leaking into a public artifact. The
correction belongs in the commit record, where it already is in full.

Also split the 3.91x ratio into its own sentence — as an appositive it had to reach
back across an interrupting clause to find what it modified.

No factual change. Suite: 462 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:28:18 +10:00
92787dcced ci: manual flake hunt for #203 (Linux × Node version) (#211)
* ci: add a manual flake hunt for #203 (Linux x Node version)

#203 has been seen four times, always on Linux CI, never once on macOS across 1000+
runs in two independent experiments. The documented next step is a Linux + Node 24
reproduction, and there is currently no way to run one without reddening an unrelated
PR and waiting for chance.

workflow_dispatch only — it never runs on push or pull_request, so it costs nothing
until someone asks for it.

Inputs are the two suspected variables:

- `node` is a choice (24 / 22 / 26) rather than pinned, because 22-vs-24 is a
  comparison worth running deliberately. A previous Linux VM attempt was invalidated
  by Node 22's `node:sqlite` ExperimentalWarning landing on stderr, which the boot gate
  read as "server did not start" — ~23 of 50 runs failed spuriously. The workflow says
  so, so the next person interprets a 22 result against that confound instead of
  rediscovering it.
- `concurrency` is the knob that actually reproduces, not round count: test() is
  fire-and-forget for async bodies, so ONE suite run already spawns 15 concurrent
  server.mjs children across 11 ltBoot tests. Several suites at once is what multiplies
  cross-process contention.

Classification is anchored on the failure marker AND the test name, which is not
cosmetic. ltDiag samples the first 900B of child stdout (#204) — the boot banner — so a
log where only the GATE test failed still contains "Local tools: ON". Validated against
real logs rather than reasoned about: 3 clean runs + 1 gate-mutation run gave

    unanchored 'Local tools'  -> 1 hit, attributed to the WRONG category
    anchored                  -> gate=1, announce=0        (correct)
    totals                    -> total=4 clean=3           (correct)

My first draft had the unanchored form with a comment claiming the failure mode was
"matches every log". That was wrong — the real defect is cross-category attribution,
caused by the diagnostic quoting the child's own output. Found by running the logic on
real logs; the comment now states the measured reason.

The job does NOT fail on a reproduction: catching the flake is the goal, and a red X
would read as "the hunt is broken" rather than "the flake was caught". Results go to
the step summary; logs upload as an artifact.

server.mjs: unchanged (CI-only; ALIGNMENT.md requires no cli.js citation).
Verified: YAML parses, all three run blocks pass `bash -n`, classify logic exercised
against real suite output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* ci: fix the hunt's fatal shell bug and retract a wrong root-cause story

Review found two HIGH defects. Both confirmed independently before fixing.

1. The Classify step would have failed on EVERY run and produced no summary.

I wrote `set -uo pipefail  # NOT -e`. GitHub's default shell is `bash -e {0}`, and
that line does not turn errexit OFF — it only ADDS pipefail. So any category counting
zero makes grep exit 1, pipefail propagates it through the pipeline, the command
substitution inherits it, and errexit kills the step. The all-clean case — the result
this workflow most wants to report — dies on the FIRST counter.

    bash -e -c 'set -uo pipefail; x=$(echo hi | grep -c nomatch); echo REACHED'
      -> exit=1, "REACHED" never printed
    bash -e -c 'set +e -u -o pipefail; ...; echo REACHED'
      -> REACHED, x=0

Now `set +e -u -o pipefail`, with the reason in the file so nobody "tidies" it back.
Verified by EXECUTING the extracted step under `bash -e` against real logs:
exit=0, 2836 bytes of summary. My stated verification was `bash -n`, which is a pure
syntax check and structurally cannot catch this. And because workflow_dispatch requires
the file on the default branch, the workflow could not have been run end-to-end before
merge — so nothing else would have caught it either.

2. The Node 22 story was a misattribution, and I had propagated it four places.

I claimed Node 22's `node:sqlite` ExperimentalWarning was read by the boot gate as
"server did not start", invalidating an earlier Linux run. The warning is real; the
causal claim is false. The predicate is

    ltWait(() => 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 it. Review also ran the full suite on Node 22.23.1: 462
passed, 0 failed, with the warning present in the logs.

What actually produced that noise floor is something I had already measured and then
failed to connect: pre-#204 fixed ports gave 246 EADDRINUSE and only 42/200 clean runs
on unmodified main. The warning was merely VISIBLE in the failure text — via
buf.err.slice(0,200) — and I read presence in the error message as causation. That is
the same error I have been correcting in others' findings all week.

The cost was not cosmetic: the input description told the next person that Node 22 was
confounded, which would have made them discard a perfectly usable arm. Node 22 is now
offered plainly, and the file states the correction so the wrong story does not survive
in the artifact that outlives this PR.

Also from review:

- `ref` input (MED-1): a null result on current main is uninterpretable, because #204
  may already have fixed #203. Hunting `7f15921^` is the positive control.
- inputs go through `env:` (MED-2): free-text inputs were interpolated straight into the
  script body. GitHub documents `inputs.*` as untrusted. Added `type: number`.
- #203's SIGNATURE, not its test name (MED-3): a CPU-starved runner blows the 9s ltWait
  and emits the same "✗ boot gate REFUSES" line. #203 is closed=true + non-zero exit +
  EMPTY stderr. Both counters are reported; the difference is contention. Demonstrated
  live — a gate-mutation run scored gate=1, sig=0.
- full histogram instead of a hand-maintained category table: an enumerated list silently
  drops the failure nobody thought of, which on a hunt is the interesting one. Bucketed on
  a 72-char name prefix, NOT `sed 's/:.*//'` as suggested — test names contain colons, so
  that collapses every `localToolsSafetyError: <case>` into one bucket (verified).
- per-run `timeout 300` and step-level timeout (MED-4), `permissions: contents: read`,
  a `concurrency:` group, Node 25 in the choices.
- "15 concurrent server.mjs children" was wrong: 15 is TOTAL spawns; peak is 11-12
  (review measured 11 x3, I measured 12) because the gate test's 3 cases and the 2 epoch
  boots are awaited serially. Corrected.
- dropped the ephemeral-port note: after #204 ports come from ltFreePort(), so it is
  stale and the inference now runs backwards.

server.mjs: unchanged (CI-only; ALIGNMENT.md requires no cli.js citation).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* ci: fix the ref the control arm depends on, and a signature that matched the opposite bug

Delta review of be7c545. One new HIGH, one real defect in the signature, three LOWs.

HIGH — `ref: 7f15921^` cannot be checked out, so MED-1's entire remedy was inert.

actions/checkout does not rev-parse; it takes a branch, a tag, or a FULL 40-char SHA.
The input description told the operator to type the one form that fails:

    git ls-remote origin '7f15921^'  -> 0 rows
    git ls-remote origin '7f15921'   -> 0 rows        (abbreviated SHA also fails)
    git fetch --depth=1 origin '+7f15921^:refs/...'
      -> fatal: invalid refspec '+7f15921^:refs/remotes/origin/tmpcheck'
    git rev-parse 7f15921^ -> c180987376   (fetches fine)

The whole point of `ref` is the pre-#204 positive control, and it would have red-X'd on
the first step. Full SHA now spelled out in both the description and the note, with the
resolution rule stated so the next person doesn't retype `^`.

The signature grep matched the OPPOSITE failure.

The comment claimed it tested "closed, exited NON-ZERO, empty stderr". It didn't test the
exit code at all — `.*` swallowed it:

    line                                          old  new
    [multi] expected a local-tools FATAL exit=1     1    1     <- true #203
    [multi] must exit non-zero          exit=0     1    0     <- gate DIDN'T refuse

exit=0 is the second assertion in that test: the gate failed to refuse and the server came
up and exited. That is the inverse of #203 and it was scoring as #203. Pinning the match to
`expected a local-tools FATAL` binds it to the third assertion. Verified against four
synthesised shapes drawn from real ltDiag text — true-#203, exit=0, contention, and the
pre-ltDiag historic format:

    sig=1 (TRUE203 only)   gate=4 (all four)   clean 1/5 completed, 6 launched

Also recorded, because it would otherwise look like a broken grep: all four historic
sightings PREDATE #204's ltDiag — their text is `expected a local-tools FATAL, got: ` with
no `closed=` and no `stderr(0B)` — so the signature can only ever match a future
reproduction. Do not "validate" it against the archived CI logs.

LOWs, all taken:

- a round killed by the step timeout leaves logs with no Results line; they counted in
  `total` and could never be clean, silently depressing the rate. `completed` is now
  reported separately, so a shortfall reads as a timeout rather than as a flake.
- `timeout 300 npm test` sends SIGTERM to `npm`, which need not reach the node child or
  the server.mjs grandchildren. Now `timeout -k 10 --foreground 300`.
- `concurrency.group` keyed on `github.ref` let two dispatches from different branches run
  at once — contending for the runner class, the one variable this experiment holds still.
  Bare `flake-hunt` group serializes all of them.
- noted the invariant that the `[ -z "$first" ] &&` list returns 1 when $first is set, so
  it must never become the last command in the step.

Confirmed unchanged after review re-checked them: `set +e -u -o pipefail` is sufficient
(exit 0 across all-clean, missing logs/, and a SIGPIPE'd histogram), and the 72-char
histogram bucket stands — `sed 's/:.*//'` would collapse every `integration:` test into
one row, which is worse than I had argued.

Reviewer's 11 vs my 12 reconciles: 11 live children plus one SIGKILLed-not-yet-reaped,
because the `finally` does not wait. "11-12" stays.

server.mjs: unchanged (CI-only; ALIGNMENT.md requires no cli.js citation).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* ci: drop --foreground — it does the opposite of what -k is there for

Delta review caught this, and it was mine: I added `--foreground` while implementing the
reviewer's own LOW about SIGTERM not reaching grandchildren, and the flag defeats exactly
that. coreutils is explicit:

    -f, --foreground ... in this mode, children of COMMAND will not be timed out

By default timeout makes itself process-group leader and signals the GROUP, so the node
child and the server.mjs grandchildren go down with npm. Measured with the same shape the
workflow uses (`timeout ... &` then `wait`), a script holding one direct child and one
grandchild, 3 runs each:

    default       survivors: 0 0 0
    --foreground  survivors: 2 2 2

My comment also claimed --foreground was needed "so the timeout applies to a backgrounded
job at all". Also false — the default form fires on backgrounded jobs 3/3 above.

The cost was not cosmetic. 50 rounds x 4 concurrent means every timeout would have leaked
a batch of orphaned node/server.mjs processes that keep competing for the runner's 4 vCPUs
— inflating the contention noise floor, which is precisely the column (`gate` minus `sig`)
this workflow exists to separate from #203.

server.mjs: unchanged (CI-only; ALIGNMENT.md requires no cli.js citation).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:22:49 +10:00
3dc023e596 feat(models): align maxTokens with the CLI registry (#195) (#208)
* feat(models): align maxTokens with the CLI registry (#195)

Every Opus entry and claude-sonnet-5 go 16384 -> 64000; claude-sonnet-4-6 and
claude-haiku-4-5 go to 32000 (haiku was 8192). These are the
max_output_tokens.default each model declares in the compiled CLI 2.1.220
registry, extracted id-anchored:

  claude-opus-5      max_output_tokens:{default:64000,upper:128000}
  claude-opus-4-8    max_output_tokens:{default:64000,upper:128000}
  claude-opus-4-7    max_output_tokens:{default:64000,upper:128000}
  claude-opus-4-6    max_output_tokens:{default:64000,upper:128000}
  claude-sonnet-5    max_output_tokens:{default:64000,upper:128000}
  claude-sonnet-4-6  max_output_tokens:{default:32000,upper:128000}
  claude-haiku-4-5   max_output_tokens:{default:32000,upper:64000}

This is not cosmetic metadata. OCP itself never enforces maxTokens — server.mjs
does not read it — but it is propagated to OpenClaw (setup.mjs,
scripts/sync-openclaw.mjs), and OpenClaw CAPS the outbound request with it:

  maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)

with reasoning budgets of medium 8192 / high 16384, and a clamp
`if (maxTokens <= thinkingBudget) thinkingBudget = maxTokens - 1024`.

So at `high` reasoning against a model declared at 16384: maxTokens resolves to
16384, the clamp fires, thinking takes 15360, and roughly 1024 tokens are left
for the visible answer. That is what this repo shipped for every model until
now, and it is the actual harm — not the advertised-vs-real mismatch.

ocp-connect keeps its own family-prefix table (/v1/models does not expose
maxTokens, so it cannot derive them). Set to the family FLOOR — opus 64000,
sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000, haiku 32000 —
because prefixes cannot distinguish versions and under-advertising merely caps
a client lower than the model allows, whereas over-advertising would promise
capacity a family member does not have. Commented in place.

New test asserts the PRINCIPLE, not the numbers: every maxTokens must exceed
OpenClaw's `high` thinking budget so an answer still fits. A value-pinning test
would need editing on every model addition and would not explain itself.
Mutation-proven: reverting one entry to 16384 fails with
"claude-opus-5 declares maxTokens=16384 <= 16384: at OpenClaw's 'high'
reasoning level the thinking budget would consume all but ~1024 tokens".

Owner-approved decision (option: align with registry defaults). Tradeoff
recorded in CHANGELOG: longer answers, correspondingly higher per-request quota
burn on long generations.

Verified: bash -n on ocp-connect, and py_compile on its embedded python block
(lines 101-334) so the comment sits at a valid indentation.

Tests: 458 passed, 0 failed (457 + 1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(models): rewrite the maxTokens rationale — my justification was factually wrong

The numbers were right, the code was right, and the reason I gave for both was
wrong. Verified every part of the review's finding first-hand before rewriting.

WHAT I CLAIMED: OpenClaw caps outbound requests via
`maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)`, so a
16384 declaration at `high` reasoning left ~1024 tokens for the visible answer,
and raising it would produce longer answers and higher quota burn.

WHY THAT CANNOT HAPPEN FOR OCP — three independent breaks, each confirmed here:

  scripts/sync-openclaw.mjs:75   api: "openai-completions"
The clamp I cited lives behind `case "anthropic-messages"`; the
openai-completions transport has no thinkingBudget concept at all, so that code
never runs for a claude-local provider.

  buildCliArgs pushes: --input-format, --disallowedTools, --allowedTools,
                       --dangerously-skip-permissions, --mcp-config
No output-token flag of any kind reaches the CLI. OCP cannot cap output even if
it wanted to.

  grep max_completion_tokens server.mjs lib/  ->  0 occurrences
That is the field OpenClaw would send to a local openai-completions endpoint,
and this repo never reads it.

So "expect longer answers and higher per-request quota burn" was a promise that
would not have materialised. That is worse than a wrong number: a release note
that tells operators to expect a behavioural change which cannot occur.

The change still stands, on the honest reason: models.json is the SPOT (ADR
0003) and its values should be true. maxTokens is ADVERTISED metadata consumed
only by clients that choose to honour it. CHANGELOG and the test comment now
say exactly that, and say plainly that OCP behavior is unchanged.

Test replaced, not just re-worded. The old one asserted `maxTokens > 16384`,
which review showed lets every entry sit at 16385 and still call itself
"registry-aligned" — the actual claim had no coverage. Now pinned per model
against a recorded table of CLI 2.1.220 id-anchored values, with an explicit
failure for any model missing a row. Mutation-proven on both holes:

  all entries at 16385          -> CAUGHT  (the old test passed this)
  sonnet-4-6 wrongly at 64000   -> CAUGHT

Also fixed ocp-connect's unknown-id fallback (8192 -> 32000). Review flagged it
as contradicting the invariant: 32000 is the LOWEST max_output_tokens.default
in the registry, so it is the safe floor and no longer sits below every family
in its own table. Unreachable today, wrong to leave inconsistent.

Verified: bash -n on ocp-connect and py_compile on its embedded python block.
Tests: 458 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(ocp-connect): revert the unknown-id fallback to 8192 — 32000 over-advertised

The previous commit raised ocp-connect's unknown-id fallback from 8192 to 32000 on
the stated grounds that 32000 was "the LOWEST max_output_tokens.default in the CLI
2.1.220 registry". That is false, and the change it justified made this PR violate
its own thesis.

Pairing-free enumeration of the compiled registry (17 occurrences, no grep-window
artifacts):

    7 max_output_tokens:{default:64000,upper:128000}
    5 max_output_tokens:{default:32000,upper:64000}
    2 max_output_tokens:{default:8192,upper:8192}
    2 max_output_tokens:{default:32000,upper:32000}
    1 max_output_tokens:{default:32000,upper:128000}

    distinct defaults: 8192 / 32000 / 64000     GLOBAL MINIMUM: 8192

The floor is 8192, held by claude-3-5-haiku and claude-3-5-sonnet — and those are
exactly the ids that reach the fallback, since "claude-3-5-*" matches none of the
family prefixes. So the raise over-advertised the only two real models the branch
serves, by 4x, in a PR whose entire claim is that advertised metadata should be true.

Verified by exercising the real classifier (verbatim source slice, not a replica):

    claude-3-5-haiku            8192  FALLBACK   registry 8192   exact
    claude-3-5-sonnet           8192  FALLBACK   registry 8192   exact
    claude-opus-5              64000  family     registry 64000  exact
    claude-sonnet-4-6          32000  family     registry 32000  exact
    claude-haiku-4-5-20251001  32000  family     registry 32000  exact
    claude-sonnet-5            32000  family     registry 64000  under (safe)

Also in this commit:

- The family table's comment said "FAMILY FLOOR of the CLI registry". It is the floor
  over each family's members CURRENTLY IN models.json, not over the registry family:
  claude-opus-4-0 / -4-5 are opus-family at 32000, so the opus row would over-advertise
  them 2x. Unreachable today (ocp-connect derives ids from /v1/models, i.e. models.json),
  but the comment now states the real scope and what to do if a legacy opus is added.

- The test table keyed haiku on "claude-haiku-4-5-20251001" while telling the reader to
  extract values id-anchored. That id has ZERO id-anchored hits: the registry record is
  id:"claude-haiku-4-5" and the dated string appears only as its provider_ids.first_party
  (10 bare-string hits). Following the instruction on that row returns nothing, which is
  precisely what pushes a reader back to the bare-string search this test exists to
  prevent. The row now carries the registry id.

Reviewer credit: the reviewer that found this also retracted its own earlier finding,
which is what produced the 32000 fallback in the first place. Both directions verified
independently here before acting.

server.mjs: unchanged by this commit (ALIGNMENT.md requires no cli.js citation).
Suite: 462 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* docs(ocp-connect): my unreachability reason was wrong — state the invariant that holds

Review of cb87b82 approved the change and flagged the new comment's reasoning. Verified
both entry points it named; both are real.

I wrote that claude-opus-4-0/-4-5 are unreachable because "ocp-connect derives ids from
/v1/models, i.e. models.json". Neither half is reliable:

- base_url is user-supplied (ocp-connect:510 builds it from arguments), so /v1/models is
  the REMOTE server's models.json — a different OCP version, or a fork, not this checkout.
- on a JSON parse failure the ids come from a hardcoded three-id list (:114), not from
  /v1/models at all. Checked all three: opus-4-6 -> 64000, sonnet-4-6 -> 32000,
  haiku-4 -> 32000. All exact against the registry, so safe today — but it is a genuine
  non-SPOT entry point that my sentence denied existed.

The invariant that actually holds is narrower: no released OCP has ever served a legacy
opus id. Same conclusion, and now it names the thing that would break it.

This is the second reasoning defect in this PR where the number was right and the
justification was not. Recording it in the comment rather than quietly fixing it.

Out of scope, noted for a future touch: the bare `except:` at :114 catches BaseException,
so a KeyboardInterrupt mid-parse silently yields the fallback list.

server.mjs: unchanged (ALIGNMENT.md requires no cli.js citation).
Suite: 462 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:26:51 +10:00
7f15921f69 test: harden and instrument the ltBoot integration tests (#203, #199) (#204)
* test: harden and instrument the ltBoot integration tests (#203, #199)

I could NOT reproduce either flake, and this commit does not claim to have
root-caused them. Stating that plainly up front, because a "fix" for an
unreproduced fault is a placebo unless you say what it actually is.

What I tried, all negative:
  - 150 targeted runs of the #203 boot-gate scenario in isolation (25 rounds x
    3 cases, with and without 8 CPU hogs)  -> 0 failures
  - 6 consecutive full-suite runs                                -> 0 failures
  - a direct test of the exit-before-stdio-drain hypothesis (40 spawns writing
    a stderr burst then exiting)                                 -> 0 races
  - a direct test of the port-clash hypothesis: with the port already held, the
    boot gate still fires FIRST and stderr still carries the FATAL, so a clash
    cannot produce the observed empty-stderr signature

So instead of guessing a cause, this removes the mechanisms that were
individually defensible anyway, and makes the next occurrence self-diagnosing.

1. Wait for 'close', not 'exit', wherever a test reads buf.err/buf.out after
   the child terminates. 'exit' fires on process termination while its stdio
   pipes may still hold unread data; 'close' is the event that guarantees both
   are drained. I could not get this to race locally, but the ordering is
   simply correct and costs nothing — and it exactly matches #203's signature
   (non-zero exit, EMPTY stderr).

2. Stop gating on a proxy signal. The #199 test waited for "listening on" and
   then asserted a DIFFERENT line ("ignored in TUI mode") that is written
   independently — a race by construction. It now waits for the line actually
   under assertion, still bounded by the same timeout, with the boot markers
   kept in the predicate so a failed boot ends the wait immediately.

3. Every fixed port is gone. All 10 ltBoot sites and their 8 matching ltPost
   calls now use ltFreePort(). #193 already hit a fixed-port variant of this,
   and 39330+i in particular was shared across a 3-iteration loop.

4. ltBoot now tracks signal/closed/spawnErr, and an 'error' listener is
   attached — without one, a spawn error is re-thrown as an uncaught exception
   and takes down the whole runner instead of failing one test.

5. New ltDiag(buf) is used in every assertion message. The historical failure
   text was `expected a local-tools FATAL, got: ` — an empty string, which
   distinguishes nothing. Both mutations below now produce an immediately
   readable cause:

     gate neutered   -> "[non-loopback] process never closed — exit=undefined
                         signal=undefined closed=false | stderr(92B)=... |
                         stdout(1147B)=..."   (it booted; the gate is gone)
     warning renamed -> "no inert-flag warning appeared — ... stderr(295B)=
                         \"⚠ OCP_LOCAL_TOOLS=1 is SILENCED-FOR-MUTATION ...\""
                         (the text changed, and you can see the new text)

Both target tests remain mutation-proven: neutering the non-loopback branch of
localToolsSafetyError fails the boot-gate test, and renaming the TUI inert
warning fails the inert test.

Stability after the change: 4 sequential + 3 concurrent full-suite runs, all
457/0. That shows no regression was introduced; it does NOT show the flakes are
gone, since I never reproduced them. #203 and #199 should stay open until a
run passes through this instrumentation and reports what it saw.

Tests: 457 passed, 0 failed. No production code touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test: fix the adjacent race the first pass missed, plus four review corrections

The review's most valuable finding is that my SEARCH was incompetent, not just
inconclusive. test-features.mjs:39-58 makes `test()` fire-and-forget for async
bodies, so ~15 ltBoot integration tests run CONCURRENTLY inside one suite run.
My "150 targeted runs in isolation" therefore removed the only condition that
produces the race. Under full-suite x 4-way concurrency the reviewer measured
#199 at 6/200 pre-PR and 0/400 post-PR (Fisher p ~ 0.001).

HIGH — I fixed one race and left its twin one line away (:1092).
`Local tools: ON` is written 14 console.log calls AFTER `listening on`, so
gating on the boot marker and then asserting the announcement reads a buffer
that may hold only the first chunk. Identical shape to #199, same pipe this
time. Measured 9/200 before AND 9/200 after my first pass — my PR did not touch
it, leaving it as the suite's top flake, and it reddens with a message that
looks exactly like a real regression. Now waits for the line under assertion;
review measured the same fix at 0/200.

MED — ENOTEMPTY (4/200). child.kill("SIGKILL") kills server.mjs but not the
fake `claude` grandchildren, which can still be writing into `dir` while rmSync
walks it. New _ltRmRetry uses Node's maxRetries/retryDelay and never lets a
leaked temp dir fail a test. Applied to all 10 lt call sites.

LOW — boot-gate wait was `buf.closed` while the TUI one was
`... || buf.spawnErr`; made symmetric so a spawn failure cannot become a 9s
hang later. ltFreePort moved above its first use (it worked only via hoisting).

Two claims of mine that were wrong, corrected in the PR body:
  - "ltDiag is used in EVERY assertion message" — it is used in the two target
    tests, 7 sites. Overstatement in a PR whose whole argument is scope honesty.
  - "39330+i was shared across a 3-iteration loop" — it was not; i is 0/1/2 so
    the ports differ. The real fixed-port risk is CROSS-PROCESS, which the
    review's data confirms: 21/200 EADDRINUSE, all on 39360/39361/39364.

Linux experiment for #203, run on the Oracle VM against pre-PR main:
  #199 reproduced at 25/50 (vs ~3% on macOS) — platform-dependent stdout/stderr
  delivery, exactly the mechanism the review identified.
  #203 did NOT reproduce, but that result is NOT usable: Oracle runs Node 22,
  whose SQLite ExperimentalWarning broke ~23/50 runs with spurious "server did
  not start" failures. The noise floor was too high to conclude anything, and
  CI is Node 24. Reporting it as contaminated rather than as evidence.

Tests: 457 passed, 0 failed; 7 consecutive clean runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix: correct the PR body, kill a comment I rotted, and get the counts right

The review's blocker was not in the code — it was that my PR body still carried
both retracted claims VERBATIM while my commit message asserted they had been
"corrected in the PR body". They had only been corrected in a trailing comment.
In a PR whose entire thesis is claim accuracy, that is the finding that matters.

The body is rewritten. Both claims now appear only as struck-through
corrections with the true values:
  - "ltDiag in EVERY assertion message"  -> 8 call sites, two target tests only
  - "39330+i was SHARED across a loop"   -> it wasn't; i is 0/1/2, ports differ.
    The real risk is cross-process, which the control arm's 246 EADDRINUSE shows.

Comment rot I introduced: test-features.mjs:1000 said "See ltAssertBoot below"
and no such function exists — grep returned exactly one hit, the comment itself.
Removed.

Counts corrected after re-measuring with the definition line excluded, which is
where my earlier numbers came from:
  ltDiag call sites   7  -> 8
  _ltRmRetry sites   10  -> 11

_ltRmRetry's catch now honours LT_DEBUG instead of being unconditionally
silent. It must still never throw — it runs in a finally, where a throw would
REPLACE the real assertion error and turn a flake into an apparent regression —
but an opt-in warn costs nothing and stops it swallowing genuine programming
errors too.

Recording the reviewer's control-arm numbers, since they are what actually
justifies this PR (full suite x 4 concurrent x 50 rounds, per arm):
                      main    this branch
  clean runs         42/200      200/200
  #199                    7            0
  :1092                   8            0
  EADDRINUSE            246            0
  ENOTEMPTY               1            0
  #203                    0            0

Tests: 457 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test: land the comment fix that didn't, and size ltDiag's window to the banner

Two things, both caught by review of the previous commit.

1. The comment fix I claimed to have made never landed.

800800e's message said it corrected the console.log gap "14 -> 13". The diff has two
hunks (ltAssertBoot removal, LT_DEBUG catch) and never touches that line, so the
comment still read 14 — and 13 was wrong too, in units. Ground truth:

    server.mjs:3627  console.log(`... listening on ...`)
    server.mjs:3640  if (LOCAL_TOOLS_ACTIVE) console.log(`Local tools: ON ...`)
    strictly between: 12 console.log calls, of which :3635 (SYSTEM_PROMPT) and
    :3636 (MCP_CONFIG) are conditional and unset in this env -> 10 actually fire.

Now 12, with the conditional note. Also corrected 9/200 -> 8/200 on the same comment:
the control-arm table says 8, and the two disagreed.

2. ltDiag sampled stdout tail-only, which is the wrong end.

Review reversed its own earlier "leave it" with direct evidence: the mutation
diagnostic printed 1147B of tail and did NOT contain "Local tools: ON" — the single
most decisive string for "it booted instead of refusing". Fixed as head+tail.

Sizing is measured, not picked round. On this tree stdout is 1118B with the string at
byte 581, so:

    head=120  -> string lands in the elided middle. VERIFIED INSUFFICIENT.
    head=900  -> clears it with ~5 banner lines of margin.

900 is robust rather than merely bigger: the banner is emitted first and is bounded, so
however much request noise follows, byte 581 stays in the head. Demonstrated under the
gate mutation:

    stdout(1133B)="... Local tools: ON (OCP_LOCAL_TOOLS=1) — model told it may use
    local tools; single-user/loopback only ..."

Also folded in the two cheap #203 levers review listed, since the next step on that
issue is a Linux + Node 24 run:

- closeMs (stamped at 'close', not read at assertion time) separates "died before
  reaching the gate" from "ran, then gated" — exit=1 with empty stderr is equally
  consistent with both today. Prints "(still open)" when close never fired.
- node=${process.version} — exactly what would have flagged the Node 22 SQLite
  ExperimentalWarning before it cost 50 contaminated Oracle runs.

Mutation restored from a file backup, not `git checkout` — that command destroyed
uncommitted work earlier in this series.

server.mjs: unchanged (test-only; ALIGNMENT.md requires no cli.js citation).
Suite: 461 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test: make ltHeadTail's head budget loud instead of silently degrading

Review's LOW on 75ac3e4, taken rather than deferred. Its failure mode is the reason:
the head budget is coupled to banner growth, and when it is exceeded ltDiag degrades
back to exactly the blind spot this PR fixed — with no test going red. Silent
degradation of a diagnostic is the same class of bug as the flakes this PR is about,
so a one-line guard is worth more here than the cycle it costs.

    const _ltOffset = buf.out.indexOf("Local tools: ON");
    assert.ok(_ltOffset < 900, "...it is now at byte ${_ltOffset}. The banner grew...");

Mutation-verified (budget 900 -> 400):

    ✗ ltHeadTail's head budget (900B) no longer reaches the local-tools announcement
      — it is now at byte 582. The banner grew. Raise the head in ltHeadTail, or
      ltDiag will silently stop showing the one line that distinguishes "booted"
      from "refused".

Margin is ~17 more models: `Models:` runs ~18B per entry, and the offset is 581 of a
1118B banner. Review independently measured 577/1117 — the delta is port-digit width.

Two micro-fixes it noted:
- `buf.t0 ? ... : "?"` was dead: t0 is always set in the literal and Date.now() is
  never 0. Removed rather than left as a branch that can't be reached.
- closeMs now declared in the buf literal alongside signal/closed/spawnErr.

Also acknowledging one change of mine that review found and my own summary omitted:
stderr sampling went 200 -> 240 chars, because the non-loopback FATAL body is ~180
chars before the "FATAL:" prefix and 200 was close to truncating the longest gate
message. Same size-to-the-content reasoning as the head; I just failed to list it.

server.mjs: unchanged (test-only; ALIGNMENT.md requires no cli.js citation).
Suite: 461 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:26:48 +10:00
c180987376 docs: guard the asymmetric cache keys (#200); document the ltBoot fault lever (#197) (#207)
* docs: guard the asymmetric cache keys (#200); document the ltBoot fault lever (#197)

Two knowledge-preservation fixes. No behavior change: server.mjs gains comments
only, verified by `git diff --stat` (comment lines) and an unchanged suite.

#200 — structuredHash and dedupKey compute the same value and must NOT be
collapsed. They take identical arguments and read as obvious duplicate work,
which is how someone "optimises" them into a bug. Their GUARDS differ, verified
line by line rather than asserted:

  structuredHash: if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(...))
  dedupKey:       (!conversationId && !hasCacheControl(...))          <- no CACHE_TTL

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 response caching.
`dedupKey = structuredHash` would silently disable stampede protection by
default, in exactly the concurrent-AI-Task case it exists to bound. The comment
records that, and says what a safe deduplication would look like if anyone
still wants one (compute under the weaker guard, derive under the stronger,
add a stampede test first).

This was first raised in the #194 review as a cosmetic nit and then RETRACTED
by that reviewer after reading the guards — the retraction is the valuable
part, so it goes in the code rather than staying in a PR thread.

#197 — new "Testing: reaching faults inside server.mjs" section in AGENTS.md.
The premise the issue was originally filed on was wrong twice over, and both
errors were mine: first that no live-server harness existed (ltBoot has been
there all along, and I had used it myself in #191), then that a synchronous
fault deep inside spawnClaudeProcess needed a production test hook (it needs
--stack-size, which ltBoot already forwards).

The section documents the fixture, the --stack-size fault lever with the
concrete numbers (~124k element spread threshold at the default stack vs ~24k
at --stack-size=200, which is what fits Linux's 131072-byte MAX_ARG_STRLEN so
the test RUNS in CI instead of skipping), and the three rules that made it hold:
discover the threshold in a child under the same flags, assert the fault
actually fired, and wait for the thing you are about to assert rather than a
proxy for it. Each rule cites the issue that taught it (#193/#199/#203).

Written to be read BEFORE someone concludes a bug class is untestable, since
that conclusion has now been reached wrongly twice in this repo.

cli.js citation: NOT APPLICABLE — comments only, no wire behavior, no code
path, no argv change. Authorized by ADR 0006 as a behaviour-preserving edit;
the field set and every request/response contract are byte-identical.

Tests: 457 passed, 0 failed (unchanged, as expected for a comments-only diff).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* docs: reference only helpers that exist on main (AGENTS.md)

Review caught a real forward-reference: the new AGENTS.md section named
`ltDiag` and told readers to wait on `closed`, and NEITHER exists on main —
both are introduced by the still-unmerged #204. `git grep ltDiag` matched
exactly one line: the documentation itself.

That is worse than a broken link. AGENTS.md is, by its own "Handoff
expectations", the FIRST file a fresh session reads; it would have sent that
session looking for two identifiers it cannot find, in the file whose whole job
is to orient them.

It also meant this PR had an undeclared merge-order dependency on #204, which
I had not stated anywhere.

Fixed so each PR is self-consistent regardless of merge order:
  - names `ltPostStatus` (which does exist) instead of `ltDiag`
  - states the #203 rule as a PRINCIPLE — a terminated child's pipes may still
    hold unread data, so wait for stdio to close rather than for the exit —
    instead of naming the `closed` flag that #204 adds

Verified: every helper the section now names resolves to a `function <name>` in
test-features.mjs on this branch, and neither `ltDiag` nor `closed` is
referenced any more.

Tests: 457 passed, 0 failed (comments/docs only, unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:05:03 +10:00
f2f9058db4 feat(models): commit models.schema.json and validate the SPOT against it in CI (#196) (#205)
* feat(models): commit models.schema.json and validate the SPOT against it in CI (#196)

models.json has declared `"$schema": "./models.schema.json"` since v3.11.0, but
that file was never committed — `git log --all -- models.schema.json` is empty.
So the SPOT that ADR 0003 makes canonical had NO structural validation: a
missing contextWindow, a typo'd openclawName, or a boolean written as a string
would pass every existing test and only surface downstream — in OpenClaw's
registry, or silently inside a truncation budget.

Validated with the repo's OWN validator (lib/structured-output.mjs, shipped for
#153) rather than adding ajv. AGENTS.md requires minimal dependencies and the
repo currently has ZERO; this keeps it that way and exercises that validator on
a second real input. Capability-probed first rather than assumed: it enforces
type / required / enum / const / additionalProperties (both the boolean and the
schema form) / items / minItems, which covers everything the SPOT needs.

Three tests:
  - the $schema reference resolves to a committed file (the #196 bug itself)
  - models.json validates against models.schema.json, strict
  - the schema actually REJECTS 8 corruption shapes — missing required field,
    wrong scalar type, non-integer window, unknown extra field, alias mapped to
    a non-string, empty models array, wrong document version, unknown
    top-level key

That third test is the guard-on-the-guard: without it a vacuous schema (`{}` or
a typo'd `properties`) would make the second test pass on anything.

Mutation-proven in three directions:
  schema replaced with {}              -> "schema failed to reject: missing required field"
  additionalProperties:false removed   -> guard fails (loosened constraint caught)
  models.json corrupted (drop a field) -> strict validation fails

What the schema deliberately does NOT encode: referential integrity — that
every aliases/legacyAliases target exists as a models[].id. That is not a JSON
Schema concept; it is already covered by two dedicated tests, and the schema
says so in its description so the next reader doesn't assume it is covered.

The field descriptions carry the non-obvious consequences that have bitten this
repo before: contextWindow is a GLOBAL lever (max across all entries x 3 sets
MAX_PROMPT_CHARS for every model, ADR 0009), maxTokens is advertising only and
is enforced by OpenClaw rather than OCP, and models[] order is load-bearing
because ocp-connect uses model_ids[0] as a fallback.

Documented per release_kit (new file/SPOT/schema -> contributor section):
AGENTS.md "Key files to know" and README's Available Models section.

Tests: 460 passed, 0 failed (457 + 3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(schema): warn that only a subset of draft 2020-12 is enforced; cover the gaps it cannot

Both review findings taken.

MED — the schema declared `$schema: draft/2020-12`, which promises full draft
semantics, while the validator that actually runs it enforces only a subset.
Someone adding `"minimum": 1` to contextWindow would get SILENT no-op with a
green suite — a trap made worse by the file looking authoritative. The
description now enumerates exactly what is enforced (type, required, const,
enum, additionalProperties in both forms, items, minItems/maxItems,
anyOf/allOf/oneOf, $ref, nullable) and names what is ignored (minimum,
maxLength, pattern, uniqueItems, propertyNames, not), with the instruction to
add such a constraint as a test instead.

LOW — three corruptions neither the schema nor any sibling test caught:
duplicate models[].id, empty-string id/displayName/openclawName, and
non-positive contextWindow/maxTokens. They are structurally NOT expressible
with the supported keyword set (no uniqueItems, no minLength, no minimum), so
they are asserted directly rather than by pretending the schema covers them.

Mutation-proven, all three previously slipped through:
  duplicate id       -> CAUGHT
  empty displayName  -> CAUGHT
  zero contextWindow -> CAUGHT

Tests: 461 passed, 0 failed (460 + 1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test(schema): close the fourth gap — uniqueness on all three name fields, not just id

Review approved the delta and named this exact follow-up as non-blocking:
"extend the same test to assert uniqueness of displayName/openclawName and use
m[f] === m[f].trim()". Implemented as specified.

Why it matters rather than being tidiness: scripts/sync-openclaw.mjs maps
`claude-local/<id>` -> { alias: displayName } and writes openclawName as the
registry label, so a duplicate in EITHER field collapses two models onto one
OpenClaw entry — the same defect class as a duplicate id, which the previous
version covered while leaving its two siblings open.

The trim check also changed shape. It was `m[f].trim().length > 0`, which a
PADDED id passes by construction — and that id is handed VERBATIM to
`claude --model`, so " claude-opus-5" would have failed upstream instead of
here. Now asserts `m[f] === m[f].trim()`.

Mutation-proven, all three previously slipped through:
  duplicate displayName  -> CAUGHT
  duplicate openclawName -> CAUGHT
  whitespace-padded id   -> CAUGHT

Tests: 461 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:04:53 +10:00
a99395ed0c ci: fix release.yml's no-CHANGELOG path, which would have failed the release job (#202) (#206)
* ci: cover models.json in the alignment guardrail; fix release.yml's no-CHANGELOG path

Two CI-layer fixes found during the v3.25.0 review. Same layer, both small, so
they land together (Iron Rule 11).

#198 — alignment.yml did not run on a models.json-only PR.
The `paths:` filter listed server.mjs / setup.mjs / scripts / lib / ocp /
ocp-connect, but not models.json. That made CLAUDE.md hard-requirement #2 ("CI
blacklist pass") vacuous for exactly the PRs that change model routing:
confirmed on #192, where only gitleaks and test-features ran.

models.json is not inert data. It drives MODEL_MAP and VALID_MODELS, the
default request model, and — since ADR 0009 — the global MAX_PROMPT_CHARS
truncation budget. A models.json-only PR can therefore change server.mjs's
runtime behavior with the guardrail never running. models.schema.json is
included for the same reason one level up: loosening the schema is what would
let a malformed models.json through.

Adding paths only widens coverage; the blacklist grep still scans server.mjs,
so this costs nothing and closes the gap. Per CLAUDE.md this is a PR amendment
to alignment.yml, which is the sanctioned way to change it (removing entries
would need an ALIGNMENT.md amendment; nothing is removed here).

#202 — release.yml's no-CHANGELOG fallback would have failed the release job.
The branch wrote an output named `notes` and exited WITHOUT creating
/tmp/release-notes.md, while the create step hard-codes
`--notes-file /tmp/release-notes.md`. So the one path that exists to "degrade
to minimal notes" instead produced a failed release.

Verified both directions by extracting the step's actual script from the YAML
and running it, rather than reading it:

  PRE-FIX,  no CHANGELOG -> exit 0, GITHUB_OUTPUT="notes=Release v3.25.0",
                            /tmp/release-notes.md MISSING
                            -> gh release create --notes-file WOULD FAIL
  POST-FIX, no CHANGELOG -> exit 0, notes_file set, file present ("Release v3.25.0")
  POST-FIX, with CHANGELOG -> file present, first line "## v3.25.0 — 2026-07-27"

Fixed by writing the file in that branch, and by removing the hard-coded-path
coupling entirely: both branches now set `notes_file` and the create step
consumes `steps.notes.outputs.notes_file`. That output existed already and was
never read — the two only agreed by accident.

Also added `set -euo pipefail` (the step previously ran unset-tolerant) and an
echo of the resolved notes before creating the release, so a wrong-looking body
is visible in the job log instead of only on the published release.

NOT changed: the empty-extraction guard. My issue text suggested adding one,
but `if [ ! -s "$NOTES" ]` was already there and already handles a heading
mismatch. Correcting that claim on #202 rather than taking credit for it.

Both workflows re-validated as YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* ci: drop the alignment.yml paths change — it would have been a green check for a check that never ran

Reverting my own half of this PR. The reviewer proved the alignment.yml change
was theatre, and my issue #198 was filed on a wrong premise.

What I verified myself before reverting:
  - the alignment job BODY references models.json zero times; only the paths
    filter I added mentioned it
  - test.yml is `pull_request:` with NO paths filter, so it already runs on
    every PR, models.json-only ones included
  - on a deliberately corrupted models.json (contextWindow 1000000, a typo'd
    openclawName), `npm test` catches it: 455 passed, 2 failed

So the real guard on models.json already existed and always ran. Adding the
path would only have made a job execute that inspects nothing about
models.json, and then reported a green "Alignment Guardrail" — implying a check
that did not happen. That is strictly worse than the job not running, which is
precisely the reviewer's point.

#198's premise was also wrong. The blacklist greps server.mjs for known
hallucinated tokens. A models.json-only PR cannot introduce a token into
server.mjs, so CLAUDE.md hard-requirement #2 is INAPPLICABLE on such a PR, not
vacuous. I read "the guardrail didn't run" as "coverage gap" without checking
what the guardrail actually inspects.

The release.yml half is unaffected and stays: that one is a real, reproduced
failure (the no-CHANGELOG branch never wrote the file the create step consumes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:42:30 +10:00
c998370b8a chore(release): v3.25.0 — Claude Opus 5, cache-key resolution, counter pairing (#201)
* chore(release): v3.25.0 — Claude Opus 5, cache-key resolution, counter pairing

package.json 3.24.0 -> 3.25.0. MINOR because the `opus` alias now resolves to a
different model: every request that names `opus` (and OpenClaw's opus entry)
silently moves from Opus 4.8 to Opus 5. Same $5/$25 per MTok, so no cost change,
but it is a user-visible default change and not a patch.

Consolidates six PRs. Three were already on main without CHANGELOG entries
(#187, #188, #191, merged after the v3.24.0 tag) and are covered here:

  #192  Claude Opus 5 + `opus` alias repoint
  #194  cache keys hash the resolved model; structured path gets its epoch
  #193  active-request counter paired to the process lifecycle
  #191  OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list
  #187  TUI --safe-mode so the host CLAUDE.md cannot leak
  #188  TUI accepts `shift+tab to cycle` as input-ready

release_kit walk (CLAUDE.md Iron Rule 5.5), every item checked rather than
assumed:
  - version_source package.json          -> bumped
  - changelog CHANGELOG.md               -> v3.25.0 section added
  - Available Models table               -> already 7 rows (landed in #192)
  - models.json source_of_truth          -> already updated (#192)
  - Environment Variables table          -> no new env var this cycle
  - API Endpoints table                  -> no new endpoint
  - new CLI subcommand / hook / file     -> none
  - bootstrap_quirk_policy               -> see below

Bootstrap quirk documented, per policy. #194 changes the cache key shape, so
alias-addressed rows orphan once and are reaped by the TTL cleanup within one
window — the same shape as the v3.13.0 v1->v2 hash upgrade already recorded in
README. Added to BOTH places the precedent lives: the Response Cache section in
README, and a dedicated entry in docs/troubleshooting.md alongside the existing
"OpenClaw shows old models after ocp update" one-time-quirk entry. Only affects
instances running with CLAUDE_CACHE_TTL > 0; it is off by default.

Verified: release.yml's awk extractor matches the `## v3.25.0 — 2026-07-27`
heading and pulls 22 lines (a mismatched heading would ship an empty release
body). Suite 457 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(release): correct four inaccuracies the reviewer found in the release notes

This PR's entire job is that the text is accurate, so these are not nits.

1. README + troubleshooting overstated the cache-key scope. "Rows keyed on a
   literal model id are unaffected" is FALSE for structured-output rows: #194
   also added `configEpoch` to the structured key, and keys.mjs:362 folds it
   into the digest, so EVERY structured row rekeys regardless of how it was
   addressed. Now states both scopes separately — normal cache: only
   alias-addressed rows; structured cache: all rows, plus why (the epoch was
   never in that key, so a CLAUDE_SYSTEM_PROMPT change did not invalidate
   structured answers either).

2. The bootstrap quirk was documented in README § Response Cache and in
   docs/troubleshooting.md, but NOT in the "Bootstrap quirks (one-time
   migrations)" bullet list at README:540 — which is the location the
   release_kit YAML actually names, and where both existing precedents live.
   Added there. My "every item checked, not assumed" claim was wrong on the one
   item that had a dedicated home.

3. #188's root cause was invented. I wrote "plan/auto mode renders a different
   idle footer than manual mode", which contradicts the landed code comment
   (lib/tui/session.mjs:184-189) and the test fixture: the two footers are
   across claude VERSIONS — the classic `? for shortcuts` vs `shift+tab to
   cycle` on newer 2.1.x — and the fixture is bypass-permissions mode, neither
   plan nor auto. Replaced with the contributor's own documented cause, plus
   the observable symptom (tui_pane_not_ready on every boot, every pre-boot
   failing with the warm pool on). Substituting my guess for an external
   contributor's evidenced diagnosis would have sent the next debugger looking
   for "plan mode".

4. "Suite: 449 → 457" used a mid-cycle baseline. Measured at the v3.24.0 tag:
   447. 449 is the count AFTER #187/#188 already landed — and this release
   claims those as part of the cycle. Corrected to 447 → 457 with the per-PR
   breakdown (#188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1 = +10).

Also taken from the same review:
  - the Added bullet now cites #192 (every Fixed entry had a PR number; Added
    did not)
  - split out a `### Changed` section for the `opus` repoint, matching how
    v3.23.0 recorded #168's `sonnet` repoint — a behavior change should not sit
    under Added
  - CHANGELOG quoted `MODEL_MAP[model] || model`, the form review REJECTED;
    the landed code is the Object.hasOwn guard (a bare lookup returns a truthy
    object for "__proto__" and never reaches the `||` fallback)

Re-verified after the edits: release.yml's awk extractor yields 27 lines /
6277 bytes with all four sections; all six PR references present. Suite 457
passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(release): qualify the normal-cache scope for OCP_LOCAL_TOOLS instances

Round-2 review. One finding accepted, one REJECTED with first-hand evidence.

ACCEPTED [MED] — my round-1 fix corrected the structured-cache scope but got
the normal-cache scope wrong in a new way. "Rows keyed on a literal model id
keep matching" is true for #194 in isolation, false for this RELEASE: #191
(a32bc9a, in this same release) reworded OCP_LOCAL_TOOLS_WRAPPER, the wrapper
text is one of CONFIG_EPOCH's four inputs (server.mjs:219), and every normal
cache key folds the epoch in. Verified by computing both digests:

  epoch with the OLD local-tools wrapper: 9a97a9dace3cadc2
  epoch with the NEW local-tools wrapper: 9f4526f7b146adfb

So an OCP_LOCAL_TOOLS=1 instance rekeys its ENTIRE normal cache once, literal
ids included. The doc heading is release-scoped while my claim was PR-scoped —
the same class of over-broad assertion as the original finding, which I then
reintroduced while fixing it. Both docs now carry the exception.

Also anchored the README bootstrap-quirk link at the specific troubleshooting
section (#cache-rekey-v3250) rather than the file, matching the existing
#tui-401 precedent.

REJECTED [claimed HIGH] — "Opus 5 is pricing:\"tier_10_50\" ($10/$50), so
'Pricing is unchanged' is false." It is not false. I re-extracted from the same
binary the review cites (claude 2.1.220), this time ANCHORED ON THE id FIELD
rather than on a bare id string:

  id:"claude-opus-5"    ... pricing:"tier_5_25"
  id:"claude-opus-4-8"  ... pricing:"tier_5_25"
  id:"claude-mythos-5"  ... pricing:"tier_10_50"   <- the real owner
  id:"claude-sonnet-5"  ... pricing:"tier_3_15"

`claude-opus-5` never appears within 200 chars before a tier_10_50 (grep -c: 0).
tier_10_50 belongs to claude-mythos-5. The review appears to have matched an
adjacent entry — the exact failure mode it accused this PR of, which is a fair
thing to have looked for; it just wasn't what happened here.

Worth recording WHY the original extraction was sound even though it looked
suspicious: the substring it matched, `claude-opus-5"},eager_input_streaming`,
is the tail of claude-opus-5's OWN provider_ids object (`gateway:"claude-opus-5"}`),
so the pricing that follows is its own. Ambiguous-looking, correct in fact —
which is why the id-anchored re-extraction was worth doing rather than assuming
either way.

The pricing claim therefore stands unchanged, on first-hand evidence.

Suite: 457 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:10:15 +10:00
f46756b906 fix(server): pair the active-request counter with the process lifecycle (supersedes #180) (#193)
* fix(server): pair the active-request counter with the process lifecycle (#180)

`stats.activeRequests` was incremented ~40 lines before the child process was
spawned, but its only decrement site — cleanup() — is wired to that process's
exit/close/error events. Any synchronous throw in the window between them
(buildCliArgs, env assembly, applying the spawn decision, or spawn() itself)
therefore leaked +1 permanently: the increment had happened, and cleanup() was
not yet attached to anything that could undo it. /health and /stats then
over-report in-flight work forever, and the drift is monotonic.

Fix: move the increment to immediately after `activeProcesses.add(proc)`, so
increment and decrement are structurally paired to exactly one process
lifecycle. No try/catch and no reconciliation pass is needed — there is no
longer a window in which the counter is incremented but unowned.

`stats.activeRequests` is observability-only, not a gate: the old
`if (activeRequests >= MAX_CONCURRENT) throw` admission check was removed in
favour of the releaseSlot semaphore (see the comment at server.mjs:1327), and
the only remaining readers are /health, /stats and the settings snapshot. So
deferring the increment past the spawn changes no admission decision.

Reproduced and verified on the real server (no test double). A deliberate
throw injected between the increment and the spawn, then three requests:

  before the fix : 3 requests -> http 500 -> stats.activeRequests == 3  (leaked)
  after the fix  : 3 requests -> http 500 -> stats.activeRequests == 0
  happy path     : real completion "OK", activeRequests 0, total 1, errors 0

The fault injection was removed before committing (no residue: `grep -rn
OCP_FAULT_INJECT` is clean) — it is documented in the PR so any reviewer can
re-run the same three commands.

cli.js citation: NOT APPLICABLE — this touches no cli.js-derived wire
behavior. Scope is justified under ALIGNMENT.md Rule 2 as OCP-owned internal
bookkeeping: the counter is OCP's own observability state, cli.js has no
equivalent operation, and the spawn arguments and protocol are untouched.

Supersedes the approach in #180, which additionally reconciled the counter to
`activeProcesses.size`. That reconciliation is racy: a concurrent request that
has incremented but not yet registered its process is invisible in
`activeProcesses`, so reconciling stomps it back down and under-reports live
work. Pairing the increment to the spawn removes the drift at its source, so
no reconciliation is required. Original report and diagnosis by @konceptnet.

Tests: 449 passed, 0 failed.

Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(review): correct three wrong claims in this PR, and add the regression test

The diff was right; three things I wrote about it were not. An independent
reviewer falsified each one, and leaving a correct fix on an incorrect
rationale in git history is exactly what ALIGNMENT.md governance exists to
prevent. Corrections:

1. "No regression test is possible here" — WRONG, and the test is now here.
   Two compounding errors. `test-features.mjs:955` already has `ltBoot`, a
   real-server integration fixture (spawns server.mjs, fake claude binary,
   HTTP) that I had used myself in #191; I reasoned from "cannot import
   server.mjs" straight to "cannot test" without grepping for it. And the
   synchronous throw is reachable from ENV ALONE, needing no production hook:
   buildCliArgs does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a
   spread of enough elements throws RangeError synchronously, inside the leak
   window. Measured, zero production changes:

     origin/main : 3 requests -> 500 x3 -> stats.activeRequests == 3
     this branch : 3 requests -> 500 x3 -> stats.activeRequests == 0

   The committed test DISCOVERS the element count rather than hard-coding it
   (the threshold is stack-size dependent, so a fixed number would silently
   stop triggering on another machine and pass vacuously), and asserts the
   requests actually 500 before trusting the counter. Mutation-proven: moving
   the increment back before the spawn fails it with "got 3".

2. My reason for rejecting #180's approach was WRONG. I claimed reconciling to
   activeProcesses.size is racy because a request that incremented but has not
   yet registered is invisible in the Set. There is no `await` anywhere in that
   window — it is fully synchronous, so on Node's single thread that state is
   unobservable and the race cannot occur. The real reason reconciliation is
   unsafe: the decrement is wired to 'exit' while activeProcesses.delete is
   wired to 'close', and exit precedes close (measured exit @2ms, close
   @2017ms with a grandchild holding stdout), so activeProcesses.size
   OVER-reports during that window — the opposite direction from what I wrote.
   Separately, a throw after activeProcesses.add leaves permanent Set residue
   that reconciliation would inherit. Corrected publicly on #180; the
   contributor was owed an accurate rejection.

3. The alignment citation named the wrong authority. ALIGNMENT.md:17 scopes
   Rules 1-5 to Class A only, and Rule 2 is a prohibition, not an
   authorization — citing it as justification is a category error. Every reader
   of stats.activeRequests is a grandfathered Class B.2 endpoint (/health,
   /status, /usage). Correct citation, per ALIGNMENT.md:142 and the in-repo
   precedent at server.mjs:3271-3276 (the F6 spawn.isolated fix):

     Authorized by ADR 0006 (grandfathered as of v3.16.4). The field SET is
     unchanged — no field added, removed or renamed — only the VALUE is made
     truthful. Behaviour-preserving; no new ADR required.

   The "cli.js: no hit, declared" part stands: cli.js has no equivalent
   operation, and spawn argv and protocol are byte-identical.

Also corrected the new comment at the increment: only 'exit' is wired inside
spawnClaudeProcess; 'close'/'error' are wired by each caller, and that is
REQUIRED because a failed spawn never emits 'exit'.

Note for a follow-up issue, not folded in (Iron Rule 11): stats.errors stays 0
across all of these 500s — synchronous throws from spawnClaudeProcess are never
counted. And CLAUDE.md hard-requirement #1 literally says "justify scope under
Rule 2", which is what I followed; CLAUDE.md predates ADR 0006's Class B
section and needs its own governance PR.

Tests: 450 passed, 0 failed.

Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test: skip the leak probe where the OS rejects the trigger, instead of failing CI

The regression test I added broke CI with `spawn E2BIG`, and the reason matters
more than the fix: the env-only trigger is NOT portable, and the review that
proposed it was measured only on darwin (the reviewer said so).

Delivering the trigger needs a single env var of ~2n bytes where n is the
spread-throw threshold (~130k elements here). Linux caps ONE env string at
MAX_ARG_STRLEN = 32 * PAGE_SIZE = 131072 bytes, so execve rejects it outright.
Encoding tricks do not rescue it: empty items cost 1 byte each, but 120k empty
items do not reach the throw threshold, and the threshold itself moves with
stack size — the margin does not exist. buildCliArgs has exactly one spread
(verified), so there is no alternative env-only trigger.

So the test now runs where the OS allows the trigger (macOS/BSD) and SKIPS
LOUDLY where it does not, printing the platform and byte count. It never
passes vacuously: it already asserts the requests actually return 500 before
trusting the counter, and E2BIG is detected both as a synchronous throw and as
an async 'error' event.

Consequence, stated plainly rather than buried: CI runs on Linux, so CI does
NOT exercise this test. The fix is protected on macOS dev machines only. That
is weaker than I would like and is why #197 (portable in-process fault
injection) matters — it is the difference between this class of bug being
guarded and merely having been checked once by hand.

Verified after the change:
  macOS, fixed        -> test RUNS and passes, 450/0
  macOS, bug restored -> test goes RED ("got 3"), 449/1
  Linux CI            -> skips with a printed reason

Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* test: make the leak probe run on Linux CI — my "impossible" conclusion was wrong

f84d4c6 concluded the trigger could not be delivered on Linux and made the test
skip there. The reviewer refuted that and built the working version; this is it.
Three things I got wrong, all the same shape — treating a darwin measurement as
universal:

1. "empty items don't reach the throw threshold" — the REASON was wrong. The
   spread's cost is per ELEMENT, not per byte: empty and "x" have an identical
   threshold. What actually kills the empty-item encoding is `.filter(Boolean)`
   at server.mjs:355, which strips empty entries, so ALLOWED_TOOLS ends up empty
   and the spread branch is never entered. The new control mutation demonstrates
   exactly this: tools filtered to empty -> requests return 200, not 500.

2. "the threshold moves with stack size — the margin does not exist" — exactly
   backwards. The stack dependence is the LEVER, and ltBoot already spawns the
   server, so the test owns its argv. Under --stack-size=200 the threshold drops
   ~5x: measured 24069 elements, so 1.5x margin is 36104 elements = 72207 bytes,
   45% UNDER Linux's MAX_ARG_STRLEN of 131072.

3. "buildCliArgs has only one spread, so there is no alternative trigger" — the
   premise is true (verified) but irrelevant. The problem was never finding a
   second spread; it was making the one spread throw with fewer elements.

Test-side only; server.mjs is untouched by this commit.
  - ltBoot(env, dir, nodeArgs = []) — one line, so a caller can set V8 flags
  - the threshold is discovered in a CHILD under the same --stack-size (the old
    version measured it in the parent, whose stack is not the one that matters)
  - hard assert entryBytes <= MAX_ARG_STRLEN, so a future regression fails loudly
    instead of silently degrading to a skip
  - dynamic free port instead of a fixed 39370 (a flake was observed on the fixed
    port across back-to-back runs)
  - the 500s must carry "call stack size exceeded", so an unrelated fault that
    also left the counter at 0 cannot be mistaken for a pass

The Linux claim here is still ARITHMETIC (72207 < 131072), not a measurement —
neither I nor the reviewer has a Linux box. CI is the check: the log must show
this test RUNNING, not skipping.

Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:39:33 +10:00
fdd6dadf96 feat(models): add Claude Opus 5 and repoint the opus alias to it (#192)
* feat(models): add Claude Opus 5 and repoint the `opus` alias to it

Adds `claude-opus-5` to models.json (the SPOT) and moves the `opus` alias
from `claude-opus-4-8` to it. `/v1/models` goes 6 -> 7; OpenClaw picks the
new entry up on the next `ocp update` via scripts/sync-openclaw.mjs.

server.mjs is NOT modified by this PR — models.json is the single source of
truth (ADR 0003) and every consumer (server.mjs MODEL_MAP, setup.mjs,
sync-openclaw.mjs, ocp-connect) derives from it. ocp-connect needs no change
either: its metadata table matches on the `claude-opus` FAMILY prefix, which
`claude-opus-5` hits (the guard added in PR #152 review).

Alignment evidence. The claim "the CLI itself defaults opus -> claude-opus-5"
is verified from the compiled CLI binary 2.1.220 via `strings`, the same
protocol used for #152/#168:

    latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",
                       sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}

The same registry entry gives context:{window:1e6,native_1m:true},
max_output_tokens:{default:64000,upper:128000} and pricing:"tier_5_25" --
i.e. identical $5/$25 per MTok to Opus 4.8, so the alias repoint carries no
cost change. Availability confirmed with a live subscription-pool completion
(`claude -p --model claude-opus-5` -> "OK"). This mirrors #168 (sonnet ->
sonnet-5), which established the precedent that following the CLI's own
latest_per_family is the correct default behavior.

contextWindow is deliberately 200000, not the native 1M:

1. MAX_PROMPT_CHARS is a SINGLE GLOBAL budget, not per-model.
   derivePromptCharBudget (lib/prompt.mjs) returns
   `max(floor, max(...contextWindows) * 3)` across ALL entries, so a 1M entry
   would lift the truncation ceiling to 3,000,000 chars for
   claude-haiku-4-5-20251001 as well -- genuinely a 200k-native model --
   turning clean OCP-side truncation into an upstream API rejection.
2. OpenClaw scales its history budget linearly off this value:
   contextWindow * maxHistoryShare * SAFETY_MARGIN (= x0.6), plus an
   oversized-message threshold at x0.5 (compaction-planning, OpenClaw
   2026.7.1). Its own bundled registry hardcodes 200000 for Claude models,
   and the upstream request to raise it to 1M (openclaw#22979) was closed
   "not planned" -- 1M needs a beta header its compaction path omits.

Raising the window for real requires per-model budgets and is ADR-level;
a new regression test pins the invariant so that change has to be deliberate.

Tests: 452 passed, 0 failed. Three mutations verified to bite:
  - revert aliases.opus -> 4-8            => 1 failure (opus-alias SPOT)
  - delete the claude-opus-5 entry        => 2 failures (presence + dangling alias)
  - set its contextWindow to 1000000      => 2 failures (invariant + budget SPOT)

E2E on an isolated port (:3999, prod on :3456 untouched and verified so):
/v1/models returns 7 ids with claude-opus-5 first; a request with
model:"opus" logs event=claude_spawned model=claude-opus-5 tier=opus and
returns a real completion. Derived MAX_PROMPT_CHARS stays 600000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(docs,test): restore the dropped Opus 4.8 line; assert the window CEILING

Two review findings from the independent Iron-Rule-10 reviewer, both confirmed
before applying:

1. docs/lan-mode.md: the sample `ocp update` output lost `ocp/claude-opus-4-8`
   entirely — the edit substituted the id instead of inserting the new one, so
   the block listed 6 bullets while line 219 of the same document claims
   "7 models available". Self-contradictory. Restored; the block is 7 again.

2. test-features.mjs: "every contextWindow is 200000" was too rigid and
   contradicted ADR 0009, which states the budget "scales automatically — no
   code change". Asserting every entry turned that documented no-code-change
   path into a must-edit-tests path, and would have failed on a future entry
   with a legitimately SMALLER window. Now asserts the MAX instead: raising the
   ceiling (the actual hazard, since the budget is global) still fails, while a
   smaller-window model stays legal.

Verified the reformulation is strictly better, not just different:
  - raise a window to 1000000  -> 2 failures (ceiling + derivePromptCharBudget SPOT)
  - add a legitimate 128k model -> 452 passed, 0 failed   (old test would have failed here)

Tests: 452 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:37:31 +10:00
9f6707ee9d fix(server): hash the resolved model in cache keys; give the structured path its epoch (#194)
* fix(server): fold the alias map into CONFIG_EPOCH so alias repoints invalidate the cache

Cache keys hash the model string exactly as the client sent it — `const model =
parsed.model || modelsConfig.aliases.sonnet` (server.mjs:2806), which then goes
straight into `cacheHash(model, ...)`. A request for "opus" is therefore cached
under the literal string "opus", not under the canonical id it resolved to.

models.json is read once at boot, so repointing an alias only takes effect on
restart — and the response cache is SQLite-backed (keys.mjs getCachedResponse
queries the `response_cache` table), so it OUTLIVES that restart. CONFIG_EPOCH
folded four config values into every cache key for exactly this hazard (#176 /
#177) but omitted the alias map. Consequence: an operator running
CLAUDE_CACHE_TTL > 0 who repoints an alias keeps being served the OLD model's
answers under that alias until TTL expiry — silently defeating the repoint.

Fix: add `modelsConfig.aliases` to the epoch digest, so any alias change is an
instant whole-cache invalidation. This is the mechanism #177 already
established for the other config inputs, applied to the one it missed; it
covers the normal, structured and singleflight paths at once because they all
key off the same epoch, and it needs no change at the three call sites.

Reproduced end-to-end on the real server, both directions (NODE_ENV=test +
OCP_DIR_OVERRIDE, isolated SQLite store, CLAUDE_CACHE_TTL=600000):

  1. boot with aliases.opus = claude-opus-4-8, request model:"opus"
     -> claude_spawned model=claude-opus-4-8, response cached
  2. stop, repoint aliases.opus -> claude-sonnet-5, restart
  3. same request again:
       on origin/main : cache_hit    (stale Opus 4.8 answer, no spawn)
       on this branch : cache MISS -> claude_spawned model=claude-sonnet-5

Default CLAUDE_CACHE_TTL is 0 (caching off), so shipped default behavior is
unaffected; this bites only operators who deliberately enabled the cache.

cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping:
cli.js has no response cache and no equivalent operation, and the spawn
arguments and protocol are unchanged.

Found by an independent codex review (gpt-5.6-sol, reasoning=high) of the
Opus 5 alias repoint in #192; verified first-hand before applying rather than
taken on the reviewer's word.

Tests: 449 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* fix(server): hash the RESOLVED model in cache keys; give the structured path its epoch

Supersedes the first attempt on this branch, which folded modelsConfig.aliases
into CONFIG_EPOCH. An independent reviewer falsified that approach with a live
repro: the structured-output cache key never reads CONFIG_EPOCH at all, so
adding inputs to the epoch could not possibly fix it. My PR body claimed it
covered "normal, structured and singleflight" — that claim was false.

Two defects, both now fixed:

1. Cache keys hashed the model string as the client sent it. `model` is
   whatever arrived — a canonical id, an alias ("opus"), or a legacyAlias
   ("claude-opus-4"); MODEL_MAP carries all three. models.json is read once at
   boot, so repointing an alias only takes effect on restart, while the SQLite
   response_cache outlives it — serving the OLD model's answers under that
   alias until TTL expiry. Now all three call sites hash
   `cacheModel = MODEL_MAP[model] || model`.

   Chosen over folding the alias map into CONFIG_EPOCH because it is strictly
   better on three counts: it fixes the normal, structured AND dedup keys at
   once (they all already pass `model`); it covers legacyAliases for free; and
   it invalidates PRECISELY — only entries for the repointed alias change key,
   where the epoch approach flushed the entire cache on any alias edit,
   including unrelated sonnet/haiku rows. Only the cache KEY is resolved;
   `model` is still echoed to the client verbatim, so the wire is unchanged.

2. The structured and dedup keys omitted `configEpoch` entirely, so #177 never
   actually covered the structured path: changing SYSTEM_PROMPT (the original
   #176 scenario) still served structured answers composed under the OLD
   config. Live-verified by the reviewer, independently reproduced here. Both
   now pass `configEpoch: CONFIG_EPOCH`. This has been latent for
   structured-output clients since #153 landed.

Tests: +3, all mutation-proven — the previous attempt shipped with ZERO
coverage (reverting it left the suite at 449/0), which the reviewer proved.
Built on the existing `ltBoot` child-process harness (test-features.mjs:987)
that I had wrongly claimed did not exist; a fake CLAUDE_BIN means zero quota
cost. Rather than mutate models.json mid-suite, they assert the equivalent
observable: an alias and its canonical target must land on the SAME cache slot,
which holds only if the key is resolved before hashing.

  - revert cacheModel -> model at the 3 sites  => 2 failures (both alias tests)
  - drop configEpoch from the structured hash  => 1 failure (#177-gap test)
  - restored                                   => 452 passed, 0 failed

Blast radius, previously undisclosed: on upgrade, alias-addressed cache rows
change key once and orphan, reaped by the TTL cleanup within one window — the
same shape as the v3.13.0 v1->v2 hash upgrade already documented in README.
Literal-id rows are unaffected. Default CLAUDE_CACHE_TTL is 0, so a default
deployment sees nothing.

cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping;
cli.js has no response cache. The endpoint is Class B.1 under ADR 0006, which
authorizes the OpenAI-compat surface this caching sits behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

* harden: hasOwn lookup for cacheModel + pin legacyAlias coverage with a test

Two non-blocking nits from the approving review, both worth taking:

1. `MODEL_MAP` is a plain object, so a bare `MODEL_MAP[model]` returns an
   INHERITED function for "constructor"/"toString"/"valueOf". Unreachable
   today — the VALID_MODELS gate 400s first, and it is built from
   Object.keys() so it holds only own keys — but the binding sits before that
   gate, so a bare lookup would hand cacheHash a function the day anyone
   widens the gate or moves the binding. Now Object.hasOwn-guarded.

2. legacyAlias coverage was constructive (MODEL_MAP = models + aliases +
   legacyAliases) but untested; all three tests used a plain alias. Added an
   explicit claude-haiku-4-5 vs claude-haiku-4-5-20251001 same-slot assertion.

Declined the third nit (structuredHash and dedupKey compute the same value
twice): it is cosmetic, and collapsing them changes evaluation order in a path
that is currently correct — not worth the risk in a fix PR.

Tests: 453 passed, 0 failed. Mutation now bites 3 (was 2): reverting
`cacheHash(cacheModel,` -> `cacheHash(model,` fails the normal, structured AND
legacyAlias slot tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:37:23 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
a32bc9af17 fix(prompt): OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list (closes #185) (#191)
The positive local-tools wrapper (server.mjs) claimed the model has "full access
to the local filesystem, working directory, and shell through your available
tools (Bash, Read, Write, Edit, Glob, Grep, etc.)". That over-promises when an
operator narrows CLAUDE_ALLOWED_TOOLS (e.g. to Read,Grep): the model is told it
has Bash/Write/shell it doesn't hold, then attempts a non-granted tool and gets
denied. The default set matches the text, so the primary/OpenClaw path was fine —
but the enumeration was a prompt-honesty mismatch (flagged in the #182 fact-check).

Reworded to flip the POSTURE only ("you may use your available local tools … do
not assume access beyond the tool set provided to you in this session") without
enumerating specific tools or asserting shell/write capability. The model learns
its real tools from the tool definitions claude is given (--allowedTools), so the
enumeration was redundant as well as fragile — now honest under any allowed set.

No const reordering / no CONFIG_EPOCH change (still a constant; the epoch-fold
test confirms toggling still invalidates the cache with the new text). Test
markers updated (selectPromptWrapper unit + the boot-server integration assertion
that the positive wrapper reaches the -p spawn). Suite 449/0.

Rule 2: OCP-owned prompt composition, no wire change, no cli.js citation.

Closes #185

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-23 17:38:41 +10:00
13 changed files with 901 additions and 64 deletions
+209
View File
@@ -0,0 +1,209 @@
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".
+13 -6
View File
@@ -21,24 +21,31 @@ jobs:
- name: Extract CHANGELOG section - name: Extract CHANGELOG section
id: notes id: notes
run: | run: |
set -euo pipefail
VERSION="${{ steps.ver.outputs.version }}" VERSION="${{ steps.ver.outputs.version }}"
NOTES=/tmp/release-notes.md
# Extract section for this version from CHANGELOG.md # Extract section for this version from CHANGELOG.md
# Pattern: "## v${VERSION}" through the next "## " or EOF # Pattern: "## v${VERSION}" through the next "## " or EOF
if [ ! -f CHANGELOG.md ]; then if [ ! -f CHANGELOG.md ]; then
echo "No CHANGELOG.md found; using minimal release notes" echo "No CHANGELOG.md found; using minimal release notes"
echo "notes=Release v${VERSION}" >> $GITHUB_OUTPUT # 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
exit 0 exit 0
fi fi
awk -v ver="v${VERSION}" ' awk -v ver="v${VERSION}" '
$0 ~ "^## " ver { found=1; print; next } $0 ~ "^## " ver { found=1; print; next }
found && /^## v/ { exit } found && /^## v/ { exit }
found { print } found { print }
' CHANGELOG.md > /tmp/release-notes.md ' CHANGELOG.md > "$NOTES"
if [ ! -s /tmp/release-notes.md ]; then if [ ! -s "$NOTES" ]; then
echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes" echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes"
echo "Release v${VERSION}" > /tmp/release-notes.md echo "Release v${VERSION}" > "$NOTES"
fi fi
echo "notes_file=/tmp/release-notes.md" >> $GITHUB_OUTPUT echo "--- release notes (${VERSION}) ---"; cat "$NOTES"
echo "notes_file=$NOTES" >> $GITHUB_OUTPUT
- name: Create GitHub Release - name: Create GitHub Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -49,5 +56,5 @@ jobs:
fi fi
gh release create "v${{ steps.ver.outputs.version }}" \ gh release create "v${{ steps.ver.outputs.version }}" \
--title "v${{ steps.ver.outputs.version }}" \ --title "v${{ steps.ver.outputs.version }}" \
--notes-file /tmp/release-notes.md \ --notes-file "${{ steps.notes.outputs.notes_file }}" \
--latest --latest
+17
View File
@@ -30,6 +30,7 @@ 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`. - `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.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. - `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. - `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.). - `ocp` — user-facing CLI (install, update, start, stop, status, logs, etc.).
@@ -52,6 +53,22 @@ 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 ## 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. 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.
+55
View File
@@ -1,5 +1,60 @@
# Changelog # 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 3932139364 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 ## 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. 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.
+8 -6
View File
@@ -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. `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 6 models: **Verify** — should list 7 models:
```bash ```bash
curl http://127.0.0.1:3456/v1/models curl http://127.0.0.1:3456/v1/models
# 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-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
``` ```
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode): **Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
@@ -169,14 +169,15 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
| Model ID | Notes | | Model ID | Notes |
|----------|-------| |----------|-------|
| `claude-opus-4-8` | Most capable (default for `opus` alias) | | `claude-opus-5` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning | | `claude-opus-4-8` | Previous Opus, retained for pinning |
| `claude-opus-4-7` | Older Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning | | `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) | | `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning | | `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) | | `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. 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, 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:
```bash ```bash
# 1. Edit models.json — add an entry # 1. Edit models.json — add an entry
@@ -387,7 +388,7 @@ curl -X DELETE http://127.0.0.1:3456/cache # clear all cached responses
ocp settings cacheTTL 0 # disable at runtime 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`. **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`. **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.
## Structured Outputs (OpenAI `response_format`) ## Structured Outputs (OpenAI `response_format`)
@@ -540,6 +541,7 @@ 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. - **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. - **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)**. 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)**.
+6 -5
View File
@@ -78,7 +78,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:** **Verify:**
```bash ```bash
curl http://127.0.0.1:3456/v1/models curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001 # 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
``` ```
### Headless install notes ### 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 installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager. using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`. 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 6 models). 3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 7 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc. 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. 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). 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. 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`. 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 6 models. 8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 7 models.
Tell me each step before running it. On error, diagnose before retrying. 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 chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one). 2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints. 3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models. 4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 7 models.
5. Tell me to reload my shell + restart any IDE that was already running. 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. 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) (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access... Testing API access...
✓ API accessible (6 models available) ✓ API accessible (7 models available)
Shell config: Shell config:
✓ .bashrc ✓ .bashrc
@@ -246,6 +246,7 @@ OCP Connect v1.3.0
✓ OpenClaw configured ✓ OpenClaw configured
Provider: ocp Provider: ocp
Models: Models:
• ocp/claude-opus-5
• ocp/claude-opus-4-8 • ocp/claude-opus-4-8
• ocp/claude-opus-4-7 • ocp/claude-opus-4-7
• ocp/claude-opus-4-6 • ocp/claude-opus-4-6
+16
View File
@@ -116,6 +116,22 @@ openclaw gateway restart # so OpenClaw re-reads the config
Future `ocp update` invocations sync automatically. 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> <a id="tui-401"></a>
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick) ### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
+15 -7
View File
@@ -2,13 +2,21 @@
"$schema": "./models.schema.json", "$schema": "./models.schema.json",
"version": 1, "version": 1,
"models": [ "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", "id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8", "displayName": "Claude Opus 4.8",
"openclawName": "Claude Opus 4.8 (via CLI)", "openclawName": "Claude Opus 4.8 (via CLI)",
"reasoning": true, "reasoning": true,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 64000
}, },
{ {
"id": "claude-opus-4-7", "id": "claude-opus-4-7",
@@ -16,7 +24,7 @@
"openclawName": "Claude Opus 4.7 (via CLI)", "openclawName": "Claude Opus 4.7 (via CLI)",
"reasoning": true, "reasoning": true,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 64000
}, },
{ {
"id": "claude-opus-4-6", "id": "claude-opus-4-6",
@@ -24,7 +32,7 @@
"openclawName": "Claude Opus 4.6 (via CLI)", "openclawName": "Claude Opus 4.6 (via CLI)",
"reasoning": true, "reasoning": true,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 64000
}, },
{ {
"id": "claude-sonnet-5", "id": "claude-sonnet-5",
@@ -32,7 +40,7 @@
"openclawName": "Claude Sonnet 5 (via CLI)", "openclawName": "Claude Sonnet 5 (via CLI)",
"reasoning": true, "reasoning": true,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 64000
}, },
{ {
"id": "claude-sonnet-4-6", "id": "claude-sonnet-4-6",
@@ -40,7 +48,7 @@
"openclawName": "Claude Sonnet 4.6 (via CLI)", "openclawName": "Claude Sonnet 4.6 (via CLI)",
"reasoning": true, "reasoning": true,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 32000
}, },
{ {
"id": "claude-haiku-4-5-20251001", "id": "claude-haiku-4-5-20251001",
@@ -48,11 +56,11 @@
"openclawName": "Claude Haiku 4.5 (via CLI)", "openclawName": "Claude Haiku 4.5 (via CLI)",
"reasoning": false, "reasoning": false,
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 8192 "maxTokens": 32000
} }
], ],
"aliases": { "aliases": {
"opus": "claude-opus-4-8", "opus": "claude-opus-5",
"sonnet": "claude-sonnet-5", "sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5-20251001" "haiku": "claude-haiku-4-5-20251001"
}, },
+65
View File
@@ -0,0 +1,65 @@
{
"$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" }
}
}
}
+25 -3
View File
@@ -129,10 +129,25 @@ provider = {
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model # 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 # edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
# expose reasoning/maxTokens, so family classification stays here.) # 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 = { model_meta = {
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 64000},
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 32000},
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192}, "claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 32000},
} }
def get_model_meta(mid): def get_model_meta(mid):
@@ -140,6 +155,13 @@ def get_model_meta(mid):
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])): for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
if mid.startswith(prefix): if mid.startswith(prefix):
return meta 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} return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
for mid in model_ids: for mid in model_ids:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "name": "open-claude-proxy",
"version": "3.24.0", "version": "3.26.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.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+41 -5
View File
@@ -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 // 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 // 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. // 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. 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.`; 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.`;
// OCP_LOCAL_TOOLS is inert in TUI mode: the interactive (non-`-p`) path composes its own prompt via // 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 // callClaudeTui/messagesToPrompt and never calls extractSystemPrompt, so the wrapper is only ever
@@ -1371,7 +1371,6 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
promptChars = stdinPayload.length; promptChars = stdinPayload.length;
} }
stats.activeRequests++;
stats.totalRequests++; stats.totalRequests++;
stats.oneOffRequests++; stats.oneOffRequests++;
if (conversationId) { if (conversationId) {
@@ -1414,6 +1413,17 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
const proc = spawn(CLAUDE, cliArgs, spawnOpts); const proc = spawn(CLAUDE, cliArgs, spawnOpts);
activeProcesses.add(proc); 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(); const t0 = Date.now();
let gotFirstByte = false; let gotFirstByte = false;
@@ -2804,6 +2814,20 @@ async function handleChatCompletions(req, res) {
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }]; const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || modelsConfig.aliases.sonnet; 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; const stream = parsed.stream;
// Validate model against known models // Validate model against known models
@@ -2899,8 +2923,18 @@ async function handleChatCompletions(req, res) {
const t0s = Date.now(); const t0s = Date.now();
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0); const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let structuredHash = null; 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)) { if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured }); structuredHash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH });
try { try {
const cached = getCachedResponse(structuredHash, CACHE_TTL); const cached = getCachedResponse(structuredHash, CACHE_TTL);
if (cached) { if (cached) {
@@ -2918,8 +2952,10 @@ async function handleChatCompletions(req, res) {
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every // 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 // 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. // 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)) const dedupKey = (!conversationId && !hasCacheControl(messages))
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured }) ? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH })
: null; : null;
const runStructured = async () => { const runStructured = async () => {
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured); const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
@@ -2968,7 +3004,7 @@ async function handleChatCompletions(req, res) {
} else { } else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format). // 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. // configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH }); const hash = cacheHash(cacheModel, 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 req._cacheHash = hash; // store for later write-back
try { try {
const cached = getCachedResponse(hash, CACHE_TTL); const cached = getCachedResponse(hash, CACHE_TTL);
+430 -31
View File
@@ -910,7 +910,7 @@ test("appendOperatorPrompt: operator value is trimmed before appending", () => {
console.log("\nOCP_LOCAL_TOOLS wrapper + safety gate:"); console.log("\nOCP_LOCAL_TOOLS wrapper + safety gate:");
const NEG = "You do NOT have access to any local filesystem"; const NEG = "You do NOT have access to any local filesystem";
const POS = "You have full access to the local filesystem"; const POS = "you may use your available local tools";
test("selectPromptWrapper: default (disabled) returns the negative wrapper BYTE-IDENTICAL", () => { test("selectPromptWrapper: default (disabled) returns the negative wrapper BYTE-IDENTICAL", () => {
// Mutation-proof: flip the ternary and the default path leaks the positive wrapper. // Mutation-proof: flip the ternary and the default path leaks the positive wrapper.
@@ -959,7 +959,8 @@ 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 // 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 // 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. // silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
import { spawn as _ltSpawn } from "node:child_process"; import { spawn as _ltSpawn, execFileSync as _ltExecFile } from "node:child_process";
import { createServer as _ltNetServer } from "node:net";
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs"; 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 { tmpdir as _ltTmp } from "node:os";
import { fileURLToPath as _ltF2P } from "node:url"; import { fileURLToPath as _ltF2P } from "node:url";
@@ -967,7 +968,7 @@ import { fileURLToPath as _ltF2P } from "node:url";
const LT_SERVER = _ltF2P(new URL("./server.mjs", import.meta.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_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_NEG_MARK = "You do NOT have access to any local filesystem";
const LT_POS_MARK = "You have full access to the local filesystem"; const LT_POS_MARK = "you may use your available local tools";
// Fake claude: record the --system-prompt it was spawned with, bump an optional spawn counter, // 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). // then emit a minimal valid stream-json response so the request completes (and caches).
const LT_FAKE = `#!/bin/sh const LT_FAKE = `#!/bin/sh
@@ -984,23 +985,76 @@ exit 0
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); } 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 ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
function ltBoot(env, dir) { function ltBoot(env, dir, nodeArgs = []) {
const child = _ltSpawn(process.execPath, [LT_SERVER], { const child = _ltSpawn(process.execPath, [...nodeArgs, LT_SERVER], {
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1", 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 }, CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
const buf = { out: "", err: "", exit: undefined }; const buf = { out: "", err: "", exit: undefined, signal: undefined, closed: false, closeMs: undefined, spawnErr: null, t0: Date.now() };
child.stdout.on("data", d => { buf.out += d; }); child.stdout.on("data", d => { buf.out += d; });
child.stderr.on("data", d => { buf.err += d; }); child.stderr.on("data", d => { buf.err += d; });
child.on("exit", code => { buf.exit = code; }); // '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; });
return { child, buf }; 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) { async function ltWait(cond, ms = 9000) {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); } while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
return false; 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) { async function ltPost(port, body) {
try { try {
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
@@ -1014,29 +1068,31 @@ 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 () => { 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 if (!LT_POSIX) return; // sh fake — skip on Windows CI
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir); const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", SP_CAPTURE: cap }, 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);
try { try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`); assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
await ltPost(39321, { model: "sonnet", messages: [{ role: "user", content: "hi" }] }); await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt"); assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
const sp = _ltRead(cap, "utf8"); 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_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"); assert.ok(!sp.includes(LT_NEG_MARK), "positive wrapper must REPLACE the negative one, not append");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); } } finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
}); });
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => { test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
if (!LT_POSIX) return; if (!LT_POSIX) return;
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir); const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset const port = await ltFreePort();
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
try { try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`); assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
await ltPost(39322, { model: "sonnet", messages: [{ role: "user", content: "hi" }] }); await ltPost(port, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt"); assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
const sp = _ltRead(cap, "utf8"); const sp = _ltRead(cap, "utf8");
// No system messages + no CLAUDE_SYSTEM_PROMPT → the wrapper is passed verbatim. // 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.`); 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"); _ltRm(dir, { recursive: true, force: true }); } } finally { child.kill("SIGKILL"); _ltRmRetry(dir); }
}); });
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => { test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
@@ -1048,25 +1104,47 @@ test("integration: boot gate REFUSES each unsafe config (multi / non-loopback /
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } }, { label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
]; ];
try { try {
for (const [i, c] of cases.entries()) { for (const c of cases) {
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(39330 + i), ...c.env }, dir); const port = await ltFreePort();
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), ...c.env }, dir);
try { try {
assert.ok(await ltWait(() => buf.exit != null), `[${c.label}] expected the process to exit`); // Wait for `closed`, not `exit`: the assertion below reads buf.err, and stderr is only
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero`); // guaranteed drained at 'close'. This is the ordering #203 was filed for.
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL, got: ${buf.err.slice(0,160)}`); 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)}`);
} finally { child.kill("SIGKILL"); } } finally { child.kill("SIGKILL"); }
} }
} finally { _ltRm(dir, { recursive: true, force: true }); } } finally { _ltRmRetry(dir); }
}); });
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => { test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
if (!LT_POSIX) return; if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir); const dir = ltMkdir(); const fake = ltFake(dir);
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none const port = await ltFreePort();
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port) }, dir); // loopback + none
try { try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `safe config must boot, got: ${buf.err.slice(0,200)}`); // Same race as #199, one line over: "Local tools: ON" (server.mjs:3640) is written 12
assert.ok(buf.out.includes("Local tools: ON"), "startup must announce local tools when active"); // console.log calls after "listening on" (:3627) — 10 of them in this env, since the
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); } // 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); }
}); });
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => { test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
@@ -1074,12 +1152,22 @@ test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not ref
const dir = ltMkdir(); const fake = ltFake(dir); const dir = ltMkdir(); const fake = ltFake(dir);
// Non-loopback would normally trip the local-tools gate; under TUI the flag is inert so the // 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. // gate must NOT fire on its behalf. Use loopback here to isolate TUI's own guards from ours.
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir); 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);
try { try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `did not start: ${buf.err.slice(0,200)}`); // Wait for the line actually under assertion, not for a proxy signal. "listening on" and the
assert.ok(!buf.out.includes("Local tools: ON"), "must NOT claim local tools are ON in TUI mode (the wrapper is unused there)"); // inert-flag warning are written independently, so gating on the former and then asserting
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err), "must warn that OCP_LOCAL_TOOLS is inert under TUI"); // the latter is a race — the flake #199 was filed for. Still bounded by the same timeout, and
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); } // 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); }
}); });
test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => { test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => {
@@ -1097,11 +1185,196 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
} finally { child.kill("SIGKILL"); } } finally { child.kill("SIGKILL"); }
}; };
try { try {
const off = await bootOnce({}, 39350); // caches "OK" under epoch(negative) const off = await bootOnce({}, await ltFreePort()); // caches "OK" under epoch(negative)
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39351); // same DB, epoch(positive) → must MISS → re-spawn const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, await ltFreePort()); // same DB, epoch(positive) → must MISS → re-spawn
assert.equal(off, 1, "first request (cache empty) must spawn claude"); 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)"); assert.equal(on, 1, "after toggling the flag the identical request must NOT be served from the old cache (epoch differs → re-spawn)");
} finally { _ltRm(dir, { recursive: true, force: true }); } } 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); }
}); });
// ── Upgrade Tests ── // ── Upgrade Tests ──
@@ -4067,6 +4340,10 @@ test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SP
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5"); 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) ────────────────────────────────── // ── Referential integrity (PR #152 review) ──────────────────────────────────
// The value-mirror assertions above only prove the alias equals a string literal — // 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 // they pass even if that literal points at a model that does not exist in
@@ -4080,18 +4357,140 @@ 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"); 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)", () => { test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.aliases)) { 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)`); 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)", () => { test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) { 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)`); 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) ──────────────────────────── // ── escapeHtml + key-name validator (issue #114) ────────────────────────────
// Replicated verbatim from dashboard.html so tests run without a browser. // Replicated verbatim from dashboard.html so tests run without a browser.
function escapeHtml(s) { function escapeHtml(s) {