mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb |
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v3.19.0 — 2026-06-02
|
||||||
|
|
||||||
|
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
|
||||||
|
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
|
||||||
|
|
||||||
## v3.18.0 — 2026-06-01
|
## v3.18.0 — 2026-06-01
|
||||||
|
|
||||||
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
||||||
|
|||||||
@@ -961,6 +961,7 @@ Then restart OCP. At boot you will see:
|
|||||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
|
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
|
||||||
### Kill-switch
|
### Kill-switch
|
||||||
|
|||||||
+109
-24
@@ -39,11 +39,47 @@ export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
|||||||
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||||
|
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||||
|
function tuiCapturePane(tmux, tmuxName) {
|
||||||
|
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||||
|
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once claude's input bar is rendered and ready for keystrokes.
|
||||||
|
function tuiInputReady(pane) {
|
||||||
|
return /\? for shortcuts/.test(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||||
|
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||||
|
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||||
|
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||||
|
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||||
|
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||||
|
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||||
|
function tuiPromptLanded(pane, prompt) {
|
||||||
|
const flatPane = pane.replace(/\s+/g, " ");
|
||||||
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
|
return needle.length >= 3 && flatPane.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Single-quote escaper for sh -c arguments.
|
// Single-quote escaper for sh -c arguments.
|
||||||
function shq(s) {
|
function shq(s) {
|
||||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||||
@@ -149,8 +185,35 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
|||||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||||
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||||
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||||
function buildTuiCmd(claudeBin, model, sessionId) {
|
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||||
|
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||||
|
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||||
|
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||||
|
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
|
||||||
|
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
|
||||||
|
// billing pool (set below per entrypointMode).
|
||||||
|
//
|
||||||
|
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
|
||||||
|
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
|
||||||
|
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||||
|
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||||
|
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||||
|
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||||
|
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||||
|
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||||
|
const sets = [
|
||||||
|
`HOME=${shq(ehome)}`,
|
||||||
|
"DISABLE_AUTOUPDATER=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||||
|
];
|
||||||
|
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||||
|
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||||
|
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||||
|
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
|
||||||
return [
|
return [
|
||||||
|
envPrefix,
|
||||||
shq(claudeBin),
|
shq(claudeBin),
|
||||||
"--model", shq(model),
|
"--model", shq(model),
|
||||||
"--session-id", sessionId,
|
"--session-id", sessionId,
|
||||||
@@ -162,9 +225,14 @@ function buildTuiCmd(claudeBin, model, sessionId) {
|
|||||||
// Full per-request TUI lifecycle:
|
// Full per-request TUI lifecycle:
|
||||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||||
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
|
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||||
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
|
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||||
|
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||||
|
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||||
|
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||||
|
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||||
|
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||||
// marker or wall-clock cap.
|
// marker or wall-clock cap.
|
||||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||||
@@ -215,35 +283,52 @@ export async function runTuiTurn({
|
|||||||
// is a no-op when the session never existed).
|
// is a no-op when the session never existed).
|
||||||
const spawnResult = tmux(
|
const spawnResult = tmux(
|
||||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
buildTuiCmd(claudeBin, model, sessionId)],
|
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||||
{ env },
|
{ env },
|
||||||
);
|
);
|
||||||
if (!spawnResult || spawnResult.status !== 0) {
|
if (!spawnResult || spawnResult.status !== 0) {
|
||||||
throw new Error("tui_spawn_failed: tmux session not created");
|
throw new Error("tui_spawn_failed: tmux session not created");
|
||||||
}
|
}
|
||||||
await sleep(BOOT_MS);
|
|
||||||
|
|
||||||
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||||
//
|
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||||
// The `-l` (literal) flag is required on the paste send-keys call so that
|
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||||
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
if (!ready) {
|
||||||
// is typed literally as text rather than being interpreted as a key binding.
|
// (readiness timed out; relying on paste-verify)
|
||||||
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
console.error("[tui] input_not_ready", tmuxName);
|
||||||
// real keypress (carriage return) to submit the prompt line.
|
}
|
||||||
spawnSync(
|
|
||||||
"sh",
|
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||||
{ env, encoding: "utf8" },
|
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||||
);
|
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||||
await sleep(PASTE_SETTLE_MS);
|
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||||
|
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||||
|
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||||
|
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||||
|
// per-session tmuxName, so concurrent turns never collide.
|
||||||
|
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||||
|
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||||
|
|
||||||
|
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||||
|
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||||
|
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||||
|
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||||
|
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||||
|
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||||
|
if (!landed) {
|
||||||
|
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit (separate Enter key event).
|
||||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||||
// Returns { text, entrypoint } from readTuiTranscript.
|
// Returns { text, entrypoint } from readTuiTranscript.
|
||||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||||
} finally {
|
} finally {
|
||||||
// 4. Teardown — always, even on throw.
|
// 5. Teardown — always, even on throw.
|
||||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -51,19 +51,29 @@ export function parseTranscriptLines(text) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A line marks the assistant turn complete when it is the turn_duration system
|
// A line marks the assistant turn complete when EITHER:
|
||||||
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||||
|
// (e.g. 2.1.159), OR
|
||||||
|
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||||
|
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||||
|
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||||
|
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||||
|
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||||
|
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||||
//
|
//
|
||||||
// Why tool_use is NOT a terminal marker:
|
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||||
// In interactive claude, when the model decides to call a tool (stop_reason=
|
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||||
// "tool_use"), claude handles the tool call internally and then continues
|
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||||
// generating — the turn is NOT complete. The transcript advances to another
|
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||||
// assistant entry after the tool result. Only {type:"system",
|
// cross-version completion detection without bringing that bug back.)
|
||||||
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||||
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
|
||||||
export function isTerminalLine(obj) {
|
export function isTerminalLine(obj) {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
if (!obj || typeof obj !== "object") return false;
|
||||||
return obj.type === "system" && obj.subtype === "turn_duration";
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||||
|
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
||||||
|
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.18.0",
|
"version": "3.19.0",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+90
-1
@@ -1387,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
|
|||||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||||
});
|
});
|
||||||
|
// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit
|
||||||
|
// turn_duration mark turn-end via assistant message.stop_reason — must be terminal.
|
||||||
|
test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true);
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true);
|
||||||
|
});
|
||||||
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||||
const evs = [
|
const evs = [
|
||||||
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
@@ -1469,7 +1478,7 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||||
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
console.log("\nTUI session reaper:");
|
console.log("\nTUI session reaper:");
|
||||||
|
|
||||||
@@ -1477,6 +1486,26 @@ test("SESSION_PREFIX is ocp-tui-", () => {
|
|||||||
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("\nTUI command construction (proxy-purity / #4):");
|
||||||
|
|
||||||
|
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||||
|
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||||
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||||
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||||
|
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||||
|
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||||
|
assert.ok(cli.includes("CLAUDE_CODE_ENTRYPOINT=cli"), "cli mode labels the subscription pool");
|
||||||
|
assert.ok(cli.includes("--strict-mcp-config") && cli.includes('mcp__*'), "MCP wall retained");
|
||||||
|
// 'auto' mode must NOT pin the entrypoint (claude self-classifies via TTY).
|
||||||
|
const auto = buildTuiCmd("/usr/bin/claude", "m", "sid-3", "/home/u", "auto");
|
||||||
|
assert.ok(!/CLAUDE_CODE_ENTRYPOINT=/.test(auto), "auto mode leaves entrypoint unset");
|
||||||
|
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||||
|
});
|
||||||
|
|
||||||
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
const killed = [];
|
const killed = [];
|
||||||
const fakeTmux = (args) => {
|
const fakeTmux = (args) => {
|
||||||
@@ -1615,6 +1644,66 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TUI readiness / paste-verify predicates (issue #130) ────────────────────
|
||||||
|
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
|
||||||
|
// Keep in sync with the definitions there.
|
||||||
|
function _tuiInputReady(pane) {
|
||||||
|
return /\? for shortcuts/.test(pane);
|
||||||
|
}
|
||||||
|
function _tuiPromptLanded(pane, prompt) {
|
||||||
|
const flatPane = pane.replace(/\s+/g, " ");
|
||||||
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
|
return needle.length >= 3 && flatPane.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real captured pane samples (empirically confirmed via live capture-pane on PI231,
|
||||||
|
// claude v2.1.114 and v2.1.159). Source: issue #130 spec.
|
||||||
|
const TUI_READY_PANE = `❯ Try "how does <filepath> work?"
|
||||||
|
? for shortcuts · ← for agents`;
|
||||||
|
|
||||||
|
const TUI_LANDED_PANE = `❯ Reply with exactly: PONG_TEST
|
||||||
|
? for shortcuts · ← for agents`;
|
||||||
|
|
||||||
|
// Welcome splash shown before input bar is rendered — no `? for shortcuts`.
|
||||||
|
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`;
|
||||||
|
|
||||||
|
console.log("\nTUI readiness + paste-verify predicates (issue #130):");
|
||||||
|
|
||||||
|
test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
|
||||||
|
assert.equal(_tuiInputReady(TUI_READY_PANE), true);
|
||||||
|
});
|
||||||
|
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
|
||||||
|
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
|
||||||
|
});
|
||||||
|
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
|
||||||
|
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (still placeholder)", () => {
|
||||||
|
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "Reply with exactly: PONG_TEST"), false);
|
||||||
|
});
|
||||||
|
test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => {
|
||||||
|
assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true);
|
||||||
|
});
|
||||||
|
test("tuiPromptLanded(READY_PANE, 'ping') === false (needle <3 chars, placeholder present)", () => {
|
||||||
|
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
|
||||||
|
});
|
||||||
|
test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
|
||||||
|
assert.equal(_tuiPromptLanded("❯ ping\n ? for shortcuts", "ping"), true);
|
||||||
|
});
|
||||||
|
// issue #130 root cause: a big bracketed paste shows "[Pasted text #N +M lines]" — must be landed.
|
||||||
|
test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => {
|
||||||
|
assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
|
||||||
|
});
|
||||||
|
// issue #130 false-positive guard: the EMPTY placeholder uses a CURLY quote (“) and randomized
|
||||||
|
// example text — the old placeholder-gone heuristic wrongly reported landed=true here, so Enter
|
||||||
|
// fired into an empty box. Must be FALSE (no positive signal: not [Pasted text], prompt not shown).
|
||||||
|
test("tuiPromptLanded(curly-quote placeholder, big prompt) === false (no false-positive)", () => {
|
||||||
|
assert.equal(_tuiPromptLanded("❯ Try “how do I log an error?”\n ? for shortcuts", "[System] Context 0."), false);
|
||||||
|
});
|
||||||
|
|
||||||
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
|
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
|
||||||
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
|
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
|
||||||
// verbatim to avoid importing server.mjs (top-level server.listen() would
|
// verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||||
|
|||||||
Reference in New Issue
Block a user