Compare commits

..
Author SHA1 Message Date
taodengandClaude Opus 4.8 e031df3f99 fix(phase6c): fold review findings — scrub hostname, README prose, parser tests, version citation
P3-1 (binding): scrub private hostname "PI231" from server.mjs comment; replace
with role-based term "the test server". Repo is public — no machine-specific
hostnames may appear.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:52:29 +10:00
taodengandClaude Opus 4.8 324c206f95 wip(rescue): OCP Phase 6c stream-json port + opus 4.8 (uncommitted /tmp work)
Rescue commit — preserves the subagent-produced Phase 6c port (claude -p →
stream-json + --system-prompt + NDJSON parser) and opus 4.8 model addition
done 2026-05-30 in a /tmp clone (volatile). NOT reviewed-for-merge; this is
preservation only on a WIP branch so a /tmp reboot does not lose the work.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:14:03 +10:00
6 changed files with 559 additions and 373 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## Unreleased
### Model — add claude-opus-4-8
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
---
## v3.16.4 — 2026-05-13
### Refactor — port-literal SPOT + CI guardrail
+11 -9
View File
@@ -116,7 +116,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
@@ -142,7 +142,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
Tell me each step before running it. On error, diagnose before retrying.
```
@@ -165,7 +165,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
@@ -235,7 +235,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
#### Headless install notes
@@ -314,7 +314,7 @@ OCP Connect v1.3.0
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (4 models available)
✓ API accessible (5 models available)
Shell config:
✓ .bashrc
@@ -344,6 +344,7 @@ OCP Connect v1.3.0
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-4-8
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
• ocp/claude-sonnet-4-6
@@ -674,17 +675,18 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca
## How It Works
```
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
```
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
## Available Models
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
| `claude-opus-4-6` | Previous Opus, retained for pinning |
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
-268
View File
@@ -1,268 +0,0 @@
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
**Status:** Forward-looking planning doc (not yet a decision)
**Date:** 2026-05-29
**Audience:** future OCP maintainer / session picking up multi-tenant security work
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
---
## 1. Why this doc exists
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
---
## 2. The multi-tenant gap (OCP-specific)
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
---
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
### 3.1 Anthropic's design doesn't expect external sandboxing
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
### 3.2 `~/.claude.json` upstream status is "closed not planned"
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
### 3.3 The threat model doesn't justify the cost
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
### 3.4 OLP-specific reason that does NOT apply to OCP
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.13.3) are sufficient on their own.
---
## 4. Three viable approaches for OCP
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
Per-spawn setup:
```
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
mkdir -p $ephemeralRoot/.claude
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
HOME=$ephemeralRoot claude -p --output-format stream-json ...
```
Mechanics:
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
Threat coverage:
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
5-minute spike before adopting:
```bash
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
```
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
```
bwrap \
--ro-bind / / \
--tmpfs /home/<ocp-user> \
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
--dev /dev --proc /proc --tmpfs /tmp \
claude -p ...
```
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
Threat coverage:
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
-`~/.ssh` and similar identity material absent from sandbox.
Trade-offs:
- bwrap dependency: install `bubblewrap` apt package on host.
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
```
mount -t overlay overlay \
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
/tmp/ocp-spawn/<reqId>/merged-claude
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
# After spawn: umount + rm -rf
```
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
Trade-offs:
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
---
## 5. Cross-key isolation orthogonal layer
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
For that, two options compose with any of A/B/C:
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
```
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
filesystem: {
denyRead: [
...otherKeysWorkspaces, // all keys except current
'/home/<ocp-user>/.ssh',
'/home/<ocp-user>/.gnupg',
'/home/<ocp-user>/.aws',
],
allowWrite: [ephemeralRoot, '/tmp'],
},
})
```
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
### 5.2 Per-OS-user OCP spawning
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
Trade-offs:
- Setup script complexity (one-time per key).
- Linux-only.
- Doesn't fit Mac mini deployment.
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
---
## 6. Trust model framing
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
| Tier | Trust Model | Sandbox requirement |
|---|---|---|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
---
## 7. Recommendation if/when this work starts
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
- ~50 LOC, no apt deps, works on Mac mini + Linux
- Solves the EROFS upgrade tax structurally
- Closes cross-key OAuth-credential-file lateral read
- Cost-effective hygiene improvement
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
- Default off (preserves Mac mini family deployment)
- Operator opts in on Linux cloud deployments
- Apt prereq documented in deployment guide
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
- Treat as separate ADR amendment with its own threat-model evidence
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
---
## 8. Authority citations
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
- [Bubblewrap README](https://github.com/containers/bubblewrap)
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
---
## 9. What this doc is NOT
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
---
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
+9 -1
View File
@@ -2,6 +2,14 @@
"$schema": "./models.schema.json",
"version": 1,
"models": [
{
"id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8",
"openclawName": "Claude Opus 4.8 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-opus-4-7",
"displayName": "Claude Opus 4.7",
@@ -36,7 +44,7 @@
}
],
"aliases": {
"opus": "claude-opus-4-7",
"opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001"
},
+289 -95
View File
@@ -2,7 +2,7 @@
/**
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* Translates OpenAI chat/completions requests into `claude --output-format stream-json` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
@@ -122,6 +122,137 @@ function resolveClaude() {
process.exit(1);
}
// ── OCP system prompt wrapper (Phase 6c port — ADR 0009 Amendment 1 analogue) ─
// Injected via `--system-prompt` flag, replacing claude CLI's default system
// prompt (which normally includes cwd, OS, tool descriptions, and git status —
// all irrelevant and potentially misleading when the model is accessed via the
// OCP HTTP proxy).
//
// Authority: claude CLI § --system-prompt (ported from OLP, verified v2.1.104;
// behavior stable through v2.1.158 — OLP ADR 0009 Amendment 1 §
// "OLP system prompt wrapper"; ported to OCP 2026-05-30).
// Reference: https://github.com/dtzp555-max/olp commit 97e7d16 (Phase 6c)
const OCP_SYSTEM_PROMPT_WRAPPER = `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.`;
// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended,
// then any system-role messages from the request appended (separated by blank line).
// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
function extractSystemPrompt(messages) {
const systemMessages = (messages ?? []).filter(m => m.role === "system");
if (systemMessages.length === 0) {
return OCP_SYSTEM_PROMPT_WRAPPER;
}
const clientContent = systemMessages.map(m =>
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
).join("\n\n");
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
}
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
// Splits a buffered string on newlines, returning complete parsed events
// plus the trailing incomplete line as `remainder` for the next data chunk.
//
// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104;
// behavior stable through v2.1.158; each event is a newline-terminated JSON object on stdout).
// Reference: OLP lib/providers/anthropic.mjs parseStreamJsonLines (commit 97e7d16).
function parseStreamJsonLines(buffered) {
const lines = buffered.split("\n");
const remainder = lines.pop(); // last element is the incomplete trailing line
const events = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
events.push(JSON.parse(trimmed));
} catch {
console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120));
events.push({ type: "parse_error", raw: trimmed });
}
}
return { events, remainder: remainder ?? "" };
}
// ── NDJSON event → text content extractor (Phase 6c port) ────────────────
// Maps claude CLI stream-json NDJSON events to { text, stop, error } signals.
// Returns:
// { text: string } — content delta to forward
// { stop: true } — terminal event (emit finish_reason=stop)
// { error: string } — error event (emit error stop)
// null — consumed event (log/ignore)
//
// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104;
// behavior stable through v2.1.158).
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
//
// @param {object} event — parsed NDJSON event
// @param {boolean} isFirstDelta — true if no content has been yielded yet
function parseStreamJsonEvent(event, isFirstDelta) {
const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.)
if (t === "system") return null;
// user — echo of user message; consumed
if (t === "user") return null;
// stream_event — contains nested content_block_delta
if (t === "stream_event") {
const inner = event.event ?? event;
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
return { text: inner.delta.text ?? "" };
}
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null;
}
// assistant — aggregate message (fallback when no prior content_block_delta seen)
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") {
if (isFirstDelta) {
const blocks = event.message?.content;
if (Array.isArray(blocks)) {
const text = blocks
.filter(b => b && b.type === "text" && typeof b.text === "string")
.map(b => b.text)
.join("");
if (text) return { text };
}
}
return null;
}
// result — terminal event
if (t === "result") {
if (event.is_error === true) {
return { error: event.error_message ?? event.result ?? "claude returned is_error" };
}
return { stop: true };
}
// rate_limit_event / usage — log for observability, don't forward
if (t === "rate_limit_event" || t === "usage") {
logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) });
return null;
}
// control_request — per Anthropic stream-json docs
if (t === "control_request") {
console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120));
return null;
}
// parse_error — already logged by parseStreamJsonLines
if (t === "parse_error") return null;
// Unknown event type — log + skip; future-proof for new claude CLI events
if (t !== undefined) {
console.error("[claude] unknown stream_json event type:", t);
}
return null;
}
// ── Configuration ───────────────────────────────────────────────────────
// Settings marked with `let` can be changed at runtime via PATCH /settings.
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10);
@@ -381,17 +512,29 @@ checkAuth();
const authCheckInterval = setInterval(checkAuth, 600000);
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
// Phase 6c port (2026-05-30): removed `-p` / `--output-format text`.
// Now uses `--output-format stream-json --verbose --no-session-persistence
// --system-prompt <OCP_SYSTEM_PROMPT_WRAPPER + client system messages>`.
//
// Authority: claude CLI § --output-format stream-json, § --verbose,
// § --no-session-persistence, § --system-prompt (ported from OLP, verified v2.1.104;
// behavior stable through v2.1.158).
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
//
// Session flags (--resume, --session-id) are dropped: they are incompatible
// with stream-json mode without -p. OCP always passes full conversation context
// via stdin instead (messagesToPrompt), preserving multi-turn correctness.
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
function buildCliArgs(cliModel, systemPrompt) {
const args = [
"--model", cliModel,
"--output-format", "stream-json",
"--verbose",
"--no-session-persistence",
"--system-prompt", systemPrompt,
];
// Permissions
if (SKIP_PERMISSIONS) {
@@ -400,11 +543,6 @@ function buildCliArgs(cliModel, sessionInfo) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
@@ -475,8 +613,15 @@ function getModelTier(cliModel) {
}
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
// Resolves session logic, builds CLI args, spawns the process, and sets up
// timeouts. Returns context object or throws synchronously.
// Builds CLI args, spawns the process, and sets up timeouts.
// Returns context object or throws synchronously.
//
// Phase 6c port (2026-05-30): session resume (--resume / --session-id) is
// dropped because it is incompatible with stream-json mode without -p.
// OCP now always passes the full serialized conversation via stdin
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
// The sessions Map is retained for stats/logging but no longer drives --resume.
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
function spawnClaudeProcess(model, messages, conversationId, keyName) {
if (stats.activeRequests >= MAX_CONCURRENT) {
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
@@ -489,43 +634,22 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// Phase 6c: always serialize full conversation via stdin (no session resume).
// System messages are extracted and passed via --system-prompt; the remaining
// messages (user/assistant/tool) are serialized by messagesToPrompt.
const systemPrompt = extractSystemPrompt(messages);
// ── Session logic ──
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
// when two callers with different API keys share the same conversationId string.
const sessionKey = _sessionKey(conversationId, keyName);
if (sessionKey && sessions.has(sessionKey)) {
const session = sessions.get(sessionKey);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
// messagesToPrompt skips system messages now that they go via --system-prompt.
// Filter them out before calling to avoid double-injection.
const nonSystemMessages = messages.filter(m => m.role !== "system");
const prompt = messagesToPrompt(nonSystemMessages);
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (sessionKey) {
const uuid = randomUUID();
const now = Date.now();
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
stats.oneOffRequests++;
if (conversationId) {
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const cliArgs = buildCliArgs(cliModel, systemPrompt);
const env = { ...process.env };
delete env.CLAUDECODE;
@@ -563,15 +687,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
proc.once("exit", cleanup);
function handleSessionFailure() {
if (sessionInfo?.resume && sessionKey) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
sessions.delete(sessionKey);
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
// #41 evidence-gathering: session-create failures currently leave a stale entry
// in the sessions map. Log without action so the staleness pattern can be
// confirmed in /logs before any code change. Do NOT delete here speculatively.
logEvent("warn", "session_failure", { mode: "create", conversationId: conversationId.slice(0, 12) + "...", action: "kept" });
// Phase 6c: session resume (--resume/--session-id) is no longer used;
// OCP always passes full context via stdin. No session state to clean up.
if (conversationId) {
logEvent("warn", "session_failure", { mode: "stateless", conversationId: conversationId.slice(0, 12) + "...", action: "none" });
}
}
@@ -607,9 +726,14 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
}
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
// On-demand spawning: each request spawns a fresh `claude -p` process.
// On-demand spawning: each request spawns a fresh claude process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
//
// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format).
// We accumulate full text across all content_block_delta events plus the
// assistant-aggregate fallback, then resolve with the assembled string.
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
function callClaude(model, messages, conversationId, keyName) {
return new Promise((resolve, reject) => {
let ctx;
@@ -620,12 +744,30 @@ function callClaude(model, messages, conversationId, keyName) {
}
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let stdout = "";
let lineBuffer = "";
let assembledText = "";
let isFirstDelta = true;
let resultEventSeen = false;
let stderr = "";
proc.stdout.on("data", (d) => {
markFirstByte();
stdout += d;
lineBuffer += d.toString();
const { events, remainder } = parseStreamJsonLines(lineBuffer);
lineBuffer = remainder;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta);
if (!parsed) continue;
if (parsed.text !== undefined) {
assembledText += parsed.text;
isFirstDelta = false;
} else if (parsed.stop) {
resultEventSeen = true;
} else if (parsed.error) {
// is_error result — treat as process error
reject(new Error(parsed.error));
}
}
});
proc.stderr.on("data", (d) => (stderr += d));
@@ -633,17 +775,19 @@ function callClaude(model, messages, conversationId, keyName) {
activeProcesses.delete(proc);
const elapsed = Date.now() - t0;
cleanup();
if (code !== 0) {
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
// as OLP commit 2864275 — bwrap shell exits null after model completes).
if (code !== 0 && !resultEventSeen) {
recordModelError(cliModel, false);
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`);
trackError(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`);
handleSessionFailure();
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
reject(new Error(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`));
} else {
recordModelSuccess(cliModel, elapsed);
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
resolve(stdout.trim());
logEvent("info", "claude_ok", { model: cliModel, chars: assembledText.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
resolve(assembledText);
}
});
@@ -685,9 +829,14 @@ function startHeartbeat(res, intervalMs, sessionId) {
}
// ── Call claude CLI (real streaming) ─────────────────────────────────────
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
// Each data chunk becomes a proper SSE event with delta content in real time.
// Pipes stdout from the claude process as SSE chunks as they arrive.
// Each NDJSON content_block_delta text event becomes one SSE delta.
// TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat.
//
// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format).
// We parse line-by-line and forward content_block_delta text events as SSE.
// The result event triggers the stop/[DONE] sequence.
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
@@ -704,6 +853,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
let headersSent = false;
let totalChars = 0;
let cachedContent = ""; // accumulate for cache write-back
let lineBuffer = "";
let isFirstDelta = true;
let resultEventSeen = false;
function ensureHeaders() {
if (res.writableEnded || res.destroyed) return false;
@@ -724,27 +876,63 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
}
// D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the
// heartbeat started in the next statement (Task 1.3) covers the
// pre-first-byte silent window. Behavior change: the `code !== 0`
// before-first-byte branch at server.mjs:610-611 becomes effectively
// unreachable in the common case — the post-headers SSE-stop path
// (612-619) handles it instead.
// heartbeat started in the next statement covers the pre-first-byte silent window.
ensureHeaders();
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
proc.stdout.on("data", (d) => {
markFirstByte();
const text = d.toString();
totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text;
lineBuffer += d.toString();
const { events, remainder } = parseStreamJsonLines(lineBuffer);
lineBuffer = remainder;
if (!ensureHeaders()) return;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta);
if (!parsed) continue;
// Stream each chunk as it arrives from the CLI process
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
}, hb);
if (parsed.text !== undefined) {
// content_block_delta text — forward as SSE delta
const text = parsed.text;
totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text;
isFirstDelta = false;
if (!ensureHeaders()) continue;
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
}, hb);
} else if (parsed.stop) {
// result event — emit stop and [DONE] immediately
resultEventSeen = true;
if (!ensureHeaders()) continue;
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
if (!res.writableEnded && !res.destroyed) {
res.write("data: [DONE]\n\n");
res.end();
}
} else if (parsed.error) {
// is_error result — emit error stop
resultEventSeen = true;
logEvent("error", "claude_result_error", { model: cliModel, error: parsed.error.slice(0, 200) });
trackError(parsed.error.slice(0, 200));
if (!headersSent && !res.writableEnded && !res.destroyed) {
jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } });
} else if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
res.write("data: [DONE]\n\n");
res.end();
}
}
}
});
proc.stderr.on("data", (d) => (stderr += d));
@@ -755,7 +943,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
cleanup();
const elapsed = Date.now() - t0;
if (code !== 0) {
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
// as OLP commit 2864275 — bwrap shell exits null after model completes).
if (code !== 0 && !resultEventSeen) {
recordModelError(cliModel, false);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
@@ -782,14 +972,18 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
if (!headersSent) ensureHeaders();
if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
res.write("data: [DONE]\n\n");
res.end();
// If result event already closed the response, nothing more to do.
// Otherwise emit a synthetic stop (version drift safety net, same as OLP).
if (!resultEventSeen) {
if (!headersSent) ensureHeaders();
if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
res.write("data: [DONE]\n\n");
res.end();
}
}
}
});
@@ -1820,7 +2014,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude --output-format stream-json (per-request process)`);
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
+242
View File
@@ -951,6 +951,248 @@ await asyncTest("doctor --check oauth + 200 with null body → fix_service", asy
assert.equal(result.fail_count, 1);
});
// ── Stream-JSON parser tests ──────────────────────────────────────────────
// MIRRORS server.mjs parseStreamJsonLines/parseStreamJsonEvent — keep in sync.
// Copied verbatim to avoid importing server.mjs (top-level server.listen() would
// start a live HTTP server). The logEvent stub silences observability side-effects.
console.log("\nStream-JSON parsers:");
function logEvent() {} // stub — observability side-effect not needed in tests
function parseStreamJsonLines(buffered) {
const lines = buffered.split("\n");
const remainder = lines.pop(); // last element is the incomplete trailing line
const events = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
events.push(JSON.parse(trimmed));
} catch {
console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120));
events.push({ type: "parse_error", raw: trimmed });
}
}
return { events, remainder: remainder ?? "" };
}
function parseStreamJsonEvent(event, isFirstDelta) {
const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.)
if (t === "system") return null;
// user — echo of user message; consumed
if (t === "user") return null;
// stream_event — contains nested content_block_delta
if (t === "stream_event") {
const inner = event.event ?? event;
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
return { text: inner.delta.text ?? "" };
}
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null;
}
// assistant — aggregate message (fallback when no prior content_block_delta seen)
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") {
if (isFirstDelta) {
const blocks = event.message?.content;
if (Array.isArray(blocks)) {
const text = blocks
.filter(b => b && b.type === "text" && typeof b.text === "string")
.map(b => b.text)
.join("");
if (text) return { text };
}
}
return null;
}
// result — terminal event
if (t === "result") {
if (event.is_error === true) {
return { error: event.error_message ?? event.result ?? "claude returned is_error" };
}
return { stop: true };
}
// rate_limit_event / usage — log for observability, don't forward
if (t === "rate_limit_event" || t === "usage") {
logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) });
return null;
}
// control_request — per Anthropic stream-json docs
if (t === "control_request") {
console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120));
return null;
}
// parse_error — already logged by parseStreamJsonLines
if (t === "parse_error") return null;
// Unknown event type — log + skip; future-proof for new claude CLI events
if (t !== undefined) {
console.error("[claude] unknown stream_json event type:", t);
}
return null;
}
// (a) content_block_delta deltas + assistant-aggregate fallback → assembled text with NO double-count
test("parseStreamJsonEvent: stream_event content_block_delta yields text", () => {
const event = {
type: "stream_event",
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
};
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Hello" });
});
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Short answer." });
});
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, false);
assert.equal(result, null);
});
test("parseStreamJsonEvent: stream_event + assistant → assembled without double-count", () => {
// Simulate receiving a content_block_delta first, then an assistant aggregate
const delta = {
type: "stream_event",
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Streaming text." } }
};
const agg = {
type: "assistant",
message: { content: [{ type: "text", text: "Streaming text." }] }
};
// First event: isFirstDelta=true → yields text
const r1 = parseStreamJsonEvent(delta, true);
assert.deepEqual(r1, { text: "Streaming text." });
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
const r2 = parseStreamJsonEvent(agg, false);
assert.equal(r2, null);
});
// (b) aggregate-only short response → assembles correctly
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
const event = {
type: "assistant",
message: {
content: [
{ type: "text", text: "Part one." },
{ type: "tool_use", id: "x" }, // non-text block — should be filtered
{ type: "text", text: " Part two." }
]
}
};
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Part one. Part two." });
});
// (c) JSON line split across two parseStreamJsonLines calls → partial-line buffering
test("parseStreamJsonLines: partial line carried as remainder", () => {
const chunk1 = '{"type":"system","subtype":"init"}\n{"type":"stream_ev';
const { events: ev1, remainder: rem1 } = parseStreamJsonLines(chunk1);
assert.equal(ev1.length, 1);
assert.equal(ev1[0].type, "system");
assert.equal(rem1, '{"type":"stream_ev');
const chunk2 = rem1 + 'ent","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n';
const { events: ev2, remainder: rem2 } = parseStreamJsonLines(chunk2);
assert.equal(ev2.length, 1);
assert.equal(ev2[0].type, "stream_event");
assert.equal(rem2, "");
// Verify the reassembled event parses through parseStreamJsonEvent correctly
const parsed = parseStreamJsonEvent(ev2[0], true);
assert.deepEqual(parsed, { text: "Hi" });
});
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
const { events, remainder } = parseStreamJsonLines("");
assert.equal(events.length, 0);
assert.equal(remainder, "");
});
// (d) is_error result event → surfaces the error
test("parseStreamJsonEvent: result is_error=true surfaces error_message", () => {
const event = { type: "result", is_error: true, error_message: "Rate limit hit" };
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { error: "Rate limit hit" });
});
test("parseStreamJsonEvent: result is_error=true falls back to result field when no error_message", () => {
const event = { type: "result", is_error: true, result: "error detail" };
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { error: "error detail" });
});
test("parseStreamJsonEvent: result is_error=true falls back to default string when no detail", () => {
const event = { type: "result", is_error: true };
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { error: "claude returned is_error" });
});
test("parseStreamJsonEvent: result is_error=false yields stop", () => {
const event = { type: "result", is_error: false, result: "success" };
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { stop: true });
});
// (e) malformed/non-JSON line → skipped without throwing
test("parseStreamJsonLines: malformed JSON line becomes parse_error event without throwing", () => {
const input = '{"type":"system"}\nnot-valid-json\n{"type":"result","is_error":false}\n';
const { events, remainder } = parseStreamJsonLines(input);
assert.equal(events.length, 3);
assert.equal(events[0].type, "system");
assert.equal(events[1].type, "parse_error");
assert.equal(events[1].raw, "not-valid-json");
assert.equal(events[2].type, "result");
});
test("parseStreamJsonEvent: parse_error event returns null without throwing", () => {
const event = { type: "parse_error", raw: "garbage" };
const result = parseStreamJsonEvent(event, false);
assert.equal(result, null);
});
// Additional edge cases
test("parseStreamJsonEvent: system event returns null", () => {
const result = parseStreamJsonEvent({ type: "system", subtype: "init" }, true);
assert.equal(result, null);
});
test("parseStreamJsonEvent: user event returns null", () => {
const result = parseStreamJsonEvent({ type: "user", message: {} }, true);
assert.equal(result, null);
});
test("parseStreamJsonEvent: stream_event non-text-delta (content_block_start) returns null", () => {
const event = { type: "stream_event", event: { type: "content_block_start", index: 0 } };
const result = parseStreamJsonEvent(event, true);
assert.equal(result, null);
});
test("parseStreamJsonEvent: unknown event type returns null", () => {
const result = parseStreamJsonEvent({ type: "future_event_type" }, false);
assert.equal(result, null);
});
// ── Cleanup ──
closeDb();