diff --git a/lib/tui/session.mjs b/lib/tui/session.mjs index 3c0342d..8142402 100644 --- a/lib/tui/session.mjs +++ b/lib/tui/session.mjs @@ -168,6 +168,8 @@ function buildTuiCmd(claudeBin, model, sessionId) { // 5. Block on the native JSONL transcript (located by session-id) until terminal // marker or wall-clock cap. // 6. Always teardown: kill session + rm temp dir (even on throw). +// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool +// classifier, e.g. "cli", or null if the transcript did not include a turn_duration). export async function runTuiTurn({ prompt, model, @@ -238,6 +240,7 @@ export async function runTuiTurn({ tmux(["send-keys", "-t", tmuxName, "Enter"]); // 3. Block on the native transcript (resolved by session-id) until terminal. + // Returns { text, entrypoint } from readTuiTranscript. return await readTuiTranscript({ home: ehome, sessionId, wallclockMs }); } finally { // 4. Teardown — always, even on throw. diff --git a/lib/tui/transcript.mjs b/lib/tui/transcript.mjs index 773ce07..4df0cdf 100644 --- a/lib/tui/transcript.mjs +++ b/lib/tui/transcript.mjs @@ -104,8 +104,10 @@ export function verifyEntrypoint(events) { // 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. +// editors). Returns { text, entrypoint } where text is the latest assistant text +// and entrypoint is the billing-pool classifier from the turn_duration line (e.g. +// "cli"), or null if not yet present. On cap with text, returns the partial result; +// on cap with no text at all, throws. // // No quiescence heuristic by design: a long Opus thinking turn stalls transcript // growth and a "file stable for N s" rule would false-abort it (spec §4.3). @@ -115,15 +117,18 @@ export function verifyEntrypoint(events) { export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) { const deadline = Date.now() + wallclockMs; let lastText = ""; + let lastEntrypoint = null; while (Date.now() < deadline) { const resolved = p || findTranscriptPath(home, sessionId); if (resolved && existsSync(resolved)) { const events = parseTranscriptLines(readFileSync(resolved, "utf8")); lastText = extractLatestAssistantText(events) || lastText; - if (events.some(isTerminalLine)) return lastText; + const ep = verifyEntrypoint(events); + if (ep != null) lastEntrypoint = ep; + if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint }; } await sleep(pollMs); } - if (lastText) return lastText; + if (lastText) return { text: lastText, entrypoint: lastEntrypoint }; throw new Error("tui_transcript_timeout: no assistant text within wallclock cap"); } diff --git a/server.mjs b/server.mjs index 5b91bd2..559f853 100644 --- a/server.mjs +++ b/server.mjs @@ -299,12 +299,20 @@ 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 +// A bind address is "loopback" only if it cannot be reached from another host. +// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is +// network-exposed and must trigger the TUI LAN gate. (issue #115) +function isLoopbackBind(addr) { + return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" || + addr === "::ffff:127.0.0.1" || /^127\./.test(addr); +} + // 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). +// 2. a non-loopback BIND_ADDRESS — server is network-exposed; any reachable 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. @@ -317,11 +325,11 @@ 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") { +if (TUI_MODE && !isLoopbackBind(BIND_ADDRESS) && 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" + + `FATAL: CLAUDE_TUI_MODE=true with a non-loopback CLAUDE_BIND (${BIND_ADDRESS}) is unsafe.\n` + + " TUI runs interactive claude with operator filesystem access; network-exposed without\n" + + " per-request isolation means any reachable 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." ); @@ -925,8 +933,14 @@ function callClaudeTui(model, messages, _conversationId, _keyName) { cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS, entrypointMode: TUI_ENTRYPOINT, - }).then((text) => { + }).then(({ text, entrypoint }) => { recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level + // Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli + // (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still + // return text but cost money — warn loudly so it's visible. (issue #115) + if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") { + logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel }); + } return text; }).catch((err) => { recordModelError(cliModel, false); diff --git a/test-features.mjs b/test-features.mjs index 9066227..f4a465f 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1438,10 +1438,11 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p const p = `${dir}/s.jsonl`; tuiWriteFile(p, [ JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }), - JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }), + JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }), ].join("\n") + "\n"); const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 }); - assert.equal(out, "hello world"); + assert.equal(out.text, "hello world"); + assert.equal(out.entrypoint, "cli"); }); await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => { @@ -1449,7 +1450,12 @@ await asyncTest("readTuiTranscript honours wall-clock cap and returns partial te const p = `${dir}/s.jsonl`; tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n"); const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); - assert.equal(out, "partial"); + assert.equal(out.text, "partial"); +}); + +await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => { + const out = await readTuiTranscript({ transcriptPath: "./lib/tui/fixtures/complete-haiku.jsonl", wallclockMs: 2000, pollMs: 50 }); + assert.equal(out.entrypoint, "cli"); }); await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => { @@ -1600,7 +1606,7 @@ if (process.env.OCP_TUI_LIVE === "1") { cwd: `${process.env.HOME}/.ocp-tui/work`, wallclockMs: 120000, }); - assert.ok(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`); + assert.ok(/PONG/i.test(out.text), `expected PONG, got: ${out.text.slice(0, 200)}`); }); } else { test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { @@ -1792,6 +1798,42 @@ test("KEY_NAME_RE: 65-char string → invalid", () => { assert.ok(!KEY_NAME_RE.test("x".repeat(65))); }); +// ── isLoopbackBind helper (issue #115) ────────────────────────────────────── +// MIRRORS server.mjs isLoopbackBind — copied verbatim to avoid importing server.mjs +// (top-level server.listen() would start a live HTTP server). +// Keep in sync with the definition in server.mjs above the TUI gate block. +console.log("\nisLoopbackBind helper (issue #115):"); + +function isLoopbackBind(addr) { + return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" || + addr === "::ffff:127.0.0.1" || /^127\./.test(addr); +} + +test("isLoopbackBind: '127.0.0.1' → true", () => { + assert.equal(isLoopbackBind("127.0.0.1"), true); +}); +test("isLoopbackBind: '::1' → true", () => { + assert.equal(isLoopbackBind("::1"), true); +}); +test("isLoopbackBind: 'localhost' → true", () => { + assert.equal(isLoopbackBind("localhost"), true); +}); +test("isLoopbackBind: '127.0.0.5' → true (127.x.x.x range)", () => { + assert.equal(isLoopbackBind("127.0.0.5"), true); +}); +test("isLoopbackBind: '0.0.0.0' → false (any-interface)", () => { + assert.equal(isLoopbackBind("0.0.0.0"), false); +}); +test("isLoopbackBind: '192.168.1.5' → false (LAN IP)", () => { + assert.equal(isLoopbackBind("192.168.1.5"), false); +}); +test("isLoopbackBind: '::' → false (IPv6 any-interface)", () => { + assert.equal(isLoopbackBind("::"), false); +}); +test("isLoopbackBind: '100.64.0.1' → false (Tailscale IP)", () => { + assert.equal(isLoopbackBind("100.64.0.1"), false); +}); + // ── Cleanup ── closeDb();