mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5a742711 | ||
|
|
05a984df89 | ||
|
|
a30b20978c | ||
|
|
cd98b51b96 | ||
|
|
74260d7f6f |
@@ -2,6 +2,36 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v3.17.1 — 2026-05-31
|
||||
|
||||
### Fix — code-audit P1/P2 hardening
|
||||
|
||||
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||
|
||||
**Availability / correctness (P1):**
|
||||
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||
|
||||
**Security gates / cache integrity (P2):**
|
||||
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||
|
||||
---
|
||||
|
||||
## v3.17.0 — 2026-05-31
|
||||
|
||||
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||
|
||||
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||
|
||||
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||
|
||||
---
|
||||
|
||||
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||
|
||||
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
|
||||
|
||||
**Status:** Forward-looking planning doc (not yet a decision)
|
||||
**Date:** 2026-05-29
|
||||
**Audience:** future OCP maintainer / session picking up multi-tenant security work
|
||||
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this doc exists
|
||||
|
||||
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
|
||||
|
||||
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
|
||||
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
|
||||
|
||||
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
|
||||
|
||||
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
|
||||
|
||||
---
|
||||
|
||||
## 2. The multi-tenant gap (OCP-specific)
|
||||
|
||||
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
|
||||
|
||||
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
|
||||
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
|
||||
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
|
||||
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
|
||||
|
||||
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
|
||||
|
||||
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
|
||||
|
||||
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
|
||||
|
||||
### 3.1 Anthropic's design doesn't expect external sandboxing
|
||||
|
||||
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
|
||||
|
||||
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
|
||||
|
||||
### 3.2 `~/.claude.json` upstream status is "closed not planned"
|
||||
|
||||
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
|
||||
|
||||
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
|
||||
|
||||
### 3.3 The threat model doesn't justify the cost
|
||||
|
||||
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
|
||||
|
||||
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
|
||||
|
||||
### 3.4 OLP-specific reason that does NOT apply to OCP
|
||||
|
||||
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.1–3.3) are sufficient on their own.
|
||||
|
||||
---
|
||||
|
||||
## 4. Three viable approaches for OCP
|
||||
|
||||
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
|
||||
|
||||
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
|
||||
|
||||
Per-spawn setup:
|
||||
|
||||
```
|
||||
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
|
||||
mkdir -p $ephemeralRoot/.claude
|
||||
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
|
||||
HOME=$ephemeralRoot claude -p --output-format stream-json ...
|
||||
```
|
||||
|
||||
Mechanics:
|
||||
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
|
||||
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
|
||||
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
|
||||
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
|
||||
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
|
||||
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
|
||||
|
||||
5-minute spike before adopting:
|
||||
|
||||
```bash
|
||||
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
|
||||
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
|
||||
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
|
||||
```
|
||||
|
||||
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
|
||||
|
||||
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
|
||||
|
||||
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
|
||||
|
||||
```
|
||||
bwrap \
|
||||
--ro-bind / / \
|
||||
--tmpfs /home/<ocp-user> \
|
||||
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
|
||||
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
|
||||
--dev /dev --proc /proc --tmpfs /tmp \
|
||||
claude -p ...
|
||||
```
|
||||
|
||||
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
|
||||
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
|
||||
- ✅ `~/.ssh` and similar identity material absent from sandbox.
|
||||
|
||||
Trade-offs:
|
||||
- bwrap dependency: install `bubblewrap` apt package on host.
|
||||
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
|
||||
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
|
||||
|
||||
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
|
||||
|
||||
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
|
||||
|
||||
```
|
||||
mount -t overlay overlay \
|
||||
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
|
||||
/tmp/ocp-spawn/<reqId>/merged-claude
|
||||
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
|
||||
# After spawn: umount + rm -rf
|
||||
```
|
||||
|
||||
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
|
||||
|
||||
Trade-offs:
|
||||
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
|
||||
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
|
||||
|
||||
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
|
||||
|
||||
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-key isolation orthogonal layer
|
||||
|
||||
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
|
||||
|
||||
For that, two options compose with any of A/B/C:
|
||||
|
||||
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
|
||||
|
||||
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
|
||||
|
||||
```
|
||||
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
|
||||
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
...otherKeysWorkspaces, // all keys except current
|
||||
'/home/<ocp-user>/.ssh',
|
||||
'/home/<ocp-user>/.gnupg',
|
||||
'/home/<ocp-user>/.aws',
|
||||
],
|
||||
allowWrite: [ephemeralRoot, '/tmp'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
|
||||
|
||||
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
|
||||
|
||||
### 5.2 Per-OS-user OCP spawning
|
||||
|
||||
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
|
||||
|
||||
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
|
||||
|
||||
Trade-offs:
|
||||
- Setup script complexity (one-time per key).
|
||||
- Linux-only.
|
||||
- Doesn't fit Mac mini deployment.
|
||||
|
||||
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Trust model framing
|
||||
|
||||
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
|
||||
|
||||
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
|
||||
|
||||
| Tier | Trust Model | Sandbox requirement |
|
||||
|---|---|---|
|
||||
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
|
||||
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
|
||||
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
|
||||
|
||||
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation if/when this work starts
|
||||
|
||||
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
|
||||
- ~50 LOC, no apt deps, works on Mac mini + Linux
|
||||
- Solves the EROFS upgrade tax structurally
|
||||
- Closes cross-key OAuth-credential-file lateral read
|
||||
- Cost-effective hygiene improvement
|
||||
|
||||
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
|
||||
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
|
||||
- Default off (preserves Mac mini family deployment)
|
||||
- Operator opts in on Linux cloud deployments
|
||||
- Apt prereq documented in deployment guide
|
||||
|
||||
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
|
||||
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
|
||||
- Treat as separate ADR amendment with its own threat-model evidence
|
||||
|
||||
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
|
||||
|
||||
---
|
||||
|
||||
## 8. Authority citations
|
||||
|
||||
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
|
||||
|
||||
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
|
||||
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
|
||||
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
|
||||
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
|
||||
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
|
||||
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
|
||||
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
|
||||
- [Bubblewrap README](https://github.com/containers/bubblewrap)
|
||||
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
|
||||
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
|
||||
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
|
||||
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
|
||||
|
||||
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
|
||||
|
||||
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
|
||||
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
|
||||
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
|
||||
|
||||
---
|
||||
|
||||
## 9. What this doc is NOT
|
||||
|
||||
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
|
||||
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
|
||||
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
|
||||
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
|
||||
|
||||
---
|
||||
|
||||
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
|
||||
+7
-1
@@ -223,9 +223,15 @@ export async function runTuiTurn({
|
||||
|
||||
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
||||
//
|
||||
// The `-l` (literal) flag is required on the paste send-keys call so that
|
||||
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
||||
// is typed literally as text rather than being interpreted as a key binding.
|
||||
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
||||
// real keypress (carriage return) to submit the prompt line.
|
||||
spawnSync(
|
||||
"sh",
|
||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
||||
{ env, encoding: "utf8" },
|
||||
);
|
||||
await sleep(PASTE_SETTLE_MS);
|
||||
|
||||
+11
-5
@@ -52,12 +52,18 @@ export function parseTranscriptLines(text) {
|
||||
}
|
||||
|
||||
// A line marks the assistant turn complete when it is the turn_duration system
|
||||
// event, or an assistant message that stopped to hand off to a tool.
|
||||
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
||||
//
|
||||
// Why tool_use is NOT a terminal marker:
|
||||
// In interactive claude, when the model decides to call a tool (stop_reason=
|
||||
// "tool_use"), claude handles the tool call internally and then continues
|
||||
// generating — the turn is NOT complete. The transcript advances to another
|
||||
// assistant entry after the tool result. Only {type:"system",
|
||||
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
||||
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||
return sr === "tool_use";
|
||||
return obj.type === "system" && obj.subtype === "turn_duration";
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
@@ -96,7 +102,7 @@ export function verifyEntrypoint(events) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||
// Block until the session transcript is terminal (turn_duration) or
|
||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||
// partial text; on cap with no text at all, throws.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.16.4",
|
||||
"version": "3.17.1",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+144
-31
@@ -293,10 +293,15 @@ const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`
|
||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with multi-user auth. Under TUI a
|
||||
// guest/anonymous prompt would run interactive claude with the OPERATOR's full
|
||||
// filesystem access (home is NOT isolation). Refuse to boot until B-path isolation
|
||||
// (tools-off + per-key ephemeral home + sandbox) lands. See ADR 0007.
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
||||
// 2. BIND_ADDRESS=0.0.0.0 — server is LAN-exposed; any LAN peer can send prompts
|
||||
// unless per-request trust is in place. Override with OCP_TUI_ALLOW_LAN=1
|
||||
// ONLY if you have a separate network-layer trust (firewall, VPN).
|
||||
// 3. PROXY_ANONYMOUS_KEY set — anonymous callers can submit prompts without a key.
|
||||
// In all three cases TUI runs interactive claude with the OPERATOR's full filesystem
|
||||
// access — home is NOT isolation. Refuse to boot. See ADR 0007.
|
||||
if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true is incompatible with CLAUDE_AUTH_MODE=multi.\n" +
|
||||
@@ -306,6 +311,25 @@ if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (TUI_MODE && BIND_ADDRESS === "0.0.0.0" && process.env.OCP_TUI_ALLOW_LAN !== "1") {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true with CLAUDE_BIND=0.0.0.0 is unsafe.\n" +
|
||||
" TUI runs interactive claude with operator filesystem access; LAN-exposed without\n" +
|
||||
" per-request isolation means any LAN peer could drive the operator's claude session.\n" +
|
||||
" Either bind to 127.0.0.1 (default) or set OCP_TUI_ALLOW_LAN=1 if you have a\n" +
|
||||
" separate network-layer trust (firewall/VPN). See docs/adr/0007-tui-interactive-mode.md."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true with PROXY_ANONYMOUS_KEY set is unsafe.\n" +
|
||||
" TUI runs interactive claude with operator filesystem access; anonymous callers\n" +
|
||||
" could drive the operator's claude session without a named key.\n" +
|
||||
" Remove PROXY_ANONYMOUS_KEY or disable TUI-mode. See docs/adr/0007-tui-interactive-mode.md."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
|
||||
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
|
||||
@@ -566,7 +590,27 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
];
|
||||
|
||||
// Permissions
|
||||
if (SKIP_PERMISSIONS) {
|
||||
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
|
||||
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
|
||||
// For AUTH_MODE !== "multi" (none/shared — single-operator/trusted), preserve
|
||||
// existing behaviour unchanged.
|
||||
if (AUTH_MODE === "multi") {
|
||||
// Disallow the full operator-FS + web + agent surface. "--disallowedTools" may
|
||||
// be repeated; claude accepts multiple occurrences (TUI path already uses it).
|
||||
args.push(
|
||||
"--disallowedTools", "Bash",
|
||||
"--disallowedTools", "Read",
|
||||
"--disallowedTools", "Write",
|
||||
"--disallowedTools", "Edit",
|
||||
"--disallowedTools", "Glob",
|
||||
"--disallowedTools", "Grep",
|
||||
"--disallowedTools", "WebFetch",
|
||||
"--disallowedTools", "WebSearch",
|
||||
"--disallowedTools", "Agent",
|
||||
"--disallowedTools", "mcp__*",
|
||||
);
|
||||
// Do NOT push --allowedTools in multi mode.
|
||||
} else if (SKIP_PERMISSIONS) {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (ALLOWED_TOOLS.length > 0) {
|
||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||
@@ -730,6 +774,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guard stdin writes against EPIPE (child may close stdin before we finish
|
||||
// writing, e.g. early exit on bad model). The ChildProcess "error" event is on
|
||||
// the spawned process, NOT on the stdin Writable — it does not catch this.
|
||||
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
|
||||
|
||||
// Write prompt to stdin immediately
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
@@ -794,7 +843,7 @@ function callClaude(model, messages, conversationId, keyName) {
|
||||
resultEventSeen = true;
|
||||
} else if (parsed.error) {
|
||||
// is_error result — treat as process error
|
||||
reject(new Error(parsed.error));
|
||||
reject(new Error(String(parsed.error)));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -916,6 +965,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
let lineBuffer = "";
|
||||
let isFirstDelta = true;
|
||||
let resultEventSeen = false;
|
||||
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
|
||||
// If errored===true the close handler must not cache the response or record success
|
||||
// (mirrors callClaude which rejects and never caches on is_error).
|
||||
let errored = false;
|
||||
|
||||
function ensureHeaders() {
|
||||
if (res.writableEnded || res.destroyed) return false;
|
||||
@@ -977,12 +1030,14 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
}
|
||||
|
||||
} 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));
|
||||
// is_error result — emit error stop; do NOT set resultEventSeen (that would
|
||||
// cause the close handler to record success + write cache). Set errored instead.
|
||||
errored = true;
|
||||
const errStr = String(parsed.error);
|
||||
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
|
||||
trackError(errStr.slice(0, 200));
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } });
|
||||
jsonResponse(res, 500, { error: { message: errStr, type: "provider_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
@@ -1005,13 +1060,17 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
|
||||
// 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) {
|
||||
// Also route to the error path when errored===true (is_error result received):
|
||||
// never record success or write cache for an errored response.
|
||||
if ((code !== 0 && !resultEventSeen) || errored) {
|
||||
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) });
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, errored, stderr: stderr.slice(0, 300) });
|
||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
|
||||
// If the error was already sent inline (parsed.error branch above), the
|
||||
// response may be writableEnded — nothing more to send.
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
@@ -1027,7 +1086,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
breakerRecordSuccess(cliModel);
|
||||
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: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
// Cache write-back for streaming
|
||||
// Cache write-back for streaming — only on true success (not errored)
|
||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
@@ -1507,9 +1566,16 @@ async function handleSettings(req, res) {
|
||||
|
||||
// PATCH
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let updates;
|
||||
try { updates = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
@@ -1546,11 +1612,18 @@ const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
@@ -1796,6 +1869,12 @@ const server = createServer(async (req, res) => {
|
||||
req._authKeyName = authKeyName;
|
||||
req._authKeyId = authKeyId;
|
||||
|
||||
// isAdmin computed here (early, before any admin-gated handler) so that
|
||||
// DELETE /sessions, GET /logs, GET /usage, GET /status, PATCH /settings
|
||||
// can all gate on it. Localhost and explicit admin key are always admin;
|
||||
// in multi-tenant mode only the "admin" named key qualifies.
|
||||
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||
|
||||
// GET /v1/models
|
||||
if (req.url === "/v1/models" && req.method === "GET") {
|
||||
return jsonResponse(res, 200, {
|
||||
@@ -1857,15 +1936,17 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /sessions — clear all sessions
|
||||
// DELETE /sessions — clear all sessions (mutating; admin only)
|
||||
if (req.url === "/sessions" && req.method === "DELETE") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
const count = sessions.size;
|
||||
sessions.clear();
|
||||
return jsonResponse(res, 200, { cleared: count });
|
||||
}
|
||||
|
||||
// GET /sessions — list active sessions
|
||||
// GET /sessions — list active sessions (operator data; admin only)
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||
@@ -1875,34 +1956,45 @@ const server = createServer(async (req, res) => {
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// GET /usage — fetch plan usage limits from Anthropic API
|
||||
// GET /usage — fetches plan usage from Anthropic API with operator token; admin only
|
||||
if (req.url === "/usage" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleUsage(req, res);
|
||||
}
|
||||
|
||||
// GET /logs — recent proxy log entries (errors and key events)
|
||||
// GET /logs — recent proxy log entries (errors and key events); admin only
|
||||
if (req.url?.startsWith("/logs") && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleLogs(req, res);
|
||||
}
|
||||
|
||||
// GET /status — combined usage + health summary
|
||||
// GET /status — combined usage + health summary; uses operator token; admin only
|
||||
if (req.url === "/status" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleStatus(req, res);
|
||||
}
|
||||
|
||||
// GET /settings — view current tunable settings
|
||||
// PATCH /settings — update settings at runtime (JSON body)
|
||||
// GET /settings — view current tunable settings (admin only)
|
||||
// PATCH /settings — update settings at runtime (JSON body; admin only, mutating)
|
||||
if (req.url === "/settings" && (req.method === "GET" || req.method === "PATCH")) {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleSettings(req, res);
|
||||
}
|
||||
|
||||
// ── Key management API ──
|
||||
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||
// (isAdmin is computed early in the request handler, before the admin-gated routes)
|
||||
|
||||
if (req.url === "/api/keys" && req.method === "POST") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
try {
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
const name = parsed.name || `key-${Date.now()}`;
|
||||
@@ -1928,7 +2020,14 @@ const server = createServer(async (req, res) => {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
|
||||
let body = "";
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
try {
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let quotaBody;
|
||||
try { quotaBody = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
// Validate quota values: must be positive integers or null
|
||||
@@ -2032,6 +2131,20 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
|
||||
|
||||
// ── Process-level safety nets ────────────────────────────────────────────
|
||||
// Prevent unhandled async rejections and synchronous exceptions from crashing
|
||||
// the daemon. Each registers once at module level so they are installed before
|
||||
// the first request arrives. These are global no-ops on the happy path.
|
||||
process.on("unhandledRejection", (e) =>
|
||||
logEvent("error", "unhandled_rejection", { error: e && e.message ? e.message : String(e) })
|
||||
);
|
||||
process.on("uncaughtException", (e) =>
|
||||
logEvent("error", "uncaught_exception", { error: e && e.message ? e.message : String(e) })
|
||||
);
|
||||
// Destroy the socket on low-level HTTP parse errors so broken connections
|
||||
// don't accumulate as open file descriptors.
|
||||
server.on("clientError", (err, socket) => { try { socket.destroy(); } catch {} });
|
||||
|
||||
// ── Graceful shutdown ────────────────────────────────────────────────────
|
||||
let shuttingDown = false;
|
||||
|
||||
|
||||
+4
-4
@@ -1309,11 +1309,11 @@ test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||
test("isTerminalLine true on turn_duration", () => {
|
||||
assert.equal(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||
});
|
||||
test("isTerminalLine true on stop_reason tool_use (message-wrapped)", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||
test("isTerminalLine false on stop_reason tool_use (message-wrapped) — tool_use is mid-turn in TUI mode", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), false);
|
||||
});
|
||||
test("isTerminalLine true on stop_reason tool_use (flat)", () => {
|
||||
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||
test("isTerminalLine false on stop_reason tool_use (flat) — claude continues after tool, turn not done", () => {
|
||||
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), false);
|
||||
});
|
||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
|
||||
Reference in New Issue
Block a user